diff --git a/package-lock.json b/package-lock.json index 3cd735469c..77d8228531 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "alkemio-server", - "version": "0.80.0", + "version": "0.81.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "alkemio-server", - "version": "0.80.0", + "version": "0.81.0", "license": "EUPL-1.2", "dependencies": { "@alkemio/matrix-adapter-lib": "^0.3.6", diff --git a/package.json b/package.json index 00a5a0fa78..96040145fd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "alkemio-server", - "version": "0.80.0", + "version": "0.81.0", "description": "Alkemio server, responsible for managing the shared Alkemio platform", "author": "Alkemio Foundation", "private": false, diff --git a/quickstart-services-ai.yml b/quickstart-services-ai.yml index cfee6940dd..f4758e8411 100644 --- a/quickstart-services-ai.yml +++ b/quickstart-services-ai.yml @@ -439,7 +439,7 @@ services: - 'host.docker.internal:host-gateway' container_name: alkemio_dev_virtual-contributor-ingest-space hostname: virtual-contributor-ingest-space - image: alkemio/virtual-contributor-ingest-space:v0.4.2 + image: alkemio/virtual-contributor-ingest-space:v0.6.0 platform: linux/x86_64 restart: always volumes: diff --git a/src/common/constants/authorization/policy.rule.constants.ts b/src/common/constants/authorization/policy.rule.constants.ts index e61bda0ca6..ec0f9cc331 100644 --- a/src/common/constants/authorization/policy.rule.constants.ts +++ b/src/common/constants/authorization/policy.rule.constants.ts @@ -5,10 +5,8 @@ export const POLICY_RULE_WHITEBOARD_CONTENT_UPDATE = export const POLICY_RULE_VISUAL_UPDATE = 'policyRule-visualUpdate'; export const POLICY_RULE_ROOM_CONTRIBUTE = 'policyRule-roomContribute'; export const POLICY_RULE_ROOM_ADMINS = 'policyRule-roomAdminsCreate'; -export const POLICY_RULE_COMMUNICATION_CONTRIBUTE = - 'policyRule-communicateContribute'; -export const POLICY_RULE_COMMUNICATION_CREATE = - 'policyRule-communicationCreate'; +export const POLICY_RULE_FORUM_CONTRIBUTE = 'policyRule-forumContribute'; +export const POLICY_RULE_FORUM_CREATE = 'policyRule-forumCreate'; export const POLICY_RULE_PLATFORM_DELETE = 'policyRule-platformDelete'; export const POLICY_RULE_CALLOUT_CREATE = 'policyRule-calloutCreate'; export const POLICY_RULE_CALLOUT_CONTRIBUTE = 'policyRule-calloutContribute'; @@ -26,5 +24,6 @@ export const PRIVILEGE_RULE_TYPES_INNOVATION_FLOW_UPDATE = 'privilegeRuleTypes-innovationFlowUpdate'; export const PRIVILEGE_RULE_READ_USER_SETTINGS = 'privilegeRule-readUserSettings'; -export const POLICY_RULE_VC_ADD_TO_COMMUNITY = - 'policyRule-virtualContributorAddToCommunity'; +export const POLICY_RULE_COMMUNITY_INVITE_MEMBER = 'policyRule-communityInvite'; +export const POLICY_RULE_COMMUNITY_ADD_VC = + 'policyRule-communityAddVirtualContributor'; diff --git a/src/common/constants/providers.ts b/src/common/constants/providers.ts index 87d30fe924..cb3bfff606 100644 --- a/src/common/constants/providers.ts +++ b/src/common/constants/providers.ts @@ -2,6 +2,8 @@ export const SUBSCRIPTION_DISCUSSION_UPDATED = 'alkemio-subscriptions-discussion-updated'; export const SUBSCRIPTION_WHITEBOARD_CONTENT = 'alkemio-subscriptions-whiteboard-content'; +export const SUBSCRIPTION_WHITEBOARD_SAVED = + 'alkemio-subscriptions-whiteboard-saved'; export const SUBSCRIPTION_CALLOUT_POST_CREATED = 'alkemio-subscriptions-callout-post-created'; export const SUBSCRIPTION_PROFILE_VERIFIED_CREDENTIAL = diff --git a/src/common/enums/ai.persona.body.of.knowledge.type.ts b/src/common/enums/ai.persona.body.of.knowledge.type.ts new file mode 100644 index 0000000000..8ea835b5f7 --- /dev/null +++ b/src/common/enums/ai.persona.body.of.knowledge.type.ts @@ -0,0 +1,8 @@ +import { registerEnumType } from '@nestjs/graphql'; +export enum AiPersonaBodyOfKnowledgeType { + ALKEMIO_SPACE = 'space', // TODO: rename to alkemio-space as value + OTHER = 'other', +} +registerEnumType(AiPersonaBodyOfKnowledgeType, { + name: 'AiPersonaBodyOfKnowledgeType', +}); diff --git a/src/common/enums/virtual.persona.access.mode.ts b/src/common/enums/ai.persona.data.access.mode.ts similarity index 59% rename from src/common/enums/virtual.persona.access.mode.ts rename to src/common/enums/ai.persona.data.access.mode.ts index e6ab1f4c92..3ab8110db9 100644 --- a/src/common/enums/virtual.persona.access.mode.ts +++ b/src/common/enums/ai.persona.data.access.mode.ts @@ -1,11 +1,11 @@ import { registerEnumType } from '@nestjs/graphql'; -export enum VirtualPersonaAccessMode { +export enum AiPersonaDataAccessMode { NONE = 'none', SPACE_PROFILE = 'space_profile', SPACE_PROFILE_AND_CONTENTS = 'space_profile_and_contents', } -registerEnumType(VirtualPersonaAccessMode, { - name: 'VirtualPersonaAccessMode', +registerEnumType(AiPersonaDataAccessMode, { + name: 'AiPersonaDataAccessMode', }); diff --git a/src/common/enums/virtual.contributor.engine.ts b/src/common/enums/ai.persona.engine.ts similarity index 55% rename from src/common/enums/virtual.contributor.engine.ts rename to src/common/enums/ai.persona.engine.ts index 6d348c455a..a94bd19a7d 100644 --- a/src/common/enums/virtual.contributor.engine.ts +++ b/src/common/enums/ai.persona.engine.ts @@ -1,11 +1,11 @@ import { registerEnumType } from '@nestjs/graphql'; -export enum VirtualContributorEngine { +export enum AiPersonaEngine { GUIDANCE = 'guidance', EXPERT = 'expert', COMMUNITY_MANAGER = 'community-manager', } -registerEnumType(VirtualContributorEngine, { - name: 'VirtualContributorEngine', +registerEnumType(AiPersonaEngine, { + name: 'AiPersonaEngine', }); diff --git a/src/common/enums/ai.persona.interaction.mode.ts b/src/common/enums/ai.persona.interaction.mode.ts new file mode 100644 index 0000000000..e96b88e9f5 --- /dev/null +++ b/src/common/enums/ai.persona.interaction.mode.ts @@ -0,0 +1,9 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum AiPersonaInteractionMode { + DISCUSSION_TAGGING = 'discussion-tagging', +} + +registerEnumType(AiPersonaInteractionMode, { + name: 'AiPersonaInteractionMode', +}); diff --git a/src/common/enums/ai.server.authorization.privilege.ts b/src/common/enums/ai.server.authorization.privilege.ts new file mode 100644 index 0000000000..85ddcf8350 --- /dev/null +++ b/src/common/enums/ai.server.authorization.privilege.ts @@ -0,0 +1,9 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum AiServerAuthorizationPrivilege { + AI_SERVER_ADMIN = 'ai-server-admin', +} + +registerEnumType(AiServerAuthorizationPrivilege, { + name: 'AiServerAuthorizationPrivilege', +}); diff --git a/src/common/enums/ai.server.role.ts b/src/common/enums/ai.server.role.ts new file mode 100644 index 0000000000..2bcef0e296 --- /dev/null +++ b/src/common/enums/ai.server.role.ts @@ -0,0 +1,10 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum AiServerRole { + GLOBAL_ADMIN = 'global-admin', + SUPPORT = 'support', +} + +registerEnumType(AiServerRole, { + name: 'AiServerRole', +}); diff --git a/src/common/enums/alkemio.error.status.ts b/src/common/enums/alkemio.error.status.ts index 30c00ba9ca..adbc1134ec 100644 --- a/src/common/enums/alkemio.error.status.ts +++ b/src/common/enums/alkemio.error.status.ts @@ -54,7 +54,7 @@ export enum AlkemioErrorStatus { GEO_SERVICE_ERROR = 'GEO_SERVICE_ERROR', GEO_SERVICE_REQUEST_LIMIT_EXCEEDED = 'GEO_SERVICE_REQUEST_LIMIT_EXCEEDED', API_RESTRICTED_ACCESS = 'API_RESTRICTED_ACCESS', - COMMUNICATION_DISCUSSION_CATEGORY = 'COMMUNICATION_DISCUSSION_CATEGORY', + FORUM_DISCUSSION_CATEGORY = 'FORUM_DISCUSSION_CATEGORY', OPERATION_NOT_ALLOWED = 'OPERATION_NOT_ALLOWED', NOT_FOUND = 'NOT_FOUND', IPFS_NOT_FOUND = 'IPFS_NOT_FOUND', diff --git a/src/common/enums/communication.discussion.category.community.ts b/src/common/enums/communication.discussion.category.community.ts deleted file mode 100644 index c2e0500a47..0000000000 --- a/src/common/enums/communication.discussion.category.community.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { registerEnumType } from '@nestjs/graphql'; - -// Credentials to be added later: -export enum DiscussionCategoryCommunity { - GENERAL = 'general', - IDEAS = 'ideas', - QUESTIONS = 'questions', - SHARING = 'sharing', -} - -registerEnumType(DiscussionCategoryCommunity, { - name: 'DiscussionCategoryCommunity', -}); diff --git a/src/common/enums/communication.discussion.category.ts b/src/common/enums/communication.discussion.category.ts deleted file mode 100644 index 12adbfceb8..0000000000 --- a/src/common/enums/communication.discussion.category.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { registerEnumType } from '@nestjs/graphql'; -import { DiscussionCategoryCommunity } from './communication.discussion.category.community'; -import { DiscussionCategoryPlatform } from './communication.discussion.category.platform'; - -export const DiscussionCategory = { - ...DiscussionCategoryCommunity, - ...DiscussionCategoryPlatform, -}; - -export type DiscussionCategory = - | DiscussionCategoryCommunity - | DiscussionCategoryPlatform; - -registerEnumType(DiscussionCategory, { - name: 'DiscussionCategory', -}); diff --git a/src/common/enums/community.contributor.type.ts b/src/common/enums/community.contributor.type.ts index 105e42a404..e548620664 100644 --- a/src/common/enums/community.contributor.type.ts +++ b/src/common/enums/community.contributor.type.ts @@ -1,5 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql'; + export enum CommunityContributorType { USER = 'user', ORGANIZATION = 'organization', VIRTUAL = 'virtual', } + +registerEnumType(CommunityContributorType, { + name: 'CommunityContributorType', +}); diff --git a/src/common/enums/communication.discussion.category.platform.ts b/src/common/enums/forum.discussion.category.ts similarity index 70% rename from src/common/enums/communication.discussion.category.platform.ts rename to src/common/enums/forum.discussion.category.ts index 8694b805b2..75c74f0011 100644 --- a/src/common/enums/communication.discussion.category.platform.ts +++ b/src/common/enums/forum.discussion.category.ts @@ -1,7 +1,7 @@ import { registerEnumType } from '@nestjs/graphql'; // Credentials to be added later: -export enum DiscussionCategoryPlatform { +export enum ForumDiscussionCategory { RELEASES = 'releases', PLATFORM_FUNCTIONALITIES = 'platform-functionalities', COMMUNITY_BUILDING = 'community-building', @@ -10,6 +10,6 @@ export enum DiscussionCategoryPlatform { OTHER = 'other', } -registerEnumType(DiscussionCategoryPlatform, { - name: 'DiscussionCategoryPlatform', +registerEnumType(ForumDiscussionCategory, { + name: 'ForumDiscussionCategory', }); diff --git a/src/common/enums/forum.discussion.privacy.ts b/src/common/enums/forum.discussion.privacy.ts new file mode 100644 index 0000000000..fc8fc93aa2 --- /dev/null +++ b/src/common/enums/forum.discussion.privacy.ts @@ -0,0 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum ForumDiscussionPrivacy { + AUTHOR = 'author', + AUTHENTICATED = 'authenticated', + PUBLIC = 'public', +} + +registerEnumType(ForumDiscussionPrivacy, { + name: 'ForumDiscussionPrivacy', +}); diff --git a/src/common/enums/license.credential.ts b/src/common/enums/license.credential.ts index d0040b3f24..c14118ea6b 100644 --- a/src/common/enums/license.credential.ts +++ b/src/common/enums/license.credential.ts @@ -6,6 +6,9 @@ export enum LicenseCredential { LICENSE_SPACE_PLUS = 'license-space-plus', LICENSE_SPACE_PREMIUM = 'license-space-premium', LICENSE_SPACE_ENTERPRISE = 'license-space-enterprise', + FEATURE_CALLOUT_TO_CALLOUT_TEMPLATE = 'feature-callout-to-callout-template', + FEATURE_VIRTUAL_CONTRIBUTORS = 'feature-virtual-contributors', + FEATURE_WHITEBOARD_MULTI_USER = 'feature-whiteboard-multi-user', } registerEnumType(LicenseCredential, { diff --git a/src/common/enums/license.feature.flag.name.ts b/src/common/enums/license.feature.flag.name.ts deleted file mode 100644 index 57c74a99c4..0000000000 --- a/src/common/enums/license.feature.flag.name.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { registerEnumType } from '@nestjs/graphql'; - -export enum LicenseFeatureFlagName { - WHITEBOARD_MULTI_USER = 'whiteboard-multi-user', - CALLOUT_TO_CALLOUT_TEMPLATE = 'callout-to-callout-template', - VIRTUAL_CONTRIBUTORS = 'virtual-contributors', -} - -registerEnumType(LicenseFeatureFlagName, { - name: 'LicenseFeatureFlagName', -}); diff --git a/src/common/enums/license.plan.type.ts b/src/common/enums/license.plan.type.ts new file mode 100644 index 0000000000..e6a85c8fa0 --- /dev/null +++ b/src/common/enums/license.plan.type.ts @@ -0,0 +1,10 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum LicensePlanType { + SPACE_PLAN = 'space-plan', + SPACE_FEATURE_FLAG = 'space-feature-flag', +} + +registerEnumType(LicensePlanType, { + name: 'LicensePlanType', +}); diff --git a/src/common/enums/logging.context.ts b/src/common/enums/logging.context.ts index c39e66a08b..49a4222e85 100644 --- a/src/common/enums/logging.context.ts +++ b/src/common/enums/logging.context.ts @@ -9,6 +9,7 @@ export enum LogContext { COMMUNITY = 'community', DATA_LOADER = 'data-loader', COMMUNICATION = 'communication', + PLATFORM_FORUM = 'platform-forum', COMMUNICATION_EVENTS = 'communication_events', COLLABORATION = 'collaboration', AGENT = 'agent', @@ -62,4 +63,6 @@ export enum LogContext { LOCAL_STORAGE = 'local-storage', INNOVATION_FLOW = 'innovation-flow', FILE_INTEGRATION = 'file-integration', + AI_SERVER = 'ai-server', + AI_PERSONA_SERVICE = 'ai-persona-service', } diff --git a/src/common/enums/messaging.queue.ts b/src/common/enums/messaging.queue.ts index 9638f843e7..87ec8ba92b 100644 --- a/src/common/enums/messaging.queue.ts +++ b/src/common/enums/messaging.queue.ts @@ -12,6 +12,7 @@ export enum MessagingQueue { EXCALIDRAW_EVENTS = 'alkemio-excalidraw-events', // SUBSCRIPTION_WHITEBOARD_CONTENT = 'alkemio-subscriptions-whiteboard-content', + SUBSCRIPTION_WHITEBOARD_SAVED = 'alkemio-subscriptions-whiteboard-saved', SUBSCRIPTION_PROFILE_VERIFIED_CREDENTIAL = 'alkemio-subscriptions-profile-verified-credential', SUBSCRIPTION_CALLOUT_POST_CREATED = 'alkemio-subscriptions-callout-post-created', SUBSCRIPTION_DISCUSSION_UPDATED = 'alkemio-subscriptions-discussion-updated', diff --git a/src/common/enums/mime.file.type.document.ts b/src/common/enums/mime.file.type.document.ts index 553e764af0..9ed52eca56 100644 --- a/src/common/enums/mime.file.type.document.ts +++ b/src/common/enums/mime.file.type.document.ts @@ -2,6 +2,14 @@ import { registerEnumType } from '@nestjs/graphql'; export enum MimeTypeDocument { PDF = 'application/pdf', + + XLS = 'application/vnd.ms-excel', + XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ODS = 'application/vnd.oasis.opendocument.spreadsheet', + + DOC = 'application/msword', + DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ODT = 'application/vnd.oasis.opendocument.text', } registerEnumType(MimeTypeDocument, { diff --git a/src/common/enums/mime.file.type.ts b/src/common/enums/mime.file.type.ts index 22512707eb..e116aff887 100644 --- a/src/common/enums/mime.file.type.ts +++ b/src/common/enums/mime.file.type.ts @@ -9,6 +9,8 @@ export const MimeFileType = { export type MimeFileType = MimeTypeVisual | MimeTypeDocument; +export const DEFAULT_ALLOWED_MIME_TYPES = Object.values(MimeFileType); + registerEnumType(MimeFileType, { name: 'MimeType', }); diff --git a/src/common/enums/search.visibility.ts b/src/common/enums/search.visibility.ts new file mode 100644 index 0000000000..806ddc64aa --- /dev/null +++ b/src/common/enums/search.visibility.ts @@ -0,0 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum SearchVisibility { + HIDDEN = 'hidden', // only shows up when directly accessed e.g. by provider + ACCOUNT = 'account', // only shows up on searches within the scope of an account + PUBLIC = 'public', // shows up globally +} + +registerEnumType(SearchVisibility, { + name: 'SearchVisibility', +}); diff --git a/src/common/enums/space.level.ts b/src/common/enums/space.level.ts index 37b6c59d53..f7e9cc5bf4 100644 --- a/src/common/enums/space.level.ts +++ b/src/common/enums/space.level.ts @@ -1,5 +1,11 @@ +import { registerEnumType } from '@nestjs/graphql'; + export enum SpaceLevel { SPACE = 0, CHALLENGE = 1, OPPORTUNITY = 2, } + +registerEnumType(SpaceLevel, { + name: 'SpaceLevel', +}); diff --git a/src/common/enums/space.type.ts b/src/common/enums/space.type.ts index 30296c0e41..821cc62ab1 100644 --- a/src/common/enums/space.type.ts +++ b/src/common/enums/space.type.ts @@ -4,6 +4,8 @@ export enum SpaceType { SPACE = 'space', CHALLENGE = 'challenge', OPPORTUNITY = 'opportunity', + VIRTUAL_CONTRIBUTOR = 'vc', + BLANK_SLATE = 'blank-slate', } registerEnumType(SpaceType, { diff --git a/src/common/enums/subscription.type.ts b/src/common/enums/subscription.type.ts index 332892be50..c95e225ae4 100644 --- a/src/common/enums/subscription.type.ts +++ b/src/common/enums/subscription.type.ts @@ -1,7 +1,8 @@ export enum SubscriptionType { COMMUNICATION_ROOM_MESSAGE_RECEIVED = 'communicationRoomMessageReceived', // todo remove - COMMUNICATION_DISCUSSION_UPDATED = 'communicationDiscussionUpdated', + FORUM_DISCUSSION_UPDATED = 'forumDiscussionUpdated', WHITEBOARD_CONTENT_UPDATED = 'whiteboardContentUpdated', + WHITEBOARD_SAVED = 'whiteboardSaved', PROFILE_VERIFIED_CREDENTIAL = 'profileVerifiedCredential', CALLOUT_POST_CREATED = 'calloutPostCreated', SUBSPACE_CREATED = 'subspaceCreated', diff --git a/src/common/enums/virtual.contributor.body.of.knowledge.type.ts b/src/common/enums/virtual.contributor.body.of.knowledge.type.ts deleted file mode 100644 index 3b65e26581..0000000000 --- a/src/common/enums/virtual.contributor.body.of.knowledge.type.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { registerEnumType } from '@nestjs/graphql'; -export enum BodyOfKnowledgeType { - SPACE = 'space', - OTHER = 'other', -} -registerEnumType(BodyOfKnowledgeType, { - name: 'BodyOfKnowledgeType', -}); diff --git a/src/common/exceptions/communication.discussion.category.exception.ts b/src/common/exceptions/forum.discussion.category.exception.ts similarity index 51% rename from src/common/exceptions/communication.discussion.category.exception.ts rename to src/common/exceptions/forum.discussion.category.exception.ts index 0804140e2f..718d86c4a7 100644 --- a/src/common/exceptions/communication.discussion.category.exception.ts +++ b/src/common/exceptions/forum.discussion.category.exception.ts @@ -1,12 +1,8 @@ import { LogContext, AlkemioErrorStatus } from '@common/enums'; import { BaseException } from './base.exception'; -export class CommunicationDiscussionCategoryException extends BaseException { +export class ForumDiscussionCategoryException extends BaseException { constructor(error: string, context: LogContext, code?: AlkemioErrorStatus) { - super( - error, - context, - code ?? AlkemioErrorStatus.COMMUNICATION_DISCUSSION_CATEGORY - ); + super(error, context, code ?? AlkemioErrorStatus.FORUM_DISCUSSION_CATEGORY); } } diff --git a/src/common/utils/match.enum.spec.ts b/src/common/utils/match.enum.spec.ts deleted file mode 100644 index 5369ea59c0..0000000000 --- a/src/common/utils/match.enum.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { LicenseFeatureFlagName } from '@common/enums/license.feature.flag.name'; -import { matchEnumString } from './match.enum'; -import { CalloutType } from '@common/enums/callout.type'; - -describe('matchEnumString function', () => { - it('should return a match for a valid input string', () => { - const inputString = 'whiteboard-multi-user'; - const matchResult = matchEnumString(LicenseFeatureFlagName, inputString); - - expect(matchResult).toEqual({ - key: 'WHITEBOARD_MULTI_USER', - value: 'whiteboard-multi-user', - }); - }); - - it('should return null for an invalid input string', () => { - const inputString = 'InvalidValue'; - const matchResult = matchEnumString(LicenseFeatureFlagName, inputString); - - expect(matchResult).toBeNull(); - }); - - it('should work with any TypeScript enum', () => { - const inputString = 'post'; - const matchResult = matchEnumString(CalloutType, inputString); - - expect(matchResult).toEqual({ key: 'POST', value: 'post' }); - }); -}); diff --git a/src/common/utils/match.enum.ts b/src/common/utils/match.enum.ts deleted file mode 100644 index 4a9669b53c..0000000000 --- a/src/common/utils/match.enum.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Matches a string to an enum member name and its corresponding value within a given TypeScript enum. - * - * @param {any} enumType - The TypeScript enum for which you want to find a match. - * @param {string} inputString - The input string that you want to match against the enum's values. - * @returns {{ key: string, value: any } | null} An object with matched enum member name and value, or null if no match is found. - * - * @example - * // Define an enum - * enum MyEnum { - * Option1 = 'Value1', - * Option2 = 'Value2', - * Option3 = 'Value3', - * } - * - * // Match the input string 'Value2' against the MyEnum enum - * const inputString = 'Value2'; - * const matchResult = matchEnumString(MyEnum, inputString); - * - * if (matchResult) { - * console.log(`Matched Enum Name: ${matchResult.key}, Enum Value: ${matchResult.value}`); - * } else { - * console.log('No match found.'); - * } - */ -export function matchEnumString( - enumType: any, - inputString: string -): { key: string; value: any } | null { - for (const key of Object.keys(enumType)) { - if (enumType[key] === inputString) { - return { key, value: enumType[key] }; - } - } - return null; // Return null if no match is found -} diff --git a/src/config/typeorm.cli.config.ts b/src/config/typeorm.cli.config.ts index c52fda58d8..35583c1a83 100644 --- a/src/config/typeorm.cli.config.ts +++ b/src/config/typeorm.cli.config.ts @@ -21,6 +21,7 @@ export const typeormCliConfig: MysqlConnectionOptions = { join('src', 'domain', '**', '*.entity.{ts,js}'), join('src', 'library', '**', '*.entity.{ts,js}'), join('src', 'platform', '**', '*.entity.{ts,js}'), + join('src', 'services', '**', '*.entity.{ts,js}'), ], migrations: [join('src', 'migrations', '*.{ts,js}')], migrationsTableName: 'migrations_typeorm', diff --git a/src/core/bootstrap/bootstrap.service.ts b/src/core/bootstrap/bootstrap.service.ts index 8d3d23e97b..088bca0c21 100644 --- a/src/core/bootstrap/bootstrap.service.ts +++ b/src/core/bootstrap/bootstrap.service.ts @@ -73,7 +73,7 @@ export class BootstrapService { await this.ensureSpaceSingleton(); await this.bootstrapProfiles(); await this.ensureSsiPopulated(); - await this.platformService.ensureCommunicationCreated(); + await this.platformService.ensureForumCreated(); // reset auth as last in the actions await this.ensureAuthorizationsPopulated(); // await this.ensureSpaceNamesInElastic(); diff --git a/src/core/dataloader/creators/loader.creators/account/account.license.loader.creator.ts b/src/core/dataloader/creators/loader.creators/account/account.license.loader.creator.ts index edd76d8f05..22eb7b7338 100644 --- a/src/core/dataloader/creators/loader.creators/account/account.license.loader.creator.ts +++ b/src/core/dataloader/creators/loader.creators/account/account.license.loader.creator.ts @@ -16,7 +16,7 @@ export class AccountLicenseLoaderCreator return createTypedRelationDataLoader( this.manager, Account, - { license: { featureFlags: true } }, + { license: true }, this.constructor.name, options ); diff --git a/src/core/license-engine/index.ts b/src/core/license-engine/index.ts index 3b20ff414b..67e562b03d 100644 --- a/src/core/license-engine/index.ts +++ b/src/core/license-engine/index.ts @@ -1 +1 @@ -export * from './license.policy.rule.feature.flag.interface'; +export * from './license.policy.rule.credential.interface'; diff --git a/src/core/license-engine/license.engine.service.ts b/src/core/license-engine/license.engine.service.ts index 98c912b294..0e389a3450 100644 --- a/src/core/license-engine/license.engine.service.ts +++ b/src/core/license-engine/license.engine.service.ts @@ -8,13 +8,11 @@ import { LogContext } from '@common/enums'; import { LicensePrivilege } from '@common/enums/license.privilege'; import { ILicensePolicy } from '@platform/license-policy/license.policy.interface'; import { ForbiddenLicensePolicyException } from '@common/exceptions/forbidden.license.policy.exception'; -import { ILicenseFeatureFlag } from '@domain/license/feature-flag/feature.flag.interface'; -import { ILicensePolicyRuleFeatureFlag } from './license.policy.rule.feature.flag.interface'; import { EntityManager } from 'typeorm'; import { InjectEntityManager } from '@nestjs/typeorm'; import { LicensePolicy } from '@platform/license-policy'; -import { ILicense } from '@domain/license/license/license.interface'; -import { License } from '@domain/license/license/license.entity'; +import { IAgent, ICredential } from '@domain/agent'; +import { ILicensePolicyCredentialRule } from './license.policy.rule.credential.interface'; @Injectable() export class LicenseEngineService { @@ -27,49 +25,47 @@ export class LicenseEngineService { public async grantAccessOrFail( privilegeRequired: LicensePrivilege, - license: ILicense, + agent: IAgent, msg: string, licensePolicy: ILicensePolicy | undefined ) { const accessGranted = await this.isAccessGranted( privilegeRequired, - license, + agent, licensePolicy ); if (accessGranted) return true; - const errorMsg = `License.engine: unable to grant '${privilegeRequired}' privilege: ${msg} license: ${license.id}`; + const errorMsg = `License.engine: unable to grant '${privilegeRequired}' privilege: ${msg} license: ${agent.id}`; // If you get to here then no match was found throw new ForbiddenLicensePolicyException( errorMsg, privilegeRequired, licensePolicy?.id || 'no license policy', - license.id + agent.id ); } public async isAccessGranted( privilegeRequired: LicensePrivilege, - license: ILicense, + agent: IAgent, licensePolicy?: ILicensePolicy | undefined ): Promise { const policy = await this.getLicensePolicyOrFail(licensePolicy); - const featureFlags = await this.getLicenseFeatureFlags(license); + const credentials = await this.getCredentialsFromAgent(agent); - const featureFlagRules = this.convertFeatureFlagRulesStr( - policy.featureFlagRules + const credentialRules = this.convertCredentialRulesStr( + policy.credentialRulesStr ); - for (const rule of featureFlagRules) { - for (const featureFlag of featureFlags) { - if (featureFlag.name === rule.featureFlagName) { - if (featureFlag.enabled) { - if (rule.grantedPrivileges.includes(privilegeRequired)) { - this.logger.verbose?.( - `[FeatureFlagRule] Granted privilege '${privilegeRequired}' using rule '${rule.name}'`, - LogContext.LICENSE - ); - return true; - } + for (const credentialRule of credentialRules) { + for (const credential of credentials) { + if (credential.type === credentialRule.credentialType) { + if (credentialRule.grantedPrivileges.includes(privilegeRequired)) { + this.logger.verbose?.( + `[CredentialRule] Granted privilege '${privilegeRequired}' using rule '${credentialRule.name}'`, + LogContext.LICENSE + ); + return true; } } } @@ -77,6 +73,17 @@ export class LicenseEngineService { return false; } + private async getCredentialsFromAgent(agent: IAgent): Promise { + const credentials = agent.credentials; + if (!credentials) { + throw new EntityNotFoundException( + `Unable to find credentials on agent ${agent.id}`, + LogContext.LICENSE + ); + } + return credentials; + } + private async getLicensePolicyOrFail( licensePolicy?: ILicensePolicy | undefined ): Promise { @@ -88,20 +95,20 @@ export class LicenseEngineService { } public async getGrantedPrivileges( - license: ILicense, + agent: IAgent, licensePolicy?: ILicensePolicy ) { const policy = await this.getLicensePolicyOrFail(licensePolicy); - const featureFlags = await this.getLicenseFeatureFlags(license); + const credentials = await this.getCredentialsFromAgent(agent); const grantedPrivileges: LicensePrivilege[] = []; - const featureFlagRules = this.convertFeatureFlagRulesStr( - policy.featureFlagRules + const credentialRules = this.convertCredentialRulesStr( + policy.credentialRulesStr ); - for (const rule of featureFlagRules) { - for (const featureFlag of featureFlags) { - if (rule.featureFlagName === featureFlag.name && featureFlag.enabled) { + for (const rule of credentialRules) { + for (const credential of credentials) { + if (rule.credentialType === credential.type) { for (const privilege of rule.grantedPrivileges) { grantedPrivileges.push(privilege); } @@ -116,9 +123,7 @@ export class LicenseEngineService { return uniquePrivileges; } - convertFeatureFlagRulesStr( - rulesStr: string - ): ILicensePolicyRuleFeatureFlag[] { + convertCredentialRulesStr(rulesStr: string): ILicensePolicyCredentialRule[] { if (!rulesStr || rulesStr.length == 0) return []; try { return JSON.parse(rulesStr); @@ -145,28 +150,4 @@ export class LicenseEngineService { } return licensePolicy; } - - private async getLicenseFeatureFlags( - licenseInput: ILicense - ): Promise { - // If already loaded do nothing - if (licenseInput.featureFlags) { - return licenseInput?.featureFlags; - } - let license: ILicense | null = null; - license = await this.entityManager.findOne(License, { - where: { id: licenseInput.id }, - relations: { - featureFlags: true, - }, - }); - - if (!license || !license.featureFlags) { - throw new EntityNotFoundException( - 'Unable to find load features flags on License', - LogContext.LICENSE - ); - } - return license.featureFlags; - } } diff --git a/src/core/license-engine/license.policy.rule.feature.flag.interface.ts b/src/core/license-engine/license.policy.rule.credential.interface.ts similarity index 50% rename from src/core/license-engine/license.policy.rule.feature.flag.interface.ts rename to src/core/license-engine/license.policy.rule.credential.interface.ts index f035c061c5..3b26eee257 100644 --- a/src/core/license-engine/license.policy.rule.feature.flag.interface.ts +++ b/src/core/license-engine/license.policy.rule.credential.interface.ts @@ -1,11 +1,11 @@ -import { LicenseFeatureFlagName } from '@common/enums/license.feature.flag.name'; +import { LicenseCredential } from '@common/enums/license.credential'; import { LicensePrivilege } from '@common/enums/license.privilege'; import { Field, ObjectType } from '@nestjs/graphql'; -@ObjectType('LicensePolicyRuleFeatureFlag') -export abstract class ILicensePolicyRuleFeatureFlag { - @Field(() => LicenseFeatureFlagName) - featureFlagName!: LicenseFeatureFlagName; +@ObjectType('LicensePolicyCredentialRule') +export abstract class ILicensePolicyCredentialRule { + @Field(() => LicenseCredential) + credentialType!: LicenseCredential; @Field(() => [LicensePrivilege]) grantedPrivileges!: LicensePrivilege[]; diff --git a/src/core/license-engine/license.policy.rule.credential.ts b/src/core/license-engine/license.policy.rule.credential.ts new file mode 100644 index 0000000000..b5097306b7 --- /dev/null +++ b/src/core/license-engine/license.policy.rule.credential.ts @@ -0,0 +1,21 @@ +import { LicenseCredential } from '@common/enums/license.credential'; +import { LicensePrivilege } from '@common/enums/license.privilege'; +import { ILicensePolicyCredentialRule } from './license.policy.rule.credential.interface'; + +export class LicensePolicyCredentialRule + implements ILicensePolicyCredentialRule +{ + credentialType: LicenseCredential; + grantedPrivileges: LicensePrivilege[]; + name: string; + + constructor( + grantedPrivileges: LicensePrivilege[], + credentialType: LicenseCredential, + name: string + ) { + this.credentialType = credentialType; + this.grantedPrivileges = grantedPrivileges; + this.name = name; + } +} diff --git a/src/core/license-engine/license.policy.rule.feature.flag.ts b/src/core/license-engine/license.policy.rule.feature.flag.ts deleted file mode 100644 index 594ed92494..0000000000 --- a/src/core/license-engine/license.policy.rule.feature.flag.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ILicensePolicyRuleFeatureFlag } from './license.policy.rule.feature.flag.interface'; -import { LicensePrivilege } from '@common/enums/license.privilege'; -import { LicenseFeatureFlagName } from '@common/enums/license.feature.flag.name'; - -export class LicensePolicyRuleFeatureFlag - implements ILicensePolicyRuleFeatureFlag -{ - featureFlagName: LicenseFeatureFlagName; - grantedPrivileges: LicensePrivilege[]; - name: string; - - constructor( - grantedPrivileges: LicensePrivilege[], - featureFlag: LicenseFeatureFlagName, - name: string - ) { - this.featureFlagName = featureFlag; - this.grantedPrivileges = grantedPrivileges; - this.name = name; - } -} diff --git a/src/core/microservices/microservices.module.ts b/src/core/microservices/microservices.module.ts index e629dd2ad9..61478f3154 100644 --- a/src/core/microservices/microservices.module.ts +++ b/src/core/microservices/microservices.module.ts @@ -17,6 +17,7 @@ import { VIRTUAL_CONTRIBUTOR_ENGINE_COMMUNITY_MANAGER, VIRTUAL_CONTRIBUTOR_ENGINE_EXPERT, VIRTUAL_CONTRIBUTOR_ENGINE_GUIDANCE, + SUBSCRIPTION_WHITEBOARD_SAVED, } from '@common/constants/providers'; import { MessagingQueue } from '@common/enums/messaging.queue'; import { @@ -57,6 +58,10 @@ const subscriptionConfig: { provide: string; queueName: MessagingQueue }[] = [ provide: SUBSCRIPTION_ROOM_EVENT, queueName: MessagingQueue.SUBSCRIPTION_ROOM_EVENT, }, + { + provide: SUBSCRIPTION_WHITEBOARD_SAVED, + queueName: MessagingQueue.SUBSCRIPTION_WHITEBOARD_SAVED, + }, ]; const trackingUUID = randomUUID(); diff --git a/src/core/validation/handlers/base/base.handler.ts b/src/core/validation/handlers/base/base.handler.ts index 0512dc0ca7..aeb9bc8882 100644 --- a/src/core/validation/handlers/base/base.handler.ts +++ b/src/core/validation/handlers/base/base.handler.ts @@ -21,7 +21,6 @@ import { import { CreateSubspaceInput } from '@domain/space/space/dto/space.dto.create.subspace'; import { CreateActorInput, UpdateActorInput } from '@domain/context/actor'; import { CommunityApplyInput } from '@domain/community/community/dto/community.dto.apply'; -import { CommunicationCreateDiscussionInput } from '@domain/communication/communication/dto/communication.dto.create.discussion'; import { CreateReferenceOnProfileInput } from '@domain/common/profile/dto/profile.dto.create.reference'; import { CreateTagsetOnProfileInput, @@ -32,7 +31,7 @@ import { OrganizationVerificationEventInput } from '@domain/community/organizati import { RoomSendMessageInput } from '@domain/communication/room/dto/room.dto.send.message'; import { UpdatePostInput } from '@domain/collaboration/post/dto/post.dto.update'; import { UpdateWhiteboardDirectInput } from '@domain/common/whiteboard/types'; -import { UpdateDiscussionInput } from '@domain/communication/discussion/dto/discussion.dto.update'; +import { UpdateDiscussionInput } from '@platform/forum-discussion/dto/discussion.dto.update'; import { UpdateEcosystemModelInput } from '@domain/context/ecosystem-model/dto/ecosystem-model.dto.update'; import { SendMessageOnCalloutInput } from '@domain/collaboration/callout/dto/callout.dto.message.created'; import { CreateCalloutOnCollaborationInput } from '@domain/collaboration/collaboration/dto/collaboration.dto.create.callout'; @@ -50,7 +49,6 @@ import { UpdateDocumentInput, } from '@domain/storage/document'; import { VisualUploadImageInput } from '@domain/common/visual/dto/visual.dto.upload.image'; -import { CreateInvitationForUsersOnCommunityInput } from '@domain/community/community/dto/community.dto.invite.existing.user'; import { CreateInvitationUserByEmailOnCommunityInput } from '@domain/community/community/dto/community.dto.invite.external.user'; import { UpdateInnovationFlowInput } from '@domain/collaboration/innovation-flow/dto'; import { @@ -84,6 +82,8 @@ import { } from '@domain/space/account/dto'; import { UpdateAccountDefaultsInput } from '@domain/space/account/dto/account.dto.update.defaults'; import { UpdateCommunityGuidelinesInput } from '@domain/community/community-guidelines/dto/community.guidelines.dto.update'; +import { CreateInvitationForContributorsOnCommunityInput } from '@domain/community/community/dto/community.dto.invite.contributor'; +import { ForumCreateDiscussionInput } from '@platform/forum/dto/forum.dto.create.discussion'; export class BaseHandler extends AbstractHandler { public async handle( @@ -149,9 +149,9 @@ export class BaseHandler extends AbstractHandler { UpdateSpaceSettingsInput, VisualUploadImageInput, CommunityApplyInput, - CreateInvitationForUsersOnCommunityInput, + CreateInvitationForContributorsOnCommunityInput, CreateInvitationUserByEmailOnCommunityInput, - CommunicationCreateDiscussionInput, + ForumCreateDiscussionInput, SendMessageOnCalloutInput, CreateCalloutOnCollaborationInput, ]; diff --git a/src/domain/collaboration/collaboration/collaboration.service.authorization.ts b/src/domain/collaboration/collaboration/collaboration.service.authorization.ts index 2ceafa8a0d..62ae54461f 100644 --- a/src/domain/collaboration/collaboration/collaboration.service.authorization.ts +++ b/src/domain/collaboration/collaboration/collaboration.service.authorization.ts @@ -24,11 +24,11 @@ import { import { CommunityRole } from '@common/enums/community.role'; import { TimelineAuthorizationService } from '@domain/timeline/timeline/timeline.service.authorization'; import { ICallout } from '../callout/callout.interface'; -import { ILicense } from '@domain/license/license/license.interface'; import { InnovationFlowAuthorizationService } from '../innovation-flow/innovation.flow.service.authorization'; import { RelationshipNotFoundException } from '@common/exceptions/relationship.not.found.exception'; import { LicenseEngineService } from '@core/license-engine/license.engine.service'; import { LicensePrivilege } from '@common/enums/license.privilege'; +import { IAgent } from '@domain/agent/agent/agent.interface'; @Injectable() export class CollaborationAuthorizationService { @@ -46,7 +46,7 @@ export class CollaborationAuthorizationService { collaborationInput: ICollaboration, parentAuthorization: IAuthorizationPolicy | undefined, communityPolicy: ICommunityPolicy, - license: ILicense + accountAgent: IAgent ): Promise { const collaboration = await this.collaborationService.getCollaborationOrFail( @@ -82,7 +82,7 @@ export class CollaborationAuthorizationService { collaboration.authorization = await this.appendCredentialRules( collaboration.authorization, communityPolicy, - license + accountAgent ); collaboration.authorization = this.appendCredentialRulesForContributors( collaboration.authorization, @@ -92,7 +92,7 @@ export class CollaborationAuthorizationService { collaboration.authorization = await this.appendPrivilegeRules( collaboration.authorization, communityPolicy, - license + accountAgent ); return this.propagateAuthorizationToChildEntities( @@ -193,7 +193,7 @@ export class CollaborationAuthorizationService { private async appendCredentialRules( authorization: IAuthorizationPolicy | undefined, policy: ICommunityPolicy, - license: ILicense + accountAgent: IAgent ): Promise { if (!authorization) throw new EntityNotInitializedException( @@ -208,7 +208,7 @@ export class CollaborationAuthorizationService { const saveAsTemplateEnabled = await this.licenseEngineService.isAccessGranted( LicensePrivilege.CALLOUT_SAVE_AS_TEMPLATE, - license + accountAgent ); if (saveAsTemplateEnabled) { const adminCriterias = this.communityPolicyService.getCredentialsForRole( @@ -269,7 +269,7 @@ export class CollaborationAuthorizationService { private async appendPrivilegeRules( authorization: IAuthorizationPolicy, policy: ICommunityPolicy, - license: ILicense + accountAgent: IAgent ): Promise { const privilegeRules: AuthorizationPolicyRulePrivilege[] = []; @@ -282,7 +282,7 @@ export class CollaborationAuthorizationService { const whiteboardRtEnabled = await this.licenseEngineService.isAccessGranted( LicensePrivilege.WHITEBOARD_MULTI_USER, - license + accountAgent ); if (whiteboardRtEnabled) { const createWhiteboardRtPrivilege = new AuthorizationPolicyRulePrivilege( diff --git a/src/domain/collaboration/collaboration/collaboration.service.ts b/src/domain/collaboration/collaboration/collaboration.service.ts index fa67eaff59..0947f5692c 100644 --- a/src/domain/collaboration/collaboration/collaboration.service.ts +++ b/src/domain/collaboration/collaboration/collaboration.service.ts @@ -53,6 +53,7 @@ import { CalloutGroupsService } from '../callout-groups/callout.group.service'; import { IAccount } from '@domain/space/account/account.interface'; import { SpaceType } from '@common/enums/space.type'; import { CalloutGroupName } from '@common/enums/callout.group.name'; +import { SpaceLevel } from '@common/enums/space.level'; @Injectable() export class CollaborationService { @@ -96,6 +97,7 @@ export class CollaborationService { const innovationFlowInput = await this.spaceDefaultsService.getCreateInnovationFlowInput( account.id, + spaceType, collaborationData.innovationFlowTemplateID ); const allowedStates = innovationFlowInput.states.map( @@ -261,8 +263,8 @@ export class CollaborationService { } const accountID = space.account.id; - switch (space.type) { - case SpaceType.SPACE: + switch (space.level) { + case SpaceLevel.SPACE: const spacesInAccount = await this.entityManager.find(Space, { where: { account: { @@ -287,7 +289,7 @@ export class CollaborationService { } return x.collaboration; }); - case SpaceType.CHALLENGE: + case SpaceLevel.CHALLENGE: const subsubspaces = space.subspaces; if (!subsubspaces) { throw new EntityNotInitializedException( diff --git a/src/domain/common/whiteboard/dto/subscription/index.ts b/src/domain/common/whiteboard/dto/subscription/index.ts new file mode 100644 index 0000000000..340f08c7e2 --- /dev/null +++ b/src/domain/common/whiteboard/dto/subscription/index.ts @@ -0,0 +1,2 @@ +export * from './whiteboard.saved.subscription.result'; +export * from './whiteboard.saved.subscription.args'; diff --git a/src/domain/common/whiteboard/dto/subscription/whiteboard.saved.subscription.args.ts b/src/domain/common/whiteboard/dto/subscription/whiteboard.saved.subscription.args.ts new file mode 100644 index 0000000000..0d6b77942e --- /dev/null +++ b/src/domain/common/whiteboard/dto/subscription/whiteboard.saved.subscription.args.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field } from '@nestjs/graphql'; +import { UUID } from '@domain/common/scalars'; + +@ArgsType() +export class WhiteboardSavedSubscriptionArgs { + @Field(() => UUID, { + description: 'The Whiteboard to receive the save events from.', + nullable: false, + }) + whiteboardID!: string; +} diff --git a/src/domain/common/whiteboard/dto/subscription/whiteboard.saved.subscription.result.ts b/src/domain/common/whiteboard/dto/subscription/whiteboard.saved.subscription.result.ts new file mode 100644 index 0000000000..3e694f9260 --- /dev/null +++ b/src/domain/common/whiteboard/dto/subscription/whiteboard.saved.subscription.result.ts @@ -0,0 +1,19 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType('WhiteboardSavedSubscriptionResult', { + description: 'The save event happened in the subscribed whiteboard.', +}) +export class WhiteboardSavedSubscriptionResult { + @Field(() => String, { + nullable: false, + description: + 'The identifier for the Whiteboard on which the save event happened.', + }) + whiteboardID!: string; + + @Field(() => Date, { + description: 'The date at which the Whiteboard was last updated.', + nullable: true, + }) + updatedDate!: Date; +} diff --git a/src/domain/common/whiteboard/dto/whiteboard.dto.update.content.ts b/src/domain/common/whiteboard/dto/whiteboard.dto.update.content.ts index 6349405d88..963f0b8e38 100644 --- a/src/domain/common/whiteboard/dto/whiteboard.dto.update.content.ts +++ b/src/domain/common/whiteboard/dto/whiteboard.dto.update.content.ts @@ -1,11 +1,9 @@ -import { UpdateNameableInput } from '@domain/common/entity/nameable-entity/dto/nameable.dto.update'; +import { UpdateBaseAlkemioInput } from '@domain/common/entity/base-entity'; import { WhiteboardContent } from '@domain/common/scalars/scalar.whiteboard.content'; import { InputType, Field } from '@nestjs/graphql'; -import { IsOptional } from 'class-validator'; @InputType() -export class UpdateWhiteboardContentInput extends UpdateNameableInput { - @Field(() => WhiteboardContent, { nullable: true }) - @IsOptional() - content?: string; +export class UpdateWhiteboardContentInput extends UpdateBaseAlkemioInput { + @Field(() => WhiteboardContent) + content!: string; } diff --git a/src/domain/common/whiteboard/whiteboard.module.ts b/src/domain/common/whiteboard/whiteboard.module.ts index d362d251c0..42a26bc9a6 100644 --- a/src/domain/common/whiteboard/whiteboard.module.ts +++ b/src/domain/common/whiteboard/whiteboard.module.ts @@ -14,6 +14,8 @@ import { WhiteboardAuthorizationService } from './whiteboard.service.authorizati import { StorageBucketModule } from '@domain/storage/storage-bucket/storage.bucket.module'; import { ProfileDocumentsModule } from '@domain/profile-documents/profile.documents.module'; import { LicenseEngineModule } from '@core/license-engine/license.engine.module'; +import { SubscriptionServiceModule } from '@services/subscriptions/subscription-service'; +import { WhiteboardSavedResolverSubscription } from './whiteboard.saved.resolver.subscription'; @Module({ imports: [ @@ -27,12 +29,14 @@ import { LicenseEngineModule } from '@core/license-engine/license.engine.module' StorageBucketModule, TypeOrmModule.forFeature([Whiteboard]), ProfileDocumentsModule, + SubscriptionServiceModule, ], providers: [ WhiteboardService, WhiteboardAuthorizationService, WhiteboardResolverMutations, WhiteboardResolverFields, + WhiteboardSavedResolverSubscription, ], exports: [ WhiteboardService, diff --git a/src/domain/common/whiteboard/whiteboard.saved.resolver.subscription.ts b/src/domain/common/whiteboard/whiteboard.saved.resolver.subscription.ts new file mode 100644 index 0000000000..1776840413 --- /dev/null +++ b/src/domain/common/whiteboard/whiteboard.saved.resolver.subscription.ts @@ -0,0 +1,90 @@ +import { Inject, LoggerService, UseGuards } from '@nestjs/common'; +import { Args, Resolver } from '@nestjs/graphql'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { CurrentUser } from '@common/decorators/current-user.decorator'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { GraphqlGuard } from '@core/authorization'; +import { LogContext } from '@common/enums/logging.context'; +import { AuthorizationService } from '@core/authorization/authorization.service'; +import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; +import { TypedSubscription } from '@src/common/decorators'; +import { + WhiteboardSavedSubscriptionArgs, + WhiteboardSavedSubscriptionResult, +} from '@domain/common/whiteboard/dto/subscription/'; +import { SubscriptionReadService } from '@services/subscriptions/subscription-service'; +import { WhiteboardSavedSubscriptionPayload } from '@services/subscriptions/subscription-service/dto'; +import { WhiteboardService } from './whiteboard.service'; + +@Resolver() +export class WhiteboardSavedResolverSubscription { + constructor( + private authorizationService: AuthorizationService, + private whiteboardService: WhiteboardService, + @Inject(WINSTON_MODULE_NEST_PROVIDER) + private readonly logger: LoggerService, + private subscriptionService: SubscriptionReadService + ) {} + + @UseGuards(GraphqlGuard) + @TypedSubscription< + WhiteboardSavedSubscriptionPayload, + WhiteboardSavedSubscriptionArgs + >(() => WhiteboardSavedSubscriptionResult, { + description: 'Receive Whiteboard Saved event', + async resolve( + this: WhiteboardSavedResolverSubscription, + payload, + args, + context + ) { + const agentInfo = context.req?.user; + const logMsgPrefix = `[Whiteboard Saved (${agentInfo.email})] - `; + this.logger.verbose?.( + `${logMsgPrefix} Sending out event: ${payload.whiteboardID} `, + LogContext.SUBSCRIPTIONS + ); + // Something is changing the Date from the payload into a string. GraphQL needs it to be a Date or a serialization error occurs + // So we do this conversion here + payload.updatedDate = new Date(payload.updatedDate); + + return payload; + }, + async filter( + this: WhiteboardSavedResolverSubscription, + payload, + variables, + context + ) { + const agentInfo = context.req?.user; + const isMatch = variables.whiteboardID === payload.whiteboardID; + this.logger.verbose?.( + `[User (${agentInfo.email}) Whiteboard Saved] - Filtering whiteboard id '${payload.whiteboardID}' - match=${isMatch}`, + LogContext.SUBSCRIPTIONS + ); + return isMatch; + }, + }) + async whiteboardSaved( + @CurrentUser() agentInfo: AgentInfo, + @Args({ nullable: false }) { whiteboardID }: WhiteboardSavedSubscriptionArgs + ) { + const logMsgPrefix = `[User (${agentInfo.email}) Saved Whiteboard] - `; + this.logger.verbose?.( + `${logMsgPrefix} Subscribing to the following whiteboard: ${whiteboardID} for saved events`, + LogContext.SUBSCRIPTIONS + ); + + const whiteboard = await this.whiteboardService.getWhiteboardOrFail( + whiteboardID + ); + this.authorizationService.grantAccessOrFail( + agentInfo, + whiteboard.authorization, + AuthorizationPrivilege.READ, + `subscription to Whiteboard save events on: ${whiteboard.id}` + ); + + return this.subscriptionService.subscribeToWhiteboardSavedEvents(); + } +} diff --git a/src/domain/common/whiteboard/whiteboard.service.ts b/src/domain/common/whiteboard/whiteboard.service.ts index ce7686974e..90b5d930bb 100644 --- a/src/domain/common/whiteboard/whiteboard.service.ts +++ b/src/domain/common/whiteboard/whiteboard.service.ts @@ -35,6 +35,8 @@ import { WHITEBOARD_CONTENT_UPDATE } from './events/event.names'; import { CalloutContribution } from '@domain/collaboration/callout-contribution/callout.contribution.entity'; import { LicenseEngineService } from '@core/license-engine/license.engine.service'; import { LicensePrivilege } from '@common/enums/license.privilege'; +import { SubscriptionPublishService } from '@services/subscriptions/subscription-service'; +import { isEqual } from 'lodash'; @Injectable() export class WhiteboardService { @@ -49,6 +51,7 @@ export class WhiteboardService { private profileService: ProfileService, private profileDocumentsService: ProfileDocumentsService, private whiteboardAuthService: WhiteboardAuthorizationService, + private subscriptionPublishService: SubscriptionPublishService, private communityResolverService: CommunityResolverService, @InjectEntityManager() private entityManager: EntityManager ) {} @@ -196,12 +199,19 @@ export class WhiteboardService { profile: true, }, }); + const currentWhiteboardContent = JSON.parse(whiteboard.content); + const newWhiteboardContent = JSON.parse( + updateWhiteboardContentData.content + ); + + if (isEqual(currentWhiteboardContent, newWhiteboardContent)) { + whiteboard.updatedDate = new Date(); - if ( - !updateWhiteboardContentData.content || - updateWhiteboardContentData.content === whiteboard.content - ) { - return whiteboard; + this.subscriptionPublishService.publishWhiteboardSaved( + whiteboard.id, + whiteboard.updatedDate + ); + return this.save(whiteboard); } if (!whiteboard?.profile) { @@ -211,17 +221,21 @@ export class WhiteboardService { ); } - const newContent = await this.reuploadDocumentsIfNotInBucket( - JSON.parse(updateWhiteboardContentData.content), + const newContentWithFiles = await this.reuploadDocumentsIfNotInBucket( + newWhiteboardContent, whiteboard?.profile.id ); - whiteboard.content = JSON.stringify(newContent); - + whiteboard.content = JSON.stringify(newContentWithFiles); const savedWhiteboard = await this.save(whiteboard); this.eventEmitter.emit(WHITEBOARD_CONTENT_UPDATE, savedWhiteboard.id); + this.subscriptionPublishService.publishWhiteboardSaved( + whiteboard.id, + savedWhiteboard.updatedDate + ); + return savedWhiteboard; } @@ -231,7 +245,7 @@ export class WhiteboardService { whiteboardId ); const license = - await this.communityResolverService.getLicenseFromCommunityOrFail( + await this.communityResolverService.getAccountAgentFromCommunityOrFail( community ); diff --git a/src/domain/communication/communication/communication.entity.ts b/src/domain/communication/communication/communication.entity.ts index 557c3dbb7e..031bb3ccaa 100644 --- a/src/domain/communication/communication/communication.entity.ts +++ b/src/domain/communication/communication/communication.entity.ts @@ -1,7 +1,6 @@ -import { Column, Entity, JoinColumn, OneToMany, OneToOne } from 'typeorm'; +import { Column, Entity, JoinColumn, OneToOne } from 'typeorm'; import { ICommunication } from '@domain/communication/communication/communication.interface'; import { AuthorizableEntity } from '@domain/common/entity/authorizable-entity/authorizable.entity'; -import { Discussion } from '@domain/communication/discussion/discussion.entity'; import { Room } from '../room/room.entity'; @Entity() @@ -12,15 +11,6 @@ export class Communication @Column() spaceID: string; - @OneToMany(() => Discussion, discussion => discussion.communication, { - eager: false, - cascade: true, - }) - discussions?: Discussion[]; - - @Column('simple-array') - discussionCategories: string[]; - @OneToOne(() => Room, { eager: true, cascade: true, @@ -36,6 +26,5 @@ export class Communication super(); this.spaceID = ''; this.displayName = displayName || ''; - this.discussionCategories = []; } } diff --git a/src/domain/communication/communication/communication.interface.ts b/src/domain/communication/communication/communication.interface.ts index 604c5493a4..bb2e734c28 100644 --- a/src/domain/communication/communication/communication.interface.ts +++ b/src/domain/communication/communication/communication.interface.ts @@ -1,13 +1,9 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { IAuthorizable } from '@domain/common/entity/authorizable-entity'; -import { IDiscussion } from '../discussion/discussion.interface'; -import { DiscussionCategory } from '@common/enums/communication.discussion.category'; import { IRoom } from '../room/room.interface'; @ObjectType('Communication') export abstract class ICommunication extends IAuthorizable { - discussions?: IDiscussion[]; - @Field(() => IRoom, { nullable: false, description: 'The updates on this Communication.', @@ -17,7 +13,4 @@ export abstract class ICommunication extends IAuthorizable { spaceID!: string; displayName!: string; - - @Field(() => [DiscussionCategory]) - discussionCategories!: string[]; } diff --git a/src/domain/communication/communication/communication.module.ts b/src/domain/communication/communication/communication.module.ts index 18ed82d766..ff25b13e05 100644 --- a/src/domain/communication/communication/communication.module.ts +++ b/src/domain/communication/communication/communication.module.ts @@ -7,37 +7,27 @@ import { CommunicationResolverMutations } from './communication.resolver.mutatio import { CommunicationService } from './communication.service'; import { AuthorizationPolicyModule } from '@domain/common/authorization-policy/authorization.policy.module'; import { CommunicationAuthorizationService } from './communication.service.authorization'; -import { DiscussionModule } from '../discussion/discussion.module'; import { CommunicationAdapterModule } from '@services/adapters/communication-adapter/communication-adapter.module'; -import { CommunicationResolverSubscriptions } from './communication.resolver.subscriptions'; import { RoomModule } from '../room/room.module'; import { NotificationAdapterModule } from '@services/adapters/notification-adapter/notification.adapter.module'; -import { EntityResolverModule } from '@services/infrastructure/entity-resolver/entity.resolver.module'; -import { PlatformAuthorizationPolicyModule } from '@platform/authorization/platform.authorization.policy.module'; -import { NamingModule } from '@services/infrastructure/naming/naming.module'; import { StorageAggregatorResolverModule } from '@services/infrastructure/storage-aggregator-resolver/storage.aggregator.resolver.module'; +import { PlatformAuthorizationPolicyModule } from '@platform/authorization/platform.authorization.policy.module'; @Module({ imports: [ AuthorizationModule, NotificationAdapterModule, AuthorizationPolicyModule, - DiscussionModule, RoomModule, CommunicationAdapterModule, - CommunicationAdapterModule, - EntityResolverModule, - NamingModule, - PlatformAuthorizationPolicyModule, StorageAggregatorResolverModule, - NamingModule, + PlatformAuthorizationPolicyModule, TypeOrmModule.forFeature([Communication]), ], providers: [ CommunicationService, CommunicationResolverMutations, CommunicationResolverFields, - CommunicationResolverSubscriptions, CommunicationAuthorizationService, ], exports: [CommunicationService, CommunicationAuthorizationService], diff --git a/src/domain/communication/communication/communication.resolver.fields.ts b/src/domain/communication/communication/communication.resolver.fields.ts index 1d970c01de..b30b4980cd 100644 --- a/src/domain/communication/communication/communication.resolver.fields.ts +++ b/src/domain/communication/communication/communication.resolver.fields.ts @@ -1,51 +1,7 @@ -import { GraphqlGuard } from '@core/authorization'; -import { UseGuards } from '@nestjs/common'; -import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql'; -import { AuthorizationAgentPrivilege, Profiling } from '@src/common/decorators'; -import { CommunicationService } from './communication.service'; -import { AuthorizationPrivilege } from '@common/enums'; +import { Resolver } from '@nestjs/graphql'; import { ICommunication } from './communication.interface'; -import { IDiscussion } from '../discussion/discussion.interface'; -import { DiscussionsInput } from './dto/communication.dto.discussions.input'; @Resolver(() => ICommunication) export class CommunicationResolverFields { - constructor(private communicationService: CommunicationService) {} - - @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) - @UseGuards(GraphqlGuard) - @ResolveField('discussions', () => [IDiscussion], { - nullable: true, - description: 'The Discussions active in this Communication.', - }) - @Profiling.api - async discussions( - @Parent() communication: ICommunication, - - @Args('queryData', { type: () => DiscussionsInput, nullable: true }) - queryData?: DiscussionsInput - ): Promise { - return await this.communicationService.getDiscussions( - communication, - queryData?.limit, - queryData?.orderBy - ); - } - - @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) - @UseGuards(GraphqlGuard) - @ResolveField('discussion', () => IDiscussion, { - nullable: true, - description: 'A particular Discussions active in this Communication.', - }) - @Profiling.api - async discussion( - @Parent() communication: ICommunication, - @Args('ID') discussionID: string - ): Promise { - return await this.communicationService.getDiscussionOrFail( - communication, - discussionID - ); - } + constructor() {} } diff --git a/src/domain/communication/communication/communication.resolver.mutations.ts b/src/domain/communication/communication/communication.resolver.mutations.ts index fc71528ec8..97b742c3fe 100644 --- a/src/domain/communication/communication/communication.resolver.mutations.ts +++ b/src/domain/communication/communication/communication.resolver.mutations.ts @@ -1,24 +1,13 @@ import { Inject, LoggerService, UseGuards } from '@nestjs/common'; import { Resolver } from '@nestjs/graphql'; import { Args, Mutation } from '@nestjs/graphql'; -import { CommunicationService } from './communication.service'; import { CurrentUser } from '@src/common/decorators'; import { GraphqlGuard } from '@core/authorization'; import { AgentInfo } from '@core/authentication.agent.info/agent.info'; import { AuthorizationService } from '@core/authorization/authorization.service'; -import { AuthorizationPrivilege, LogContext } from '@common/enums'; -import { IDiscussion } from '../discussion/discussion.interface'; -import { CommunicationCreateDiscussionInput } from './dto/communication.dto.create.discussion'; -import { DiscussionService } from '../discussion/discussion.service'; -import { DiscussionAuthorizationService } from '../discussion/discussion.service.authorization'; -import { SUBSCRIPTION_DISCUSSION_UPDATED } from '@common/constants/providers'; -import { PubSubEngine } from 'graphql-subscriptions'; -import { CommunicationDiscussionUpdated } from './dto/communication.dto.event.discussion.updated'; -import { SubscriptionType } from '@common/enums/subscription.type'; +import { AuthorizationPrivilege } from '@common/enums'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { NotificationAdapter } from '@services/adapters/notification-adapter/notification.adapter'; -import { NotificationInputForumDiscussionCreated } from '@services/adapters/notification-adapter/dto/notification.dto.input.discussion.created'; -import { COMMUNICATION_PLATFORM_SPACEID } from '@common/constants'; import { NotificationInputUserMessage } from '@services/adapters/notification-adapter/dto/notification.dto.input.user.message'; import { CommunicationSendMessageToUserInput } from './dto/communication.dto.send.message.user'; import { NotificationInputOrganizationMessage } from '@services/adapters/notification-adapter/dto/notification.input.organization.message'; @@ -26,107 +15,16 @@ import { CommunicationSendMessageToOrganizationInput } from './dto/communication import { PlatformAuthorizationPolicyService } from '@src/platform/authorization/platform.authorization.policy.service'; import { NotificationInputCommunityLeadsMessage } from '@services/adapters/notification-adapter/dto/notification.dto.input.community.leads.message'; import { CommunicationSendMessageToCommunityLeadsInput } from './dto/communication.dto.send.message.community.leads'; -import { ValidationException } from '@common/exceptions/validation.exception'; -import { NamingService } from '@services/infrastructure/naming/naming.service'; -import { DiscussionCategory } from '@common/enums/communication.discussion.category'; @Resolver() export class CommunicationResolverMutations { constructor( private authorizationService: AuthorizationService, private notificationAdapter: NotificationAdapter, - private communicationService: CommunicationService, - private namingService: NamingService, - private discussionAuthorizationService: DiscussionAuthorizationService, - private discussionService: DiscussionService, private platformAuthorizationService: PlatformAuthorizationPolicyService, - @Inject(SUBSCRIPTION_DISCUSSION_UPDATED) - private readonly subscriptionDiscussionMessage: PubSubEngine, @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService ) {} - @UseGuards(GraphqlGuard) - @Mutation(() => IDiscussion, { - description: 'Creates a new Discussion as part of this Communication.', - }) - async createDiscussion( - @CurrentUser() agentInfo: AgentInfo, - @Args('createData') createData: CommunicationCreateDiscussionInput - ): Promise { - const communication = - await this.communicationService.getCommunicationOrFail( - createData.communicationID - ); - await this.authorizationService.grantAccessOrFail( - agentInfo, - communication.authorization, - AuthorizationPrivilege.CREATE_DISCUSSION, - `create discussion on communication: ${communication.id}` - ); - - if (createData.category === DiscussionCategory.RELEASES) { - const platformAuthorization = - await this.platformAuthorizationService.getPlatformAuthorizationPolicy(); - await this.authorizationService.grantAccessOrFail( - agentInfo, - platformAuthorization, - AuthorizationPrivilege.PLATFORM_ADMIN, - `User not authorized to create discussion with ${DiscussionCategory.RELEASES} category.` - ); - } - - const displayNameAvailable = - await this.namingService.isDiscussionDisplayNameAvailableInCommunication( - createData.profile.displayName, - communication.id - ); - if (!displayNameAvailable) - throw new ValidationException( - `Unable to create Discussion: the provided displayName is already taken: ${createData.profile.displayName}`, - LogContext.SPACES - ); - - const discussion = await this.communicationService.createDiscussion( - createData, - agentInfo.userID, - agentInfo.communicationID - ); - - const savedDiscussion = await this.discussionService.save(discussion); - await this.discussionAuthorizationService.applyAuthorizationPolicy( - discussion, - communication.authorization - ); - - if (communication.spaceID === COMMUNICATION_PLATFORM_SPACEID) { - // Send the notification - const notificationInput: NotificationInputForumDiscussionCreated = { - triggeredBy: agentInfo.userID, - discussion: discussion, - }; - await this.notificationAdapter.forumDiscussionCreated(notificationInput); - } - - // Send out the subscription event - const eventID = `discussion-message-updated-${Math.floor( - Math.random() * 100 - )}`; - const subscriptionPayload: CommunicationDiscussionUpdated = { - eventID: eventID, - discussionID: discussion.id, - }; - this.logger.verbose?.( - `[Discussion updated] - event published: '${eventID}'`, - LogContext.SUBSCRIPTIONS - ); - this.subscriptionDiscussionMessage.publish( - SubscriptionType.COMMUNICATION_DISCUSSION_UPDATED, - subscriptionPayload - ); - - return savedDiscussion; - } - @UseGuards(GraphqlGuard) @Mutation(() => Boolean, { description: 'Send message to a User.', diff --git a/src/domain/communication/communication/communication.service.authorization.ts b/src/domain/communication/communication/communication.service.authorization.ts index c7d7e74a0e..a2e06e273c 100644 --- a/src/domain/communication/communication/communication.service.authorization.ts +++ b/src/domain/communication/communication/communication.service.authorization.ts @@ -2,13 +2,12 @@ import { Injectable } from '@nestjs/common'; import { ICommunication } from '@domain/communication/communication'; import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; import { IAuthorizationPolicy } from '@domain/common/authorization-policy/authorization.policy.interface'; -import { DiscussionAuthorizationService } from '../discussion/discussion.service.authorization'; import { AuthorizationPrivilege, LogContext } from '@common/enums'; import { CommunicationService } from './communication.service'; import { AuthorizationPolicyRulePrivilege } from '@core/authorization/authorization.policy.rule.privilege'; import { - POLICY_RULE_COMMUNICATION_CONTRIBUTE, - POLICY_RULE_COMMUNICATION_CREATE, + POLICY_RULE_FORUM_CONTRIBUTE, + POLICY_RULE_FORUM_CREATE, } from '@common/constants'; import { RoomAuthorizationService } from '../room/room.service.authorization'; import { RelationshipNotFoundException } from '@common/exceptions/relationship.not.found.exception'; @@ -18,7 +17,6 @@ export class CommunicationAuthorizationService { constructor( private authorizationPolicyService: AuthorizationPolicyService, private communicationService: CommunicationService, - private discussionAuthorizationService: DiscussionAuthorizationService, private roomAuthorizationService: RoomAuthorizationService ) {} @@ -31,9 +29,6 @@ export class CommunicationAuthorizationService { communicationInput.id, { relations: { - discussions: { - comments: true, - }, updates: { authorization: true, }, @@ -41,7 +36,7 @@ export class CommunicationAuthorizationService { } ); - if (!communication.discussions || !communication.updates) { + if (!communication.updates) { throw new RelationshipNotFoundException( `Unable to load entities to reset auth for communication ${communication.id} `, LogContext.COMMUNICATION @@ -58,13 +53,6 @@ export class CommunicationAuthorizationService { communication.authorization ); - for (const discussion of communication.discussions) { - await this.discussionAuthorizationService.applyAuthorizationPolicy( - discussion, - communication.authorization - ); - } - communication.updates = this.roomAuthorizationService.applyAuthorizationPolicy( communication.updates, @@ -88,14 +76,14 @@ export class CommunicationAuthorizationService { const contributePrivilege = new AuthorizationPolicyRulePrivilege( [AuthorizationPrivilege.CREATE_DISCUSSION], AuthorizationPrivilege.CONTRIBUTE, - POLICY_RULE_COMMUNICATION_CONTRIBUTE + POLICY_RULE_FORUM_CONTRIBUTE ); privilegeRules.push(contributePrivilege); const createPrivilege = new AuthorizationPolicyRulePrivilege( [AuthorizationPrivilege.CREATE_DISCUSSION], AuthorizationPrivilege.CREATE, - POLICY_RULE_COMMUNICATION_CREATE + POLICY_RULE_FORUM_CREATE ); privilegeRules.push(createPrivilege); return this.authorizationPolicyService.appendPrivilegeAuthorizationRules( diff --git a/src/domain/communication/communication/communication.service.ts b/src/domain/communication/communication/communication.service.ts index 43fbe62829..d797dfe94c 100644 --- a/src/domain/communication/communication/communication.service.ts +++ b/src/domain/communication/communication/communication.service.ts @@ -12,31 +12,17 @@ import { ICommunication, } from '@domain/communication/communication'; import { AuthorizationPolicy } from '@domain/common/authorization-policy'; -import { IDiscussion } from '../discussion/discussion.interface'; -import { DiscussionService } from '../discussion/discussion.service'; import { CommunicationAdapter } from '@services/adapters/communication-adapter/communication.adapter'; import { IUser } from '@domain/community/user/user.interface'; -import { CommunicationCreateDiscussionInput } from './dto/communication.dto.create.discussion'; -import { DiscussionCategory } from '@common/enums/communication.discussion.category'; -import { CommunicationDiscussionCategoryException } from '@common/exceptions/communication.discussion.category.exception'; -import { UUID_LENGTH } from '@common/constants/entity.field.length.constants'; import { RoomService } from '../room/room.service'; import { IRoom } from '../room/room.interface'; import { RoomType } from '@common/enums/room.type'; -import { COMMUNICATION_PLATFORM_SPACEID } from '@common/constants'; -import { StorageAggregatorResolverService } from '@services/infrastructure/storage-aggregator-resolver/storage.aggregator.resolver.service'; -import { DiscussionsOrderBy } from '@common/enums/discussions.orderBy'; -import { Discussion } from '../discussion/discussion.entity'; -import { NamingService } from '@services/infrastructure/naming/naming.service'; @Injectable() export class CommunicationService { constructor( - private discussionService: DiscussionService, private roomService: RoomService, private communicationAdapter: CommunicationAdapter, - private storageAggregatorResolverService: StorageAggregatorResolverService, - private namingService: NamingService, @InjectRepository(Communication) private communicationRepository: Repository, @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService @@ -44,16 +30,12 @@ export class CommunicationService { async createCommunication( displayName: string, - spaceID: string, - discussionCategories: DiscussionCategory[] + spaceID: string ): Promise { const communication: ICommunication = new Communication(displayName); communication.authorization = new AuthorizationPolicy(); communication.spaceID = spaceID; - communication.discussions = []; - communication.discussionCategories = discussionCategories; - // save to get the id assigned await this.save(communication); @@ -69,150 +51,6 @@ export class CommunicationService { return await this.communicationRepository.save(communication); } - async createDiscussion( - discussionData: CommunicationCreateDiscussionInput, - userID: string, - userCommunicationID: string - ): Promise { - const displayName = discussionData.profile.displayName; - const communicationID = discussionData.communicationID; - - this.logger.verbose?.( - `[Discussion] Adding discussion (${displayName}) to Communication (${communicationID})`, - LogContext.COMMUNICATION - ); - - // Try to find the Communication - const communication = await this.getCommunicationOrFail(communicationID, { - relations: { discussions: true }, - }); - - if (!communication.discussionCategories.includes(discussionData.category)) { - throw new CommunicationDiscussionCategoryException( - `Invalid discussion category supplied ('${discussionData.category}'), allowed categories: ${communication.discussionCategories}`, - LogContext.COMMUNICATION - ); - } - - let roomType = RoomType.DISCUSSION; - if (this.isPlatformCommunication(communication)) { - roomType = RoomType.DISCUSSION_FORUM; - } - - const storageAggregator = - await this.storageAggregatorResolverService.getStorageAggregatorForCommunication( - communication.id - ); - const reservedNameIDs = - await this.namingService.getReservedNameIDsInCommunication( - communication.id - ); - discussionData.nameID = - this.namingService.createNameIdAvoidingReservedNameIDs( - `${discussionData.profile.displayName}`, - reservedNameIDs - ); - const discussion = await this.discussionService.createDiscussion( - discussionData, - userID, - communication.displayName, - roomType, - storageAggregator - ); - this.logger.verbose?.( - `[Discussion] Room created (${displayName}) and membership replicated from Updates (${communicationID})`, - LogContext.COMMUNICATION - ); - - communication.discussions?.push(discussion); - await this.communicationRepository.save(communication); - - // Trigger a room membership request for the current user that is not awaited - const room = await this.discussionService.getComments(discussion.id); - await this.communicationAdapter.addUserToRoom( - room.externalRoomID, - userCommunicationID - ); - - // we're no longer replicating membership, because all the rooms are public and visible. - // Set the Matrix membership so that users sending to rooms they are a member of responds quickly - // const updates = this.getUpdates(communication); - // Do not await as the memberhip will be updated in the background - // this.communicationAdapter.replicateRoomMembership( - // discussion.communicationRoomID, - // updates.communicationRoomID, - // userCommunicationID - // ); - - return discussion; - } - - private isPlatformCommunication(communication: ICommunication): boolean { - if (communication.spaceID === COMMUNICATION_PLATFORM_SPACEID) { - return true; - } - return false; - } - - async getDiscussions( - communication: ICommunication, - limit?: number, - orderBy: DiscussionsOrderBy = DiscussionsOrderBy.DISCUSSIONS_CREATEDATE_DESC - ): Promise { - const communicationWithDiscussions = await this.getCommunicationOrFail( - communication.id, - { - relations: { discussions: true }, - } - ); - const discussions = communicationWithDiscussions.discussions; - if (!discussions) - throw new EntityNotInitializedException( - `Unable to load Discussions for Communication: ${communication.id} `, - LogContext.COMMUNICATION - ); - - const sortedDiscussions = (discussions as Discussion[]).sort((a, b) => { - switch (orderBy) { - case DiscussionsOrderBy.DISCUSSIONS_CREATEDATE_ASC: - return a.createdDate.getTime() - b.createdDate.getTime(); - case DiscussionsOrderBy.DISCUSSIONS_CREATEDATE_DESC: - return b.createdDate.getTime() - a.createdDate.getTime(); - } - return 0; - }); - return limit && limit > 0 - ? sortedDiscussions.slice(0, limit) - : sortedDiscussions; - } - - async getDiscussionOrFail( - communication: ICommunication, - discussionID: string - ): Promise { - const discussions = await this.getDiscussions(communication); - let discussion: IDiscussion | undefined; - if (discussionID.length === UUID_LENGTH) { - discussion = discussions.find( - discussion => discussion.id === discussionID - ); - } - if (!discussion) { - // look up based on nameID - discussion = discussions.find( - discussion => discussion.nameID === discussionID - ); - } - - if (!discussion) { - throw new EntityNotFoundException( - `Unable to find Discussion with ID: ${discussionID}`, - LogContext.COMMUNICATION - ); - } - return discussion; - } - getUpdates(communication: ICommunication): IRoom { if (!communication.updates) { throw new EntityNotInitializedException( @@ -244,17 +82,16 @@ export class CommunicationService { async removeCommunication(communicationID: string): Promise { // Note need to load it in with all contained entities so can remove fully const communication = await this.getCommunicationOrFail(communicationID, { - relations: { discussions: true }, + relations: { updates: true }, }); - // Remove all groups - for (const discussion of await this.getDiscussions(communication)) { - await this.discussionService.removeDiscussion({ - ID: discussion.id, - }); - } + if (!communication.updates) + throw new EntityNotFoundException( + `Unable to find Communication with ID: ${communicationID}`, + LogContext.COMMUNICATION + ); - await this.roomService.deleteRoom(this.getUpdates(communication)); + await this.roomService.deleteRoom(communication.updates); await this.communicationRepository.remove(communication as Communication); return true; @@ -277,11 +114,7 @@ export class CommunicationService { const communicationRoomIDs: string[] = [ this.getUpdates(communication).externalRoomID, ]; - const discussions = await this.getDiscussions(communication); - for (const discussion of discussions) { - const room = await this.discussionService.getComments(discussion.id); - communicationRoomIDs.push(room.displayName); - } + return communicationRoomIDs; } @@ -304,10 +137,7 @@ export class CommunicationService { const communicationRoomIDs: string[] = [ this.getUpdates(communication).externalRoomID, ]; - for (const discussion of await this.getDiscussions(communication)) { - const room = await this.discussionService.getComments(discussion.id); - communicationRoomIDs.push(room.externalRoomID); - } + await this.communicationAdapter.removeUserFromRooms( communicationRoomIDs, user.communicationID diff --git a/src/domain/communication/discussion/discussion.interface.ts b/src/domain/communication/discussion/discussion.interface.ts deleted file mode 100644 index 4594b6832a..0000000000 --- a/src/domain/communication/discussion/discussion.interface.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { DiscussionCategory } from '@common/enums/communication.discussion.category'; -import { Field, ObjectType } from '@nestjs/graphql'; -import { IRoom } from '../room/room.interface'; -import { INameable } from '@domain/common/entity/nameable-entity'; - -@ObjectType('Discussion') -export abstract class IDiscussion extends INameable { - @Field(() => DiscussionCategory, { - description: 'The category assigned to this Discussion.', - }) - category!: string; - - createdBy!: string; - - comments!: IRoom; -} diff --git a/src/domain/communication/discussion/index.ts b/src/domain/communication/discussion/index.ts deleted file mode 100644 index 99b66f225a..0000000000 --- a/src/domain/communication/discussion/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '../communication/dto/communication.dto.create.discussion'; diff --git a/src/domain/communication/index.ts b/src/domain/communication/index.ts deleted file mode 100644 index 63de687be9..0000000000 --- a/src/domain/communication/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './communication'; -export * from './discussion'; diff --git a/src/domain/communication/room/room.module.ts b/src/domain/communication/room/room.module.ts index 36c217533d..49019ceeba 100644 --- a/src/domain/communication/room/room.module.ts +++ b/src/domain/communication/room/room.module.ts @@ -18,7 +18,6 @@ import { RoomServiceEvents } from './room.service.events'; import { RoomEventResolverSubscription } from './room.event.resolver.subscription'; import { SubscriptionServiceModule } from '@services/subscriptions/subscription-service'; import { VirtualContributorModule } from '@domain/community/virtual-contributor/virtual.contributor.module'; -import { VirtualPersonaModule } from '@platform/virtual-persona/virtual.persona.module'; @Module({ imports: [ @@ -32,7 +31,6 @@ import { VirtualPersonaModule } from '@platform/virtual-persona/virtual.persona. RoomModule, CommunicationAdapterModule, MessagingModule, - VirtualPersonaModule, VirtualContributorModule, TypeOrmModule.forFeature([Room]), SubscriptionServiceModule, diff --git a/src/domain/communication/room/room.service.events.ts b/src/domain/communication/room/room.service.events.ts index cf8db733c1..46cc6a48ff 100644 --- a/src/domain/communication/room/room.service.events.ts +++ b/src/domain/communication/room/room.service.events.ts @@ -11,7 +11,7 @@ import { NotificationInputEntityMentions } from '@services/adapters/notification import { getMentionsFromText } from '../messaging/get.mentions.from.text'; import { IRoom } from './room.interface'; import { NotificationInputForumDiscussionComment } from '@services/adapters/notification-adapter/dto/notification.dto.input.forum.discussion.comment'; -import { IDiscussion } from '../discussion/discussion.interface'; +import { IDiscussion } from '../../../platform/forum-discussion/discussion.interface'; import { NotificationInputUpdateSent } from '@services/adapters/notification-adapter/dto/notification.dto.input.update.sent'; import { ActivityInputUpdateSent } from '@services/adapters/activity-adapter/dto/activity.dto.input.update.sent'; import { ActivityInputMessageRemoved } from '@services/adapters/activity-adapter/dto/activity.dto.input.message.removed'; @@ -34,8 +34,7 @@ import { NotSupportedException } from '@common/exceptions'; import { EntityManager } from 'typeorm'; import { InjectEntityManager } from '@nestjs/typeorm'; import { Space } from '@domain/space/space/space.entity'; -import { VirtualPersonaService } from '@platform/virtual-persona/virtual.persona.service'; -import { VirtualPersonaQuestionInput } from '@platform/virtual-persona/dto/virtual.persona.question.dto.input'; +import { VirtualContributorQuestionInput } from '@domain/community/virtual-contributor/dto/virtual.contributor.dto.question.input'; @Injectable() export class RoomServiceEvents { @@ -46,7 +45,6 @@ export class RoomServiceEvents { private communityResolverService: CommunityResolverService, private roomService: RoomService, private subscriptionPublishService: SubscriptionPublishService, - private virtualPersonaService: VirtualPersonaService, private virtualContributorService: VirtualContributorService, // this should use the space service but still the same circular dependency issue :( @InjectEntityManager('default') @@ -98,12 +96,12 @@ export class RoomServiceEvents { mention.nameId, { relations: { - virtualPersona: true, + aiPersona: true, }, } ); - const virtualPersona = virtualContributor?.virtualPersona; + const virtualPersona = virtualContributor?.aiPersona; if (!virtualPersona) { throw new Error( @@ -111,24 +109,15 @@ export class RoomServiceEvents { ); } - const chatData: VirtualPersonaQuestionInput = { - virtualPersonaID: virtualPersona.id, + const chatData: VirtualContributorQuestionInput = { + virtualContributorID: virtualContributor.id, question: question.message, }; - let knowledgeSpaceId = undefined; - if (virtualContributor.bodyOfKnowledgeID) { - //toDo should not be needed, fix in https://app.zenhub.com/workspaces/alkemio-development-5ecb98b262ebd9f4aec4194c/issues/gh/alkem-io/virtual-contributor-ingest-space/5 - knowledgeSpaceId = await this.getSpaceNameId( - virtualContributor.bodyOfKnowledgeID - ); - } - - const result = await this.virtualPersonaService.askQuestion( + const result = await this.virtualContributorService.askQuestion( chatData, agentInfo, - spaceNameID, - knowledgeSpaceId + spaceNameID ); let answer = result.answer; diff --git a/src/domain/community/ai-persona/ai.persona.entity.ts b/src/domain/community/ai-persona/ai.persona.entity.ts new file mode 100644 index 0000000000..3b360c62b5 --- /dev/null +++ b/src/domain/community/ai-persona/ai.persona.entity.ts @@ -0,0 +1,35 @@ +import { Column, Entity } from 'typeorm'; +import { IAiPersona } from './ai.persona.interface'; +import { AuthorizableEntity } from '@domain/common/entity/authorizable-entity'; +import { AiPersonaInteractionMode } from '@common/enums/ai.persona.interaction.mode'; +import { AiPersonaBodyOfKnowledgeType } from '@common/enums/ai.persona.body.of.knowledge.type'; +import { AiPersonaDataAccessMode } from '@common/enums/ai.persona.data.access.mode'; + +@Entity() +export class AiPersona extends AuthorizableEntity implements IAiPersona { + // No direct link; this is a generic identifier + @Column('varchar', { nullable: false, length: 128 }) + aiPersonaServiceID!: string; + + @Column('text', { nullable: true }) + description = ''; + + @Column('varchar', { + length: 255, + default: AiPersonaDataAccessMode.SPACE_PROFILE_AND_CONTENTS, + }) + dataAccessMode!: AiPersonaDataAccessMode; + + @Column('simple-array', { + nullable: false, + default: [AiPersonaInteractionMode.DISCUSSION_TAGGING], + }) + interactionModes!: AiPersonaInteractionMode[]; + + @Column('varchar', { + length: 255, + nullable: false, + default: AiPersonaBodyOfKnowledgeType.ALKEMIO_SPACE, + }) + bodyOfKnowledgeType!: AiPersonaBodyOfKnowledgeType; +} diff --git a/src/domain/community/ai-persona/ai.persona.interface.ts b/src/domain/community/ai-persona/ai.persona.interface.ts new file mode 100644 index 0000000000..b72c84f86e --- /dev/null +++ b/src/domain/community/ai-persona/ai.persona.interface.ts @@ -0,0 +1,37 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { IAuthorizable } from '@domain/common/entity/authorizable-entity'; +import { Markdown } from '@domain/common/scalars/scalar.markdown'; +import { AiPersonaBodyOfKnowledgeType } from '@common/enums/ai.persona.body.of.knowledge.type'; +import { AiPersonaDataAccessMode } from '@common/enums/ai.persona.data.access.mode'; +import { AiPersonaInteractionMode } from '@common/enums/ai.persona.interaction.mode'; + +@ObjectType('AiPersona') +export class IAiPersona extends IAuthorizable { + aiPersonaServiceID!: string; + + @Field(() => Markdown, { + nullable: false, + description: 'The description for this AI Persona.', + }) + description!: string; + + @Field(() => AiPersonaBodyOfKnowledgeType, { + nullable: false, + description: 'The type of knowledge provided by this AI Persona.', + }) + bodyOfKnowledgeType!: AiPersonaBodyOfKnowledgeType; + + @Field(() => AiPersonaDataAccessMode, { + nullable: false, + description: + 'The type of context sharing that are supported by this AI Persona when used.', + }) + dataAccessMode!: AiPersonaDataAccessMode; + + @Field(() => [AiPersonaInteractionMode], { + nullable: false, + description: + 'The type of interactions that are supported by this AI Persona when used.', + }) + interactionModes!: AiPersonaInteractionMode[]; +} diff --git a/src/domain/community/ai-persona/ai.persona.module.ts b/src/domain/community/ai-persona/ai.persona.module.ts new file mode 100644 index 0000000000..2a2c3e7153 --- /dev/null +++ b/src/domain/community/ai-persona/ai.persona.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common'; +import { AiPersonaService } from './ai.persona.service'; +import { AiPersonaResolverMutations } from './ai.persona.resolver.mutations'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AiPersonaResolverQueries } from './ai.persona.resolver.queries'; +import { AiPersonaAuthorizationService } from './ai.persona.service.authorization'; +import { AuthorizationModule } from '@core/authorization/authorization.module'; +import { AuthorizationPolicyModule } from '@domain/common/authorization-policy/authorization.policy.module'; +import { AiPersona } from './ai.persona.entity'; +import { AiPersonaResolverFields } from './ai.persona.resolver.fields'; +import { AiServerAdapterModule } from '@services/adapters/ai-server-adapter/ai.server.adapter.module'; + +@Module({ + imports: [ + AuthorizationPolicyModule, + AuthorizationModule, + AiServerAdapterModule, + TypeOrmModule.forFeature([AiPersona]), + ], + providers: [ + AiPersonaService, + AiPersonaAuthorizationService, + AiPersonaResolverQueries, + AiPersonaResolverMutations, + AiPersonaResolverFields, + ], + exports: [AiPersonaService, AiPersonaAuthorizationService], +}) +export class AiPersonaModule {} diff --git a/src/domain/community/ai-persona/ai.persona.resolver.fields.ts b/src/domain/community/ai-persona/ai.persona.resolver.fields.ts new file mode 100644 index 0000000000..f1bbc30faa --- /dev/null +++ b/src/domain/community/ai-persona/ai.persona.resolver.fields.ts @@ -0,0 +1,43 @@ +import { UseGuards } from '@nestjs/common'; +import { Resolver } from '@nestjs/graphql'; +import { Parent, ResolveField } from '@nestjs/graphql'; +import { AiPersona } from './ai.persona.entity'; +import { AiPersonaService } from './ai.persona.service'; +import { AuthorizationPrivilege } from '@common/enums'; +import { GraphqlGuard } from '@core/authorization'; +import { CurrentUser, Profiling } from '@common/decorators'; +import { AuthorizationService } from '@core/authorization/authorization.service'; +import { IAuthorizationPolicy } from '@domain/common/authorization-policy'; +import { IAiPersona } from './ai.persona.interface'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; + +@Resolver(() => IAiPersona) +export class AiPersonaResolverFields { + constructor( + private authorizationService: AuthorizationService, + private aiPersonaService: AiPersonaService + ) {} + + @UseGuards(GraphqlGuard) + @ResolveField('authorization', () => IAuthorizationPolicy, { + nullable: true, + description: 'The Authorization for this Virtual.', + }) + @Profiling.api + async authorization( + @Parent() parent: AiPersona, + @CurrentUser() agentInfo: AgentInfo + ) { + // Reload to ensure the authorization is loaded + const aiPersona = await this.aiPersonaService.getAiPersonaOrFail(parent.id); + + this.authorizationService.grantAccessOrFail( + agentInfo, + aiPersona.authorization, + AuthorizationPrivilege.READ, + `virtual authorization access: ${aiPersona.id}` + ); + + return aiPersona.authorization; + } +} diff --git a/src/domain/community/ai-persona/ai.persona.resolver.mutations.ts b/src/domain/community/ai-persona/ai.persona.resolver.mutations.ts new file mode 100644 index 0000000000..e310c6f7b4 --- /dev/null +++ b/src/domain/community/ai-persona/ai.persona.resolver.mutations.ts @@ -0,0 +1,40 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Resolver, Mutation } from '@nestjs/graphql'; +import { AiPersonaService } from './ai.persona.service'; +import { CurrentUser, Profiling } from '@src/common/decorators'; +import { GraphqlGuard } from '@core/authorization'; +import { AuthorizationPrivilege } from '@common/enums'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { AuthorizationService } from '@core/authorization/authorization.service'; +import { IAiPersona } from './ai.persona.interface'; +import { UpdateAiPersonaInput } from './dto/ai.persona.dto.update'; + +@Resolver(() => IAiPersona) +export class AiPersonaResolverMutations { + constructor( + private aiPersonaService: AiPersonaService, + private authorizationService: AuthorizationService + ) {} + + @UseGuards(GraphqlGuard) + @Mutation(() => IAiPersona, { + description: 'Updates the specified AiPersona.', + }) + @Profiling.api + async updateAiPersona( + @CurrentUser() agentInfo: AgentInfo, + @Args('aiPersonaData') aiPersonaData: UpdateAiPersonaInput + ): Promise { + const aiPersona = await this.aiPersonaService.getAiPersonaOrFail( + aiPersonaData.ID + ); + await this.authorizationService.grantAccessOrFail( + agentInfo, + aiPersona.authorization, + AuthorizationPrivilege.UPDATE, + `orgUpdate: ${aiPersona.id}` + ); + + return await this.aiPersonaService.updateAiPersona(aiPersonaData); + } +} diff --git a/src/domain/community/ai-persona/ai.persona.resolver.queries.ts b/src/domain/community/ai-persona/ai.persona.resolver.queries.ts new file mode 100644 index 0000000000..6ee91dbcf9 --- /dev/null +++ b/src/domain/community/ai-persona/ai.persona.resolver.queries.ts @@ -0,0 +1,25 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; +import { CurrentUser } from '@src/common/decorators'; +import { AiPersonaService } from './ai.persona.service'; +import { UseGuards } from '@nestjs/common'; +import { GraphqlGuard } from '@core/authorization'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { IAiPersonaQuestionResult } from './dto/ai.persona.question.dto.result'; +import { AiPersonaQuestionInput } from './dto/ai.persona.question.dto.input'; + +@Resolver() +export class AiPersonaResolverQueries { + constructor(private aiPersonaService: AiPersonaService) {} + + @UseGuards(GraphqlGuard) + @Query(() => IAiPersonaQuestionResult, { + nullable: false, + description: 'Ask the virtual persona engine for guidance.', + }) + async askAiPersonaQuestion( + @CurrentUser() agentInfo: AgentInfo, + @Args('chatData') chatData: AiPersonaQuestionInput + ): Promise { + return this.aiPersonaService.askQuestion(chatData, agentInfo, ''); + } +} diff --git a/src/domain/community/ai-persona/ai.persona.service.authorization.ts b/src/domain/community/ai-persona/ai.persona.service.authorization.ts new file mode 100644 index 0000000000..2a9aeb9a2a --- /dev/null +++ b/src/domain/community/ai-persona/ai.persona.service.authorization.ts @@ -0,0 +1,202 @@ +import { Injectable } from '@nestjs/common'; +import { AuthorizationCredential, LogContext } from '@common/enums'; +import { AuthorizationPrivilege } from '@common/enums'; +import { IAuthorizationPolicy } from '@domain/common/authorization-policy'; +import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; +import { + EntityNotInitializedException, + RelationshipNotFoundException, +} from '@common/exceptions'; +import { AiPersonaService } from './ai.persona.service'; +import { IAuthorizationPolicyRuleCredential } from '@core/authorization/authorization.policy.rule.credential.interface'; +import { + CREDENTIAL_RULE_TYPES_ORGANIZATION_AUTHORIZATION_RESET, + CREDENTIAL_RULE_TYPES_ORGANIZATION_GLOBAL_COMMUNITY_READ, + CREDENTIAL_RULE_TYPES_ORGANIZATION_GLOBAL_ADMINS, + CREDENTIAL_RULE_ORGANIZATION_ADMIN, + CREDENTIAL_RULE_ORGANIZATION_READ, + CREDENTIAL_RULE_ORGANIZATION_SELF_REMOVAL, +} from '@common/constants'; +import { IAiPersona } from './ai.persona.interface'; + +@Injectable() +export class AiPersonaAuthorizationService { + constructor( + private aiPersonaService: AiPersonaService, + private authorizationPolicy: AuthorizationPolicyService, + private authorizationPolicyService: AuthorizationPolicyService + ) {} + + async applyAuthorizationPolicy( + aiPersonaInput: IAiPersona, + parentAuthorization: IAuthorizationPolicy | undefined + ): Promise { + const aiPersona = await this.aiPersonaService.getAiPersonaOrFail( + aiPersonaInput.id, + { + relations: { + authorization: true, + }, + } + ); + if (!aiPersona.authorization) + throw new RelationshipNotFoundException( + `Unable to load entities for virtual persona: ${aiPersona.id} `, + LogContext.COMMUNITY + ); + aiPersona.authorization = await this.authorizationPolicyService.reset( + aiPersona.authorization + ); + + aiPersona.authorization = + this.authorizationPolicyService.inheritParentAuthorization( + aiPersona.authorization, + parentAuthorization + ); + aiPersona.authorization = this.appendCredentialRules( + aiPersona.authorization, + aiPersona.id + ); + + return aiPersona; + } + + private appendCredentialRules( + authorization: IAuthorizationPolicy | undefined, + virtualID: string + ): IAuthorizationPolicy { + if (!authorization) + throw new EntityNotInitializedException( + `Authorization definition not found for virtual: ${virtualID}`, + LogContext.COMMUNITY + ); + + const newRules: IAuthorizationPolicyRuleCredential[] = []; + + // Allow global admins to reset authorization + const globalAdminNotInherited = + this.authorizationPolicy.createCredentialRuleUsingTypesOnly( + [AuthorizationPrivilege.AUTHORIZATION_RESET], + [ + AuthorizationCredential.GLOBAL_ADMIN, + AuthorizationCredential.GLOBAL_SUPPORT, + ], + CREDENTIAL_RULE_TYPES_ORGANIZATION_AUTHORIZATION_RESET + ); + globalAdminNotInherited.cascade = false; + newRules.push(globalAdminNotInherited); + + const communityAdmin = + this.authorizationPolicyService.createCredentialRuleUsingTypesOnly( + [ + AuthorizationPrivilege.GRANT, + AuthorizationPrivilege.CREATE, + AuthorizationPrivilege.READ, + AuthorizationPrivilege.UPDATE, + AuthorizationPrivilege.DELETE, + ], + [AuthorizationCredential.GLOBAL_COMMUNITY_READ], + CREDENTIAL_RULE_TYPES_ORGANIZATION_GLOBAL_COMMUNITY_READ + ); + newRules.push(communityAdmin); + + // Allow Global admins + Global Space Admins to manage access to Spaces + contents + const globalAdmin = + this.authorizationPolicyService.createCredentialRuleUsingTypesOnly( + [AuthorizationPrivilege.GRANT], + [ + AuthorizationCredential.GLOBAL_ADMIN, + AuthorizationCredential.GLOBAL_SUPPORT, + ], + CREDENTIAL_RULE_TYPES_ORGANIZATION_GLOBAL_ADMINS + ); + newRules.push(globalAdmin); + + const virtualAdmin = this.authorizationPolicyService.createCredentialRule( + [ + AuthorizationPrivilege.GRANT, + AuthorizationPrivilege.CREATE, + AuthorizationPrivilege.UPDATE, + AuthorizationPrivilege.DELETE, + ], + [ + { + type: AuthorizationCredential.ORGANIZATION_ADMIN, + resourceID: virtualID, + }, + { + type: AuthorizationCredential.ORGANIZATION_OWNER, + resourceID: virtualID, + }, + ], + CREDENTIAL_RULE_ORGANIZATION_ADMIN + ); + + newRules.push(virtualAdmin); + + const readPrivilege = this.authorizationPolicyService.createCredentialRule( + [AuthorizationPrivilege.READ], + [ + { + type: AuthorizationCredential.ORGANIZATION_ASSOCIATE, + resourceID: virtualID, + }, + { + type: AuthorizationCredential.ORGANIZATION_ADMIN, + resourceID: virtualID, + }, + { + type: AuthorizationCredential.ORGANIZATION_OWNER, + resourceID: virtualID, + }, + { + type: AuthorizationCredential.GLOBAL_REGISTERED, + resourceID: '', + }, + ], + CREDENTIAL_RULE_ORGANIZATION_READ + ); + newRules.push(readPrivilege); + + const updatedAuthorization = + this.authorizationPolicy.appendCredentialAuthorizationRules( + authorization, + newRules + ); + + return updatedAuthorization; + } + + public extendAuthorizationPolicyForSelfRemoval( + virtual: IAiPersona, + userToBeRemovedID: string + ): IAuthorizationPolicy { + const newRules: IAuthorizationPolicyRuleCredential[] = []; + + const userSelfRemovalRule = + this.authorizationPolicyService.createCredentialRule( + [AuthorizationPrivilege.GRANT], + [ + { + type: AuthorizationCredential.USER_SELF_MANAGEMENT, + resourceID: userToBeRemovedID, + }, + ], + CREDENTIAL_RULE_ORGANIZATION_SELF_REMOVAL + ); + newRules.push(userSelfRemovalRule); + + const clonedVirtualAuthorization = + this.authorizationPolicyService.cloneAuthorizationPolicy( + virtual.authorization + ); + + const updatedAuthorization = + this.authorizationPolicyService.appendCredentialAuthorizationRules( + clonedVirtualAuthorization, + newRules + ); + + return updatedAuthorization; + } +} diff --git a/src/domain/community/ai-persona/ai.persona.service.ts b/src/domain/community/ai-persona/ai.persona.service.ts new file mode 100644 index 0000000000..e9328926dc --- /dev/null +++ b/src/domain/community/ai-persona/ai.persona.service.ts @@ -0,0 +1,170 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { FindOneOptions, Repository } from 'typeorm'; +import { EntityNotFoundException } from '@common/exceptions'; +import { AuthorizationPolicy } from '@domain/common/authorization-policy'; +import { AiPersona } from './ai.persona.entity'; +import { IAiPersona } from './ai.persona.interface'; +import { CreateAiPersonaInput as CreateAiPersonaInput } from './dto/ai.persona.dto.create'; +import { DeleteAiPersonaInput as DeleteAiPersonaInput } from './dto/ai.persona.dto.delete'; +import { UpdateAiPersonaInput } from './dto/ai.persona.dto.update'; +import { IAiPersonaQuestionResult } from './dto/ai.persona.question.dto.result'; +import { AiPersonaQuestionInput } from './dto/ai.persona.question.dto.input'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { LogContext } from '@common/enums/logging.context'; +import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; +import { AiServerAdapter } from '@services/adapters/ai-server-adapter/ai.server.adapter'; +import { AiServerAdapterAskQuestionInput } from '@services/adapters/ai-server-adapter/dto/ai.server.adapter.dto.ask.question'; +import { AiPersonaBodyOfKnowledgeType } from '@common/enums/ai.persona.body.of.knowledge.type'; +import { AiPersonaDataAccessMode } from '@common/enums/ai.persona.data.access.mode'; +import { AiPersonaInteractionMode } from '@common/enums/ai.persona.interaction.mode'; +import { SpaceIngestionPurpose } from '@services/infrastructure/event-bus/commands'; + +@Injectable() +export class AiPersonaService { + constructor( + private authorizationPolicyService: AuthorizationPolicyService, + @InjectRepository(AiPersona) + private aiPersonaRepository: Repository, + private aiServerAdapter: AiServerAdapter, + @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService + ) {} + + async createAiPersona( + aiPersonaData: CreateAiPersonaInput + ): Promise { + let aiPersona: IAiPersona = new AiPersona(); + aiPersona.description = aiPersonaData.description ?? ''; + aiPersona.authorization = new AuthorizationPolicy(); + aiPersona.bodyOfKnowledgeType = AiPersonaBodyOfKnowledgeType.ALKEMIO_SPACE; + aiPersona.dataAccessMode = + AiPersonaDataAccessMode.SPACE_PROFILE_AND_CONTENTS; + aiPersona.interactionModes = [AiPersonaInteractionMode.DISCUSSION_TAGGING]; + + if (aiPersonaData.aiPersonaServiceID) { + const personaService = await this.aiServerAdapter.getPersonaServiceOrFail( + aiPersonaData.aiPersonaServiceID + ); + + this.aiServerAdapter.ensurePersonaIsUsable( + personaService.id, + SpaceIngestionPurpose.KNOWLEDGE + ); + aiPersona.aiPersonaServiceID = personaService.id; + } else if (aiPersonaData.aiPersonaService) { + const aiPersonaService = + await this.aiServerAdapter.createAiPersonaService( + aiPersonaData.aiPersonaService + ); + aiPersona.aiPersonaServiceID = aiPersonaService.id; + } + + aiPersona = await this.aiPersonaRepository.save(aiPersona); + this.logger.verbose?.( + `Created new AI Persona with id ${aiPersona.id}`, + LogContext.PLATFORM + ); + + return aiPersona; + } + + async updateAiPersona( + aiPersonaData: UpdateAiPersonaInput + ): Promise { + const aiPersona = await this.getAiPersonaOrFail(aiPersonaData.ID, { + relations: { authorization: true }, + }); + + return await this.aiPersonaRepository.save(aiPersona); + } + + async deleteAiPersona(deleteData: DeleteAiPersonaInput): Promise { + const personaID = deleteData.ID; + + const aiPersona = await this.getAiPersonaOrFail(personaID, { + relations: { + authorization: true, + }, + }); + if (!aiPersona.authorization) { + throw new EntityNotFoundException( + `Unable to find all fields on Virtual Persona with ID: ${deleteData.ID}`, + LogContext.PLATFORM + ); + } + await this.authorizationPolicyService.delete(aiPersona.authorization); + const result = await this.aiPersonaRepository.remove( + aiPersona as AiPersona + ); + result.id = personaID; + return result; + } + + public async getAiPersona( + aiPersonaID: string, + options?: FindOneOptions + ): Promise { + const aiPersona = await this.aiPersonaRepository.findOne({ + ...options, + where: { ...options?.where, id: aiPersonaID }, + }); + + return aiPersona; + } + + public async getAiPersonaOrFail( + virtualID: string, + options?: FindOneOptions + ): Promise { + const aiPersona = await this.getAiPersona(virtualID, options); + if (!aiPersona) + throw new EntityNotFoundException( + `Unable to find Virtual Persona with ID: ${virtualID}`, + LogContext.PLATFORM + ); + return aiPersona; + } + + async save(aiPersona: IAiPersona): Promise { + return await this.aiPersonaRepository.save(aiPersona); + } + + public async askQuestion( + personaQuestionInput: AiPersonaQuestionInput, + agentInfo: AgentInfo, + contextSpaceNameID: string + ): Promise { + const aiPersona = await this.getAiPersonaOrFail( + personaQuestionInput.aiPersonaID, + { + relations: { + authorization: true, + }, + } + ); + + if (!aiPersona.authorization) { + throw new EntityNotFoundException( + `Unable to find AI Persona Service for AI Persona with ID: ${personaQuestionInput.aiPersonaID}`, + LogContext.PLATFORM + ); + } + + this.logger.verbose?.( + `Asking question to AI Persona from user ${agentInfo.userID} + with context ${contextSpaceNameID}`, + LogContext.PLATFORM + ); + + const input: AiServerAdapterAskQuestionInput = { + question: personaQuestionInput.question, + personaServiceID: aiPersona.aiPersonaServiceID, + }; + + return await this.aiServerAdapter.askQuestion( + input, + agentInfo, + contextSpaceNameID + ); + } +} diff --git a/src/domain/community/ai-persona/dto/ai.persona.dto.create.ts b/src/domain/community/ai-persona/dto/ai.persona.dto.create.ts new file mode 100644 index 0000000000..ddfdcb2839 --- /dev/null +++ b/src/domain/community/ai-persona/dto/ai.persona.dto.create.ts @@ -0,0 +1,19 @@ +import { HUGE_TEXT_LENGTH } from '@common/constants'; +import { Markdown } from '@domain/common/scalars/scalar.markdown'; +import { UUID } from '@domain/common/scalars/scalar.uuid'; +import { Field, InputType } from '@nestjs/graphql'; +import { CreateAiPersonaServiceInput } from '@services/ai-server/ai-persona-service/dto/ai.persona.service.dto.create'; +import { MaxLength } from 'class-validator'; + +@InputType() +export class CreateAiPersonaInput { + @Field(() => Markdown, { nullable: true }) + @MaxLength(HUGE_TEXT_LENGTH) + description?: string = ''; + + @Field(() => UUID, { nullable: true }) + aiPersonaServiceID?: string = undefined; + + @Field(() => CreateAiPersonaServiceInput, { nullable: true }) + aiPersonaService?: CreateAiPersonaServiceInput = undefined; +} diff --git a/src/platform/virtual-persona/dto/virtual.persona.dto.delete.ts b/src/domain/community/ai-persona/dto/ai.persona.dto.delete.ts similarity index 78% rename from src/platform/virtual-persona/dto/virtual.persona.dto.delete.ts rename to src/domain/community/ai-persona/dto/ai.persona.dto.delete.ts index 3c9f30aae5..d7584d2744 100644 --- a/src/platform/virtual-persona/dto/virtual.persona.dto.delete.ts +++ b/src/domain/community/ai-persona/dto/ai.persona.dto.delete.ts @@ -3,7 +3,7 @@ import { UUID_NAMEID } from '@domain/common/scalars'; import { Field, InputType } from '@nestjs/graphql'; @InputType() -export class DeleteVirtualPersonaInput extends DeleteBaseAlkemioInput { +export class DeleteAiPersonaInput extends DeleteBaseAlkemioInput { @Field(() => UUID_NAMEID, { nullable: false }) ID!: string; } diff --git a/src/domain/community/ai-persona/dto/ai.persona.dto.update.ts b/src/domain/community/ai-persona/dto/ai.persona.dto.update.ts new file mode 100644 index 0000000000..edeb8f570a --- /dev/null +++ b/src/domain/community/ai-persona/dto/ai.persona.dto.update.ts @@ -0,0 +1,5 @@ +import { UpdateBaseAlkemioInput } from '@domain/common/entity/base-entity'; +import { InputType } from '@nestjs/graphql'; + +@InputType() +export class UpdateAiPersonaInput extends UpdateBaseAlkemioInput {} diff --git a/src/platform/virtual-persona/dto/virtual.persona.question.dto.input.ts b/src/domain/community/ai-persona/dto/ai.persona.question.dto.input.ts similarity index 82% rename from src/platform/virtual-persona/dto/virtual.persona.question.dto.input.ts rename to src/domain/community/ai-persona/dto/ai.persona.question.dto.input.ts index 366890f810..b38f277449 100644 --- a/src/platform/virtual-persona/dto/virtual.persona.question.dto.input.ts +++ b/src/domain/community/ai-persona/dto/ai.persona.question.dto.input.ts @@ -2,12 +2,12 @@ import { UUID } from '@domain/common/scalars'; import { Field, InputType } from '@nestjs/graphql'; @InputType() -export class VirtualPersonaQuestionInput { +export class AiPersonaQuestionInput { @Field(() => UUID, { nullable: false, description: 'Virtual Persona Type.', }) - virtualPersonaID!: string; + aiPersonaID!: string; @Field(() => String, { nullable: false, diff --git a/src/platform/virtual-persona/dto/virtual.persona.question.dto.result.ts b/src/domain/community/ai-persona/dto/ai.persona.question.dto.result.ts similarity index 87% rename from src/platform/virtual-persona/dto/virtual.persona.question.dto.result.ts rename to src/domain/community/ai-persona/dto/ai.persona.question.dto.result.ts index 119fbd429c..fc40a0564b 100644 --- a/src/platform/virtual-persona/dto/virtual.persona.question.dto.result.ts +++ b/src/domain/community/ai-persona/dto/ai.persona.question.dto.result.ts @@ -1,8 +1,8 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { ISource } from '@services/api/chat-guidance/dto/chat.guidance.query.result.dto'; -@ObjectType('VirtualPersonaResult') -export abstract class IVirtualPersonaQuestionResult { +@ObjectType('AiPersonaResult') +export abstract class IAiPersonaQuestionResult { @Field(() => String, { nullable: true, description: 'The id of the answer; null if an error was returned', diff --git a/src/domain/community/ai-persona/dto/index.ts b/src/domain/community/ai-persona/dto/index.ts new file mode 100644 index 0000000000..05c2c78809 --- /dev/null +++ b/src/domain/community/ai-persona/dto/index.ts @@ -0,0 +1,3 @@ +export * from './ai.persona.dto.create'; +export * from './ai.persona.dto.update'; +export * from './ai.persona.dto.delete'; diff --git a/src/domain/community/ai-persona/index.ts b/src/domain/community/ai-persona/index.ts new file mode 100644 index 0000000000..9639df43db --- /dev/null +++ b/src/domain/community/ai-persona/index.ts @@ -0,0 +1,2 @@ +export * from './ai.persona.entity'; +export * from './ai.persona.interface'; diff --git a/src/domain/community/application/application.resolver.fields.ts b/src/domain/community/application/application.resolver.fields.ts index ddd5126b88..14aa0c373d 100644 --- a/src/domain/community/application/application.resolver.fields.ts +++ b/src/domain/community/application/application.resolver.fields.ts @@ -5,9 +5,9 @@ import { ApplicationService } from './application.service'; import { AuthorizationPrivilege } from '@common/enums'; import { Application, IApplication } from '@domain/community/application'; import { GraphqlGuard } from '@core/authorization'; -import { IUser } from '@domain/community/user/user.interface'; import { AuthorizationAgentPrivilege, Profiling } from '@src/common/decorators'; import { IQuestion } from '@domain/common/question/question.interface'; +import { IContributor } from '../contributor/contributor.interface'; @Resolver(() => IApplication) export class ApplicationResolverFields { @@ -15,13 +15,13 @@ export class ApplicationResolverFields { @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) @UseGuards(GraphqlGuard) - @ResolveField('user', () => IUser, { + @ResolveField('contributor', () => IContributor, { nullable: false, description: 'The User for this Application.', }) @Profiling.api - async user(@Parent() application: Application): Promise { - return await this.applicationService.getUser(application.id); + async contributor(@Parent() application: Application): Promise { + return await this.applicationService.getContributor(application.id); } @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) diff --git a/src/domain/community/application/application.service.authorization.ts b/src/domain/community/application/application.service.authorization.ts index d976395e23..d40595f940 100644 --- a/src/domain/community/application/application.service.authorization.ts +++ b/src/domain/community/application/application.service.authorization.ts @@ -37,7 +37,7 @@ export class ApplicationAuthorizationService { const newRules: IAuthorizationPolicyRuleCredential[] = []; // get the user - const user = await this.applicationService.getUser(application.id); + const user = await this.applicationService.getContributor(application.id); // also grant the user privileges to manage their own application const userApplicationRule = diff --git a/src/domain/community/application/application.service.ts b/src/domain/community/application/application.service.ts index efdc69e2a2..2caf5aa405 100644 --- a/src/domain/community/application/application.service.ts +++ b/src/domain/community/application/application.service.ts @@ -20,9 +20,9 @@ import { LifecycleService } from '@domain/common/lifecycle/lifecycle.service'; import { applicationLifecycleConfig } from '@domain/community/application/application.lifecycle.config'; import { AuthorizationPolicy } from '@domain/common/authorization-policy'; import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; -import { IUser } from '@domain/community/user/user.interface'; import { IQuestion } from '@domain/common/question/question.interface'; import { asyncFilter } from '@common/utils'; +import { IContributor } from '../contributor/contributor.interface'; @Injectable() export class ApplicationService { @@ -99,14 +99,14 @@ export class ApplicationService { return await this.applicationRepository.save(application); } - async getUser(applicationID: string): Promise { + async getContributor(applicationID: string): Promise { const application = await this.getApplicationOrFail(applicationID, { relations: { user: true }, }); const user = application.user; if (!user) throw new RelationshipNotFoundException( - `Unable to load User for Application ${applicationID} `, + `Unable to load Contributor for Application ${applicationID} `, LogContext.COMMUNITY ); return user; diff --git a/src/domain/community/community/community.lifecycle.invitation.options.provider.ts b/src/domain/community/community/community.lifecycle.invitation.options.provider.ts index 1daa35946b..c57cc49aa2 100644 --- a/src/domain/community/community/community.lifecycle.invitation.options.provider.ts +++ b/src/domain/community/community/community.lifecycle.invitation.options.provider.ts @@ -95,9 +95,9 @@ export class CommunityInvitationLifecycleOptionsProvider { }, } ); - const userID = invitation.invitedUser; + const contributorID = invitation.invitedContributor; const community = invitation.community; - if (!userID || !community) { + if (!contributorID || !community) { throw new EntityNotInitializedException( `Lifecycle not initialized on Invitation: ${invitation.id}`, LogContext.COMMUNITY @@ -111,17 +111,18 @@ export class CommunityInvitationLifecycleOptionsProvider { LogContext.COMMUNITY ); } - await this.communityService.assignUserToRole( + await this.communityService.assignContributorToRole( community.parentCommunity, - userID, + contributorID, CommunityRole.MEMBER, + invitation.contributorType, event.agentInfo, true ); } await this.communityService.assignUserToRole( community, - userID, + contributorID, CommunityRole.MEMBER, event.agentInfo, true diff --git a/src/domain/community/community/community.module.ts b/src/domain/community/community/community.module.ts index 1dcd4c6353..177d909ca8 100644 --- a/src/domain/community/community/community.module.ts +++ b/src/domain/community/community/community.module.ts @@ -31,6 +31,8 @@ import { CommunityGuidelinesModule } from '../community-guidelines/community.gui import { VirtualContributorModule } from '../virtual-contributor/virtual.contributor.module'; import { LicenseEngineModule } from '@core/license-engine/license.engine.module'; import { AccountHostModule } from '@domain/space/account/account.host.module'; +import { ContributorModule } from '../contributor/contributor.module'; +import { AiServerAdapterModule } from '@services/adapters/ai-server-adapter/ai.server.adapter.module'; @Module({ imports: [ @@ -42,6 +44,7 @@ import { AccountHostModule } from '@domain/space/account/account.host.module'; AgentModule, UserGroupModule, UserModule, + ContributorModule, OrganizationModule, VirtualContributorModule, ApplicationModule, @@ -59,6 +62,7 @@ import { AccountHostModule } from '@domain/space/account/account.host.module'; TypeOrmModule.forFeature([Community]), TrustRegistryAdapterModule, ContributionReporterModule, + AiServerAdapterModule, ], providers: [ CommunityService, diff --git a/src/domain/community/community/community.resolver.mutations.ts b/src/domain/community/community/community.resolver.mutations.ts index fbb91ae9e0..0ac1368151 100644 --- a/src/domain/community/community/community.resolver.mutations.ts +++ b/src/domain/community/community/community.resolver.mutations.ts @@ -38,7 +38,6 @@ import { NotificationInputCommunityInvitation } from '@services/adapters/notific import { InvitationEventInput } from '../invitation/dto/invitation.dto.event'; import { CommunityInvitationLifecycleOptionsProvider } from './community.lifecycle.invitation.options.provider'; import { CreateInvitationInput, IInvitation } from '../invitation'; -import { CreateInvitationForUsersOnCommunityInput } from './dto/community.dto.invite.existing.user'; import { IOrganization } from '../organization'; import { IUser } from '../user/user.interface'; import { CreateInvitationUserByEmailOnCommunityInput } from './dto/community.dto.invite.external.user'; @@ -54,13 +53,13 @@ import { VirtualContributorService } from '../virtual-contributor/virtual.contri import { IVirtualContributor } from '../virtual-contributor'; import { EntityNotInitializedException } from '@common/exceptions'; import { CommunityInvitationException } from '@common/exceptions/community.invitation.exception'; -import { EventBus } from '@nestjs/cqrs'; -import { - IngestSpace, - SpaceIngestionPurpose, -} from '@services/infrastructure/event-bus/commands'; +import { SpaceIngestionPurpose } from '@services/infrastructure/event-bus/commands'; import { AccountHostService } from '@domain/space/account/account.host.service'; +import { CreateInvitationForContributorsOnCommunityInput } from './dto/community.dto.invite.contributor'; +import { IContributor } from '../contributor/contributor.interface'; +import { ContributorService } from '../contributor/contributor.service'; import { InvitationExternalService } from '../invitation.external/invitation.external.service'; +import { AiServerAdapter } from '@services/adapters/ai-server-adapter/ai.server.adapter'; const IAnyInvitation = createUnionType({ name: 'AnyInvitation', @@ -95,8 +94,9 @@ export class CommunityResolverMutations { private invitationExternalAuthorizationService: InvitationExternalAuthorizationService, private communityAuthorizationService: CommunityAuthorizationService, private accountHostService: AccountHostService, + private contributorService: ContributorService, private invitationExternalService: InvitationExternalService, - private eventBus: EventBus + private aiServerAdapter: AiServerAdapter ) {} @UseGuards(GraphqlGuard) @@ -261,13 +261,10 @@ export class CommunityResolverMutations { ); virtual = await this.virtualContributorService.save(virtual); - // publish to EB for space ingestion const spaceID = await this.communityService.getRootSpaceID(community); - // we are publising an event instead of executing a command because Nest's CQRS - // won't execute a command unless a command handler is defined within the application - // we want to have an external handler so for now events will do - this.eventBus.publish( - new IngestSpace(spaceID, SpaceIngestionPurpose.Context) + this.aiServerAdapter.ensureSpaceIsUsable( + spaceID, + SpaceIngestionPurpose.CONTEXT ); return virtual; @@ -455,13 +452,13 @@ export class CommunityResolverMutations { @UseGuards(GraphqlGuard) @Mutation(() => [IInvitation], { description: - 'Invite an existing User to join the specified Community as a member.', + 'Invite an existing Contriburor to join the specified Community as a member.', }) @Profiling.api - async inviteExistingUserForCommunityMembership( + async inviteContributorsForCommunityMembership( @CurrentUser() agentInfo: AgentInfo, @Args('invitationData') - invitationData: CreateInvitationForUsersOnCommunityInput + invitationData: CreateInvitationForContributorsOnCommunityInput ): Promise { const community = await this.communityService.getCommunityOrFail( invitationData.communityID, @@ -473,6 +470,12 @@ export class CommunityResolverMutations { }, } ); + if (invitationData.invitedContributors.length === 0) { + throw new CommunityInvitationException( + `No contributors were provided to invite: ${community.id}`, + LogContext.COMMUNITY + ); + } await this.authorizationService.grantAccessOrFail( agentInfo, @@ -481,14 +484,17 @@ export class CommunityResolverMutations { `create invitation community: ${community.id}` ); - const users: IUser[] = []; - for (const userID of invitationData.invitedUsers) { - const user = await this.userService.getUserOrFail(userID, { - relations: { - agent: true, - }, - }); - users.push(user); + const contributors: IContributor[] = []; + for (const contributorID of invitationData.invitedContributors) { + const contributor = await this.contributorService.getContributorOrFail( + contributorID, + { + relations: { + agent: true, + }, + } + ); + contributors.push(contributor); } // Logic is that the ability to invite to a subspace requires the ability to invite to the @@ -503,20 +509,20 @@ export class CommunityResolverMutations { ); // Need to see if also can invite to the parent community if any of the users are not members there - for (const user of users) { - if (!user.agent) { + for (const contributor of contributors) { + if (!contributor.agent) { throw new EntityNotInitializedException( - `Unable to load agent on user: ${user.id}`, + `Unable to load agent on contributor: ${contributor.id}`, LogContext.COMMUNITY ); } const isMember = await this.communityService.isMember( - user.agent, + contributor.agent, community.parentCommunity ); if (!isMember && !canInviteToParent) { throw new CommunityInvitationException( - `User is not a member of the parent community (${community.parentCommunity.id}) and the current user does not have the privilege to invite to the parent community`, + `Contributor is not a member of the parent community (${community.parentCommunity.id}) and the current user does not have the privilege to invite to the parent community`, LogContext.COMMUNITY ); } else { @@ -528,10 +534,10 @@ export class CommunityResolverMutations { } return Promise.all( - users.map(async invitedUser => { - return await this.inviteSingleExistingUser( + contributors.map(async invitedContributor => { + return await this.inviteSingleExistingContributor( community, - invitedUser, + invitedContributor, agentInfo, invitationData.invitedToParent, invitationData.welcomeMessage @@ -540,24 +546,23 @@ export class CommunityResolverMutations { ); } - private async inviteSingleExistingUser( + private async inviteSingleExistingContributor( community: ICommunity, - invitedUser: IUser, + invitedContributor: IContributor, agentInfo: AgentInfo, invitedToParent: boolean, welcomeMessage?: string ): Promise { const input: CreateInvitationInput = { communityID: community.id, - invitedUser: invitedUser.id, + invitedContributor: invitedContributor.id, createdBy: agentInfo.userID, invitedToParent, welcomeMessage, }; - let invitation = await this.communityService.createInvitationExistingUser( - input - ); + let invitation = + await this.communityService.createInvitationExistingContributor(input); invitation = await this.invitationAuthorizationService.applyAuthorizationPolicy( @@ -570,7 +575,7 @@ export class CommunityResolverMutations { const notificationInput: NotificationInputCommunityInvitation = { triggeredBy: agentInfo.userID, community: community, - invitedUser: invitedUser.id, + invitedUser: invitedContributor.id, welcomeMessage, }; @@ -663,7 +668,7 @@ export class CommunityResolverMutations { } if (existingUser) { - return this.inviteSingleExistingUser( + return this.inviteSingleExistingContributor( community, existingUser, agentInfo, diff --git a/src/domain/community/community/community.service.authorization.ts b/src/domain/community/community/community.service.authorization.ts index 26203454ed..86525612df 100644 --- a/src/domain/community/community/community.service.authorization.ts +++ b/src/domain/community/community/community.service.authorization.ts @@ -19,13 +19,13 @@ import { CREDENTIAL_RULE_TYPES_ACCESS_VIRTUAL_CONTRIBUTORS, CREDENTIAL_RULE_TYPES_COMMUNITY_ADD_MEMBERS, CREDENTIAL_RULE_TYPES_COMMUNITY_INVITE_MEMBERS, - POLICY_RULE_VC_ADD_TO_COMMUNITY, + POLICY_RULE_COMMUNITY_ADD_VC, + POLICY_RULE_COMMUNITY_INVITE_MEMBER, } from '@common/constants'; import { InvitationExternalAuthorizationService } from '../invitation.external/invitation.external.service.authorization'; import { InvitationAuthorizationService } from '../invitation/invitation.service.authorization'; import { RelationshipNotFoundException } from '@common/exceptions/relationship.not.found.exception'; import { CommunityGuidelinesAuthorizationService } from '../community-guidelines/community.guidelines.service.authorization'; -import { ILicense } from '@domain/license/license/license.interface'; import { CommunityPolicyService } from '../community-policy/community.policy.service'; import { ICredentialDefinition } from '@domain/agent/credential/credential.definition.interface'; import { ICommunityPolicy } from '../community-policy/community.policy.interface'; @@ -33,6 +33,7 @@ import { CommunityRole } from '@common/enums/community.role'; import { LicenseEngineService } from '@core/license-engine/license.engine.service'; import { LicensePrivilege } from '@common/enums/license.privilege'; import { AuthorizationPolicyRulePrivilege } from '@core/authorization/authorization.policy.rule.privilege'; +import { IAgent } from '@domain/agent'; @Injectable() export class CommunityAuthorizationService { @@ -52,7 +53,7 @@ export class CommunityAuthorizationService { async applyAuthorizationPolicy( communityInput: ICommunity, parentAuthorization: IAuthorizationPolicy | undefined, - license: ILicense, + accountAgent: IAgent, communityPolicy: ICommunityPolicy ): Promise { const community = await this.communityService.getCommunityOrFail( @@ -100,7 +101,7 @@ export class CommunityAuthorizationService { community.authorization = await this.extendAuthorizationPolicy( community.authorization, parentAuthorization?.anonymousReadAccess, - license, + accountAgent, communityPolicy ); community.authorization = this.appendVerifiedCredentialRules( @@ -167,7 +168,7 @@ export class CommunityAuthorizationService { private async extendAuthorizationPolicy( authorization: IAuthorizationPolicy | undefined, allowGlobalRegisteredReadAccess: boolean | undefined, - license: ILicense, + accountAgent: IAgent, policy: ICommunityPolicy ): Promise { const newRules: IAuthorizationPolicyRuleCredential[] = []; @@ -224,7 +225,7 @@ export class CommunityAuthorizationService { const accessVirtualContributors = await this.licenseEngineService.isAccessGranted( LicensePrivilege.VIRTUAL_CONTRIBUTOR_ACCESS, - license + accountAgent ); if (accessVirtualContributors) { const criterias: ICredentialDefinition[] = @@ -307,12 +308,19 @@ export class CommunityAuthorizationService { const createVCPrivilege = new AuthorizationPolicyRulePrivilege( [AuthorizationPrivilege.COMMUNITY_ADD_MEMBER_VC_FROM_ACCOUNT], AuthorizationPrivilege.GRANT, - POLICY_RULE_VC_ADD_TO_COMMUNITY + POLICY_RULE_COMMUNITY_ADD_VC + ); + + // If you are able to add a member, then you are also logically able to invite a member + const invitePrivilege = new AuthorizationPolicyRulePrivilege( + [AuthorizationPrivilege.COMMUNITY_INVITE], + AuthorizationPrivilege.COMMUNITY_ADD_MEMBER, + POLICY_RULE_COMMUNITY_INVITE_MEMBER ); return this.authorizationPolicyService.appendPrivilegeAuthorizationRules( authorization, - [createVCPrivilege] + [createVCPrivilege, invitePrivilege] ); } } diff --git a/src/domain/community/community/community.service.events.ts b/src/domain/community/community/community.service.events.ts index b6f3d4ba5e..d4f9d07778 100644 --- a/src/domain/community/community/community.service.events.ts +++ b/src/domain/community/community/community.service.events.ts @@ -37,6 +37,7 @@ export class CommunityEventsService { agentInfo: AgentInfo, newMember: IUser ) { + // TODO: community just needs to know the level, not the type // Send the notification const notificationInput: NotificationInputCommunityNewMember = { userID: newMember.id, @@ -60,8 +61,7 @@ export class CommunityEventsService { } ); break; - case SpaceType.CHALLENGE: - case SpaceType.OPPORTUNITY: + default: // Challenge, Opportunity, VIRTUAL_CONTRIBUTOR, BLANK_SLATE... this.contributionReporter.subspaceJoined( { id: community.parentID, diff --git a/src/domain/community/community/community.service.ts b/src/domain/community/community/community.service.ts index 4cbe8ed87b..dd624659f3 100644 --- a/src/domain/community/community/community.service.ts +++ b/src/domain/community/community/community.service.ts @@ -38,7 +38,6 @@ import { ICommunityPolicy } from '../community-policy/community.policy.interface import { AgentInfo } from '@core/authentication.agent.info/agent.info'; import { CommunityPolicyService } from '../community-policy/community.policy.service'; import { ICommunityPolicyDefinition } from '../community-policy/community.policy.definition'; -import { DiscussionCategoryCommunity } from '@common/enums/communication.discussion.category.community'; import { IForm } from '@domain/common/form/form.interface'; import { FormService } from '@domain/common/form/form.service'; import { UpdateFormInput } from '@domain/common/form/dto/form.dto.update'; @@ -62,6 +61,8 @@ import { IVirtualContributor } from '../virtual-contributor'; import { VirtualContributorService } from '../virtual-contributor/virtual.contributor.service'; import { CommunityRoleImplicit } from '@common/enums/community.role.implicit'; import { AuthorizationCredential } from '@common/enums'; +import { ContributorService } from '../contributor/contributor.service'; +import { IContributor } from '../contributor/contributor.interface'; @Injectable() export class CommunityService { @@ -69,6 +70,7 @@ export class CommunityService { private authorizationPolicyService: AuthorizationPolicyService, private agentService: AgentService, private userService: UserService, + private contributorService: ContributorService, private organizationService: OrganizationService, private virtualContributorService: VirtualContributorService, private userGroupService: UserGroupService, @@ -117,8 +119,7 @@ export class CommunityService { community.communication = await this.communicationService.createCommunication( communityData.name, - '', - Object.values(DiscussionCategoryCommunity) + '' ); return await this.communityRepository.save(community); } @@ -444,11 +445,11 @@ export class CommunityService { } private async findOpenInvitation( - userID: string, + contributorID: string, communityID: string ): Promise { const invitations = await this.invitationService.findExistingInvitations( - userID, + contributorID, communityID ); for (const invitation of invitations) { @@ -551,6 +552,39 @@ export class CommunityService { return policyRole.credential; } + async assignContributorToRole( + community: ICommunity, + contributorID: string, + role: CommunityRole, + contributorType: CommunityContributorType, + agentInfo?: AgentInfo, + triggerNewMemberEvents = false + ): Promise { + switch (contributorType) { + case CommunityContributorType.USER: + return await this.assignUserToRole( + community, + contributorID, + role, + agentInfo, + triggerNewMemberEvents + ); + case CommunityContributorType.ORGANIZATION: + return await this.assignOrganizationToRole( + community, + contributorID, + role + ); + case CommunityContributorType.VIRTUAL: + return await this.assignVirtualToRole(community, contributorID, role); + default: + throw new EntityNotInitializedException( + `Invalid community contributor type: ${contributorType}`, + LogContext.ROLES + ); + } + } + async assignUserToRole( community: ICommunity, userID: string, @@ -573,7 +607,7 @@ export class CommunityService { return user; } - user.agent = await this.assignContributorToRole( + user.agent = await this.assignContributorAgentToRole( community, agent, role, @@ -647,7 +681,7 @@ export class CommunityService { ); } - virtualContributor.agent = await this.assignContributorToRole( + virtualContributor.agent = await this.assignContributorAgentToRole( community, agent, role, @@ -755,7 +789,7 @@ export class CommunityService { const { organization, agent } = await this.organizationService.getOrganizationAndAgent(organizationID); - organization.agent = await this.assignContributorToRole( + organization.agent = await this.assignContributorAgentToRole( community, agent, role, @@ -977,7 +1011,7 @@ export class CommunityService { ); } - public async assignContributorToRole( + public async assignContributorAgentToRole( community: ICommunity, agent: IAgent, role: CommunityRole, @@ -1173,12 +1207,13 @@ export class CommunityService { return application; } - async createInvitationExistingUser( + async createInvitationExistingContributor( invitationData: CreateInvitationInput ): Promise { - const { user, agent } = await this.userService.getUserAndAgent( - invitationData.invitedUser - ); + const { contributor: contributor, agent } = + await this.contributorService.getContributorAndAgent( + invitationData.invitedContributor + ); const community = await this.getCommunityOrFail( invitationData.communityID, { @@ -1186,10 +1221,15 @@ export class CommunityService { } ); - await this.validateInvitationToExistingUser(user, agent, community); + await this.validateInvitationToExistingContributor( + contributor, + agent, + community + ); const invitation = await this.invitationService.createInvitation( - invitationData + invitationData, + contributor ); community.invitations?.push(invitation); await this.communityRepository.save(community); @@ -1237,7 +1277,7 @@ export class CommunityService { ); if (openApplication) { throw new CommunityMembershipException( - `An open application (ID: ${openApplication.id}) already exists for user ${openApplication.user?.id} on Community: ${community.id}.`, + `An open application (ID: ${openApplication.id}) already exists for contributor ${openApplication.user?.id} on Community: ${community.id}.`, LogContext.COMMUNITY ); } @@ -1245,7 +1285,7 @@ export class CommunityService { const openInvitation = await this.findOpenInvitation(user.id, community.id); if (openInvitation) { throw new CommunityMembershipException( - `An open invitation (ID: ${openInvitation.id}) already exists for user ${openInvitation.invitedUser} on Community: ${community.id}.`, + `An open invitation (ID: ${openInvitation.id}) already exists for contributor ${openInvitation.invitedContributor} (${openInvitation.contributorType}) on Community: ${community.id}.`, LogContext.COMMUNITY ); } @@ -1254,31 +1294,34 @@ export class CommunityService { const isExistingMember = await this.isMember(agent, community); if (isExistingMember) throw new CommunityMembershipException( - `User ${user.nameID} is already a member of the Community: ${community.id}.`, + `Contributor ${user.nameID} is already a member of the Community: ${community.id}.`, LogContext.COMMUNITY ); } - private async validateInvitationToExistingUser( - user: IUser, + private async validateInvitationToExistingContributor( + contributor: IContributor, agent: IAgent, community: ICommunity ) { - const openInvitation = await this.findOpenInvitation(user.id, community.id); + const openInvitation = await this.findOpenInvitation( + contributor.id, + community.id + ); if (openInvitation) { throw new CommunityMembershipException( - `An open invitation (ID: ${openInvitation.id}) already exists for user ${openInvitation.invitedUser} on Community: ${community.id}.`, + `An open invitation (ID: ${openInvitation.id}) already exists for contributor ${openInvitation.invitedContributor} (${openInvitation.contributorType}) on Community: ${community.id}.`, LogContext.COMMUNITY ); } const openApplication = await this.findOpenApplication( - user.id, + contributor.id, community.id ); if (openApplication) { throw new CommunityMembershipException( - `An open application (ID: ${openApplication.id}) already exists for user ${openApplication.user?.id} on Community: ${community.id}.`, + `An open application (ID: ${openApplication.id}) already exists for contributor ${openApplication.user?.id} on Community: ${community.id}.`, LogContext.COMMUNITY ); } @@ -1287,7 +1330,7 @@ export class CommunityService { const isExistingMember = await this.isMember(agent, community); if (isExistingMember) throw new CommunityMembershipException( - `User ${user.nameID} is already a member of the Community: ${community.id}.`, + `Contributor ${contributor.nameID} is already a member of the Community: ${community.id}.`, LogContext.COMMUNITY ); } diff --git a/src/domain/community/community/dto/community.dto.invite.existing.user.ts b/src/domain/community/community/dto/community.dto.invite.contributor.ts similarity index 76% rename from src/domain/community/community/dto/community.dto.invite.existing.user.ts rename to src/domain/community/community/dto/community.dto.invite.contributor.ts index 40d93a0893..edd9cd5b68 100644 --- a/src/domain/community/community/dto/community.dto.invite.existing.user.ts +++ b/src/domain/community/community/dto/community.dto.invite.contributor.ts @@ -4,16 +4,16 @@ import { IsOptional, MaxLength } from 'class-validator'; import { MID_TEXT_LENGTH, UUID_LENGTH } from '@common/constants'; @InputType() -export class CreateInvitationForUsersOnCommunityInput { +export class CreateInvitationForContributorsOnCommunityInput { @Field(() => UUID, { nullable: false }) @MaxLength(UUID_LENGTH) communityID!: string; @Field(() => [UUID], { nullable: false, - description: 'The identifiers for the users being invited.', + description: 'The identifiers for the contributors being invited.', }) - invitedUsers!: string[]; + invitedContributors!: string[]; @Field({ nullable: true }) @IsOptional() diff --git a/src/domain/community/contributor/contributor.interface.ts b/src/domain/community/contributor/contributor.interface.ts index 08c5cc7e60..aaaf07e49b 100644 --- a/src/domain/community/contributor/contributor.interface.ts +++ b/src/domain/community/contributor/contributor.interface.ts @@ -13,13 +13,13 @@ import { IProfile } from '@domain/common/profile/profile.interface'; import { IAgent } from '@domain/agent'; @InterfaceType('Contributor', { - resolveType(journey) { - if (journey instanceof User) return IUser; - if (journey instanceof Organization) return IOrganization; - if (journey instanceof VirtualContributor) return IVirtualContributor; + resolveType(contributor) { + if (contributor instanceof User) return IUser; + if (contributor instanceof Organization) return IOrganization; + if (contributor instanceof VirtualContributor) return IVirtualContributor; throw new RelationshipNotFoundException( - `Unable to determine contributor type for ${journey.id}`, + `Unable to determine contributor type for ${contributor.id}`, LogContext.COMMUNITY ); }, diff --git a/src/domain/community/contributor/contributor.service.ts b/src/domain/community/contributor/contributor.service.ts index 5a20f34711..0244813404 100644 --- a/src/domain/community/contributor/contributor.service.ts +++ b/src/domain/community/contributor/contributor.service.ts @@ -1,13 +1,24 @@ import { CredentialsSearchInput } from '@domain/agent/credential/dto/credentials.dto.search'; import { Injectable } from '@nestjs/common'; import { InjectEntityManager } from '@nestjs/typeorm'; -import { EntityManager, FindOneOptions } from 'typeorm'; +import { EntityManager, FindOneOptions, In } from 'typeorm'; import { IContributor } from './contributor.interface'; import { User } from '../user'; import { Organization } from '../organization'; -import { EntityNotFoundException } from '@common/exceptions'; +import { + EntityNotFoundException, + EntityNotInitializedException, + RelationshipNotFoundException, +} from '@common/exceptions'; import { LogContext } from '@common/enums/logging.context'; import { UUID_LENGTH } from '@common/constants/entity.field.length.constants'; +import { IAgent } from '@domain/agent/agent/agent.interface'; +import { VirtualContributor } from '../virtual-contributor'; +import { CommunityContributorType } from '@common/enums/community.contributor.type'; +import { IAccount } from '@domain/space/account/account.interface'; +import { Credential } from '@domain/agent/credential/credential.entity'; +import { AuthorizationCredential } from '@common/enums'; +import { Account } from '@domain/space/account/account.entity'; @Injectable() export class ContributorService { @@ -61,12 +72,31 @@ export class ContributorService { } ); - return userContributors.concat(organizationContributors); + const vcContributors = await this.entityManager.find(VirtualContributor, { + where: { + agent: { + credentials: { + type: credentialCriteria.type, + resourceID: credResourceID, + }, + }, + }, + relations: { + agent: { + credentials: true, + }, + }, + take: limit, + }); + + return userContributors + .concat(organizationContributors) + .concat(vcContributors); } async getContributor( contributorID: string, - options?: FindOneOptions + options?: FindOneOptions ): Promise { let contributor: IContributor | null; if (contributorID.length === UUID_LENGTH) { @@ -80,6 +110,12 @@ export class ContributorService { where: { ...options?.where, id: contributorID }, }); } + if (!contributor) { + contributor = await this.entityManager.findOne(VirtualContributor, { + ...options, + where: { ...options?.where, id: contributorID }, + }); + } } else { // look up based on nameID contributor = await this.entityManager.findOne(User, { @@ -92,13 +128,19 @@ export class ContributorService { where: { ...options?.where, nameID: contributorID }, }); } + if (!contributor) { + contributor = await this.entityManager.findOne(VirtualContributor, { + ...options, + where: { ...options?.where, nameID: contributorID }, + }); + } } return contributor; } async getContributorOrFail( contributorID: string, - options?: FindOneOptions + options?: FindOneOptions ): Promise { const contributor = await this.getContributor(contributorID, options); if (!contributor) @@ -108,4 +150,105 @@ export class ContributorService { ); return contributor; } + + async getContributorAndAgent( + contributorID: string + ): Promise<{ contributor: IContributor; agent: IAgent }> { + const contributor = await this.getContributorOrFail(contributorID, { + relations: { agent: true }, + }); + + if (!contributor.agent) { + throw new EntityNotInitializedException( + `Contributor Agent not initialized: ${contributorID}`, + LogContext.AUTH + ); + } + return { contributor: contributor, agent: contributor.agent }; + } + + public getContributorType(contributor: IContributor) { + if (contributor instanceof User) return CommunityContributorType.USER; + if (contributor instanceof Organization) + return CommunityContributorType.ORGANIZATION; + if (contributor instanceof VirtualContributor) + return CommunityContributorType.VIRTUAL; + throw new RelationshipNotFoundException( + `Unable to determine contributor type for ${contributor.id}`, + LogContext.COMMUNITY + ); + } + + // A utility method to load fields that are known by the Contributor type if not already + public async getContributorWithRelations( + contributor: IContributor, + options?: FindOneOptions + ): Promise { + const type = this.getContributorType(contributor); + let contributorWithRelations: IContributor | null = null; + switch (type) { + case CommunityContributorType.USER: + contributorWithRelations = await this.entityManager.findOne(User, { + ...options, + where: { ...options?.where, id: contributor.id }, + }); + break; + case CommunityContributorType.ORGANIZATION: + contributorWithRelations = await this.entityManager.findOne( + Organization, + { + ...options, + where: { ...options?.where, id: contributor.id }, + } + ); + break; + case CommunityContributorType.VIRTUAL: + contributorWithRelations = await this.entityManager.findOne( + VirtualContributor, + { + ...options, + where: { ...options?.where, id: contributor.id }, + } + ); + break; + } + if (!contributorWithRelations) { + throw new RelationshipNotFoundException( + `Unable to determine contributor type for ${contributor.id}`, + LogContext.COMMUNITY + ); + } + return contributorWithRelations; + } + + public async getAccountsHostedByContributor( + contributor: IContributor + ): Promise { + let agent = contributor.agent; + if (!agent) { + const contributorWithAgent = await this.getContributorWithRelations( + contributor, + { + relations: { agent: true }, + } + ); + agent = contributorWithAgent.agent; + } + const hostedAccountCredentials = await this.entityManager.find(Credential, { + where: { + type: AuthorizationCredential.ACCOUNT_HOST, + agent: { + id: agent.id, + }, + }, + }); + const accountIDs = hostedAccountCredentials.map(cred => cred.resourceID); + const accounts = await this.entityManager.find(Account, { + where: { + id: In(accountIDs), + }, + }); + + return accounts; + } } diff --git a/src/domain/community/invitation/dto/invitation.dto.create.ts b/src/domain/community/invitation/dto/invitation.dto.create.ts index 258dadf3cd..799cc3641c 100644 --- a/src/domain/community/invitation/dto/invitation.dto.create.ts +++ b/src/domain/community/invitation/dto/invitation.dto.create.ts @@ -7,11 +7,11 @@ import { IsOptional, MaxLength } from 'class-validator'; export class CreateInvitationInput { @Field(() => UUID, { nullable: false, - description: 'The identifier for the user being invited.', + description: 'The identifier for the contributor being invited.', }) @IsOptional() @MaxLength(UUID_LENGTH) - invitedUser!: string; + invitedContributor!: string; @Field({ nullable: true }) @IsOptional() diff --git a/src/domain/community/invitation/invitation.entity.ts b/src/domain/community/invitation/invitation.entity.ts index 06cc55a718..fd90841394 100644 --- a/src/domain/community/invitation/invitation.entity.ts +++ b/src/domain/community/invitation/invitation.entity.ts @@ -3,6 +3,7 @@ import { Community } from '@domain/community/community/community.entity'; import { Lifecycle } from '@domain/common/lifecycle/lifecycle.entity'; import { IInvitation } from './invitation.interface'; import { AuthorizableEntity } from '@domain/common/entity/authorizable-entity'; +import { CommunityContributorType } from '@common/enums/community.contributor.type'; @Entity() export class Invitation extends AuthorizableEntity implements IInvitation { @OneToOne(() => Lifecycle, { @@ -21,7 +22,7 @@ export class Invitation extends AuthorizableEntity implements IInvitation { community?: Community; @Column('char', { length: 36, nullable: true }) - invitedUser!: string; + invitedContributor!: string; @Column('char', { length: 36, nullable: true }) createdBy!: string; @@ -31,4 +32,7 @@ export class Invitation extends AuthorizableEntity implements IInvitation { @Column('boolean', { default: false }) invitedToParent!: boolean; + + @Column('char', { length: 36, nullable: true }) + contributorType!: CommunityContributorType; } diff --git a/src/domain/community/invitation/invitation.interface.ts b/src/domain/community/invitation/invitation.interface.ts index a959c35c9e..06c72eb25d 100644 --- a/src/domain/community/invitation/invitation.interface.ts +++ b/src/domain/community/invitation/invitation.interface.ts @@ -2,10 +2,11 @@ import { ILifecycle } from '@domain/common/lifecycle/lifecycle.interface'; import { ICommunity } from '@domain/community/community/community.interface'; import { Field, ObjectType } from '@nestjs/graphql'; import { IAuthorizable } from '@domain/common/entity/authorizable-entity'; +import { CommunityContributorType } from '@common/enums/community.contributor.type'; @ObjectType('Invitation') export class IInvitation extends IAuthorizable { - invitedUser!: string; + invitedContributor!: string; createdBy!: string; community?: ICommunity; @@ -25,7 +26,13 @@ export class IInvitation extends IAuthorizable { @Field(() => Boolean, { nullable: false, description: - 'Whether to also add the invited user to the parent community.', + 'Whether to also add the invited contributor to the parent community.', }) invitedToParent!: boolean; + + @Field(() => CommunityContributorType, { + nullable: false, + description: 'The type of contributor that is invited.', + }) + contributorType!: CommunityContributorType; } diff --git a/src/domain/community/invitation/invitation.module.ts b/src/domain/community/invitation/invitation.module.ts index afaa812dab..d5c3413cf5 100644 --- a/src/domain/community/invitation/invitation.module.ts +++ b/src/domain/community/invitation/invitation.module.ts @@ -9,6 +9,7 @@ import { InvitationResolverFields } from './invitation.resolver.fields'; import { AuthorizationModule } from '@core/authorization/authorization.module'; import { InvitationAuthorizationService } from './invitation.service.authorization'; import { InvitationResolverMutations } from './invitation.resolver.mutations'; +import { ContributorModule } from '../contributor/contributor.module'; @Module({ imports: [ @@ -16,6 +17,7 @@ import { InvitationResolverMutations } from './invitation.resolver.mutations'; AuthorizationModule, LifecycleModule, UserModule, + ContributorModule, TypeOrmModule.forFeature([Invitation]), ], providers: [ diff --git a/src/domain/community/invitation/invitation.resolver.fields.ts b/src/domain/community/invitation/invitation.resolver.fields.ts index e673aea82e..0d2ead4f33 100644 --- a/src/domain/community/invitation/invitation.resolver.fields.ts +++ b/src/domain/community/invitation/invitation.resolver.fields.ts @@ -7,6 +7,7 @@ import { IInvitation } from '@domain/community/invitation'; import { GraphqlGuard } from '@core/authorization'; import { IUser } from '@domain/community/user/user.interface'; import { AuthorizationAgentPrivilege, Profiling } from '@src/common/decorators'; +import { IContributor } from '../contributor/contributor.interface'; @Resolver(() => IInvitation) export class InvitationResolverFields { @@ -14,13 +15,15 @@ export class InvitationResolverFields { @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) @UseGuards(GraphqlGuard) - @ResolveField('user', () => IUser, { + @ResolveField('contributor', () => IContributor, { nullable: false, - description: 'The User who is invited.', + description: 'The Contributor who is invited.', }) @Profiling.api - async invitedUser(@Parent() invitation: IInvitation): Promise { - return await this.invitationService.getInvitedUser(invitation); + async invitedContributor( + @Parent() invitation: IInvitation + ): Promise { + return await this.invitationService.getInvitedContributor(invitation); } @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) diff --git a/src/domain/community/invitation/invitation.service.authorization.ts b/src/domain/community/invitation/invitation.service.authorization.ts index 47b0b784e9..28d49ce636 100644 --- a/src/domain/community/invitation/invitation.service.authorization.ts +++ b/src/domain/community/invitation/invitation.service.authorization.ts @@ -35,7 +35,7 @@ export class InvitationAuthorizationService { const newRules: IAuthorizationPolicyRuleCredential[] = []; // get the user - const user = await this.invitationService.getInvitedUser(invitation); + const user = await this.invitationService.getInvitedContributor(invitation); // also grant the user privileges to work with their own invitation const userInvitationRule = diff --git a/src/domain/community/invitation/invitation.service.ts b/src/domain/community/invitation/invitation.service.ts index 119545affb..21284cbb74 100644 --- a/src/domain/community/invitation/invitation.service.ts +++ b/src/domain/community/invitation/invitation.service.ts @@ -21,6 +21,8 @@ import { asyncFilter } from '@common/utils'; import { IUser } from '../user'; import { UserService } from '../user/user.service'; import { LogContext } from '@common/enums/logging.context'; +import { ContributorService } from '../contributor/contributor.service'; +import { IContributor } from '../contributor/contributor.interface'; @Injectable() export class InvitationService { @@ -29,14 +31,18 @@ export class InvitationService { @InjectRepository(Invitation) private invitationRepository: Repository, private userService: UserService, + private contributorService: ContributorService, private lifecycleService: LifecycleService, @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService ) {} async createInvitation( - invitationData: CreateInvitationInput + invitationData: CreateInvitationInput, + contributor: IContributor ): Promise { const invitation: IInvitation = Invitation.create(invitationData); + invitation.contributorType = + await this.contributorService.getContributorType(contributor); invitation.authorization = new AuthorizationPolicy(); @@ -99,14 +105,16 @@ export class InvitationService { return ''; } - async getInvitedUser(invitation: IInvitation): Promise { - const user = await this.userService.getUserOrFail(invitation.invitedUser); - if (!user) + async getInvitedContributor(invitation: IInvitation): Promise { + const contributor = await this.contributorService.getContributorOrFail( + invitation.invitedContributor + ); + if (!contributor) throw new RelationshipNotFoundException( - `Unable to load User for invitation ${invitation.id} `, + `Unable to load contributor for invitation ${invitation.id} `, LogContext.COMMUNITY ); - return user; + return contributor; } async getCreatedBy(invitation: IInvitation): Promise { @@ -120,11 +128,14 @@ export class InvitationService { } async findExistingInvitations( - userID: string, + contributorID: string, communityID: string ): Promise { const existingInvitations = await this.invitationRepository.find({ - where: { invitedUser: userID, community: { id: communityID } }, + where: { + invitedContributor: contributorID, + community: { id: communityID }, + }, relations: { community: true }, }); @@ -132,13 +143,13 @@ export class InvitationService { return []; } - async findInvitationsForUser( - userID: string, + async findInvitationsForContributor( + contributorID: string, states: string[] = [] ): Promise { const findOpts: FindManyOptions = { relations: { community: true }, - where: { invitedUser: userID }, + where: { invitedContributor: contributorID }, }; if (states.length) { diff --git a/src/domain/community/organization/organization.module.ts b/src/domain/community/organization/organization.module.ts index b4b17c1c6e..e5cda2a569 100644 --- a/src/domain/community/organization/organization.module.ts +++ b/src/domain/community/organization/organization.module.ts @@ -20,12 +20,14 @@ import { PlatformAuthorizationPolicyModule } from '@src/platform/authorization/p import { EntityResolverModule } from '@services/infrastructure/entity-resolver/entity.resolver.module'; import { OrganizationStorageAggregatorLoaderCreator } from '@core/dataloader/creators/loader.creators/community/organization.storage.aggregator.loader.creator'; import { StorageAggregatorModule } from '@domain/storage/storage-aggregator/storage.aggregator.module'; +import { ContributorModule } from '../contributor/contributor.module'; @Module({ imports: [ AgentModule, AuthorizationPolicyModule, AuthorizationModule, + ContributorModule, OrganizationVerificationModule, UserModule, UserGroupModule, diff --git a/src/domain/community/organization/organization.resolver.fields.ts b/src/domain/community/organization/organization.resolver.fields.ts index 87f6fae39d..d51c3365dd 100644 --- a/src/domain/community/organization/organization.resolver.fields.ts +++ b/src/domain/community/organization/organization.resolver.fields.ts @@ -33,6 +33,8 @@ import { ILoader } from '@core/dataloader/loader.interface'; import { OrganizationStorageAggregatorLoaderCreator } from '@core/dataloader/creators/loader.creators/community/organization.storage.aggregator.loader.creator'; import { OrganizationRole } from '@common/enums/organization.role'; import { IStorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.interface'; +import { IAccount } from '@domain/space/account/account.interface'; +import { ContributorService } from '../contributor/contributor.service'; @Resolver(() => IOrganization) export class OrganizationResolverFields { @@ -40,7 +42,8 @@ export class OrganizationResolverFields { private authorizationService: AuthorizationService, private organizationService: OrganizationService, private groupService: UserGroupService, - private preferenceSetService: PreferenceSetService + private preferenceSetService: PreferenceSetService, + private contributorService: ContributorService ) {} //@AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) @@ -109,6 +112,29 @@ export class OrganizationResolverFields { return userGroup; } + @UseGuards(GraphqlGuard) + @ResolveField('accounts', () => [IAccount], { + nullable: false, + description: 'The accounts hosted by this Organization.', + }) + @Profiling.api + async accounts( + @Parent() organization: IOrganization, + @CurrentUser() agentInfo: AgentInfo + ): Promise { + const accountsVisible = await this.authorizationService.isAccessGranted( + agentInfo, + organization.authorization, + AuthorizationPrivilege.UPDATE + ); + if (accountsVisible) { + return await this.contributorService.getAccountsHostedByContributor( + organization + ); + } + return []; + } + @UseGuards(GraphqlGuard) @ResolveField('associates', () => [IUser], { nullable: true, diff --git a/src/domain/community/user/user.module.ts b/src/domain/community/user/user.module.ts index 2c0609fa3a..438caf30bf 100644 --- a/src/domain/community/user/user.module.ts +++ b/src/domain/community/user/user.module.ts @@ -30,6 +30,7 @@ import { UserStorageAggregatorLoaderCreator } from '@core/dataloader/creators/lo import { DocumentModule } from '@domain/storage/document/document.module'; import { StorageBucketModule } from '@domain/storage/storage-bucket/storage.bucket.module'; import { AvatarModule } from '@domain/common/visual/avatar.module'; +import { ContributorModule } from '../contributor/contributor.module'; @Module({ imports: [ @@ -52,6 +53,7 @@ import { AvatarModule } from '@domain/common/visual/avatar.module'; StorageBucketModule, DocumentModule, AvatarModule, + ContributorModule, TypeOrmModule.forFeature([User]), ], providers: [ diff --git a/src/domain/community/user/user.resolver.fields.ts b/src/domain/community/user/user.resolver.fields.ts index d30e8c8dbb..2763723eed 100644 --- a/src/domain/community/user/user.resolver.fields.ts +++ b/src/domain/community/user/user.resolver.fields.ts @@ -30,6 +30,8 @@ import { import { ILoader } from '@core/dataloader/loader.interface'; import { Loader } from '@core/dataloader/decorators'; import { IStorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.interface'; +import { ContributorService } from '../contributor/contributor.service'; +import { IAccount } from '@domain/space/account/account.interface'; @Resolver(() => IUser) export class UserResolverFields { @@ -38,6 +40,7 @@ export class UserResolverFields { private userService: UserService, private preferenceSetService: PreferenceSetService, private messagingService: MessagingService, + private contributorService: ContributorService, private platformAuthorizationService: PlatformAuthorizationPolicyService, @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService ) {} @@ -180,6 +183,27 @@ export class UserResolverFields { return 'not accessible'; } + @UseGuards(GraphqlGuard) + @ResolveField('accounts', () => [IAccount], { + nullable: false, + description: 'The accounts hosted by this User.', + }) + @Profiling.api + async accounts( + @Parent() user: User, + @CurrentUser() agentInfo: AgentInfo + ): Promise { + const accountsVisible = await this.isAccessGranted( + user, + agentInfo, + AuthorizationPrivilege.READ_USER_PII + ); + if (accountsVisible) { + return await this.contributorService.getAccountsHostedByContributor(user); + } + return []; + } + @UseGuards(GraphqlGuard) @ResolveField('isContactable', () => Boolean, { nullable: false, diff --git a/src/domain/community/virtual-contributor/dto/virtual.contributor.dto.create.ts b/src/domain/community/virtual-contributor/dto/virtual.contributor.dto.create.ts index 56b5531b16..376df055e1 100644 --- a/src/domain/community/virtual-contributor/dto/virtual.contributor.dto.create.ts +++ b/src/domain/community/virtual-contributor/dto/virtual.contributor.dto.create.ts @@ -1,16 +1,12 @@ import { Field, InputType } from '@nestjs/graphql'; import { CreateContributorInput } from '@domain/community/contributor/dto/contributor.dto.create'; -import { UUID } from '@domain/common/scalars/scalar.uuid'; -import { BodyOfKnowledgeType } from '@common/enums/virtual.contributor.body.of.knowledge.type'; +import { CreateAiPersonaInput } from '@domain/community/ai-persona/dto/ai.persona.dto.create'; @InputType() export class CreateVirtualContributorInput extends CreateContributorInput { - @Field(() => UUID, { nullable: true }) - virtualPersonaID?: string; - - @Field(() => BodyOfKnowledgeType, { nullable: true }) - bodyOfKnowledgeType?: BodyOfKnowledgeType; - - @Field(() => UUID, { nullable: true }) - bodyOfKnowledgeID?: string; + @Field(() => CreateAiPersonaInput, { + nullable: false, + description: 'Data used to create the AI Persona', + }) + aiPersona!: CreateAiPersonaInput; } diff --git a/src/domain/community/virtual-contributor/dto/virtual.contributor.dto.question.input.ts b/src/domain/community/virtual-contributor/dto/virtual.contributor.dto.question.input.ts new file mode 100644 index 0000000000..b6d2c35eed --- /dev/null +++ b/src/domain/community/virtual-contributor/dto/virtual.contributor.dto.question.input.ts @@ -0,0 +1,17 @@ +import { UUID } from '@domain/common/scalars'; +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class VirtualContributorQuestionInput { + @Field(() => UUID, { + nullable: false, + description: 'Virtual Contributor to be asked.', + }) + virtualContributorID!: string; + + @Field(() => String, { + nullable: false, + description: 'The question that is being asked.', + }) + question!: string; +} diff --git a/src/domain/community/virtual-contributor/virtual.contributor.entity.ts b/src/domain/community/virtual-contributor/virtual.contributor.entity.ts index 9267b448b5..46b8f9d9cf 100644 --- a/src/domain/community/virtual-contributor/virtual.contributor.entity.ts +++ b/src/domain/community/virtual-contributor/virtual.contributor.entity.ts @@ -1,23 +1,15 @@ -import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { Column, Entity, JoinColumn, ManyToOne, OneToOne } from 'typeorm'; import { IVirtualContributor } from './virtual.contributor.interface'; import { ContributorBase } from '../contributor/contributor.base.entity'; import { Account } from '@domain/space/account/account.entity'; -import { BodyOfKnowledgeType } from '@common/enums/virtual.contributor.body.of.knowledge.type'; -import { VirtualPersona } from '@platform/virtual-persona/virtual.persona.entity'; +import { SearchVisibility } from '@common/enums/search.visibility'; +import { AiPersona } from '../ai-persona'; @Entity() export class VirtualContributor extends ContributorBase implements IVirtualContributor { - // Note: a many-one without corresponding one-many - @ManyToOne(() => VirtualPersona, { - eager: true, - cascade: true, - }) - @JoinColumn() - virtualPersona!: VirtualPersona; - @ManyToOne(() => Account, account => account.virtualContributors, { eager: true, onDelete: 'SET NULL', @@ -28,9 +20,20 @@ export class VirtualContributor @Column({ length: 255, nullable: false }) communicationID!: string; - @Column({ length: 64, nullable: true }) - bodyOfKnowledgeType!: BodyOfKnowledgeType; + @OneToOne(() => AiPersona, { + eager: true, + cascade: true, + }) + @JoinColumn() + aiPersona!: AiPersona; - @Column({ length: 255, nullable: true }) - bodyOfKnowledgeID!: string; + @Column() + listedInStore!: boolean; + + @Column('varchar', { + length: 36, + nullable: false, + default: SearchVisibility.ACCOUNT, + }) + searchVisibility!: SearchVisibility; } diff --git a/src/domain/community/virtual-contributor/virtual.contributor.interface.ts b/src/domain/community/virtual-contributor/virtual.contributor.interface.ts index 01f1515598..d48acd0f68 100644 --- a/src/domain/community/virtual-contributor/virtual.contributor.interface.ts +++ b/src/domain/community/virtual-contributor/virtual.contributor.interface.ts @@ -1,10 +1,9 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { IContributorBase } from '../contributor/contributor.base.interface'; import { IAccount } from '@domain/space/account/account.interface'; -import { BodyOfKnowledgeType } from '@common/enums/virtual.contributor.body.of.knowledge.type'; import { IContributor } from '../contributor/contributor.interface'; -import { IVirtualPersona } from '@platform/virtual-persona/virtual.persona.interface'; -import { UUID } from '@domain/common/scalars'; +import { SearchVisibility } from '@common/enums/search.visibility'; +import { IAiPersona } from '../ai-persona'; @ObjectType('VirtualContributor', { implements: () => [IContributor], @@ -13,27 +12,24 @@ export class IVirtualContributor extends IContributorBase implements IContributor { - @Field(() => IVirtualPersona, { - description: 'The virtual persona being used by this virtual contributor', - }) - virtualPersona!: IVirtualPersona; + account!: IAccount; communicationID!: string; - @Field(() => IAccount, { - nullable: true, - description: 'The account under which the virtual contributor was created', + + @Field(() => IAiPersona, { + description: 'The AI persona being used by this virtual contributor', }) - account!: IAccount; + aiPersona!: IAiPersona; - @Field(() => BodyOfKnowledgeType, { - nullable: true, - description: 'The body of knowledge type used for the Virtual Contributor', + @Field(() => SearchVisibility, { + description: 'Visibility of the VC in searches.', + nullable: false, }) - bodyOfKnowledgeType?: BodyOfKnowledgeType; + searchVisibility!: SearchVisibility; - @Field(() => UUID, { - nullable: true, - description: 'The body of knowledge ID used for the Virtual Contributor', + @Field(() => Boolean, { + nullable: false, + description: 'Flag to control if this VC is listed in the platform store.', }) - bodyOfKnowledgeID?: string; + listedInStore!: boolean; } diff --git a/src/domain/community/virtual-contributor/virtual.contributor.module.ts b/src/domain/community/virtual-contributor/virtual.contributor.module.ts index fc26ba5ec7..12c779db65 100644 --- a/src/domain/community/virtual-contributor/virtual.contributor.module.ts +++ b/src/domain/community/virtual-contributor/virtual.contributor.module.ts @@ -1,10 +1,10 @@ import { Module } from '@nestjs/common'; import { VirtualContributorService } from './virtual.contributor.service'; import { VirtualContributorResolverMutations } from './virtual.contributor.resolver.mutations'; +import { VirtualContributorResolverQueries } from './virtual.contributor.resolver.queries'; import { TypeOrmModule } from '@nestjs/typeorm'; import { VirtualContributorResolverFields } from './virtual.contributor.resolver.fields'; import { ProfileModule } from '@domain/common/profile/profile.module'; -import { VirtualContributorResolverQueries } from './virtual.contributor.resolver.queries'; import { VirtualContributorAuthorizationService } from './virtual.contributor.service.authorization'; import { AuthorizationModule } from '@core/authorization/authorization.module'; import { AgentModule } from '@domain/agent/agent/agent.module'; @@ -13,8 +13,10 @@ import { VirtualStorageAggregatorLoaderCreator } from '@core/dataloader/creators import { StorageAggregatorModule } from '@domain/storage/storage-aggregator/storage.aggregator.module'; import { VirtualContributor } from './virtual.contributor.entity'; import { CommunicationAdapterModule } from '@services/adapters/communication-adapter/communication-adapter.module'; -import { VirtualPersonaModule } from '@platform/virtual-persona/virtual.persona.module'; import { NamingModule } from '@services/infrastructure/naming/naming.module'; +import { AiPersonaModule } from '../ai-persona/ai.persona.module'; +import { AiServerAdapterModule } from '@services/adapters/ai-server-adapter/ai.server.adapter.module'; +import { PlatformAuthorizationPolicyModule } from '@platform/authorization/platform.authorization.policy.module'; @Module({ imports: [ @@ -24,15 +26,17 @@ import { NamingModule } from '@services/infrastructure/naming/naming.module'; ProfileModule, NamingModule, StorageAggregatorModule, - VirtualPersonaModule, + AiPersonaModule, + AiServerAdapterModule, CommunicationAdapterModule, TypeOrmModule.forFeature([VirtualContributor]), + PlatformAuthorizationPolicyModule, ], providers: [ VirtualContributorService, VirtualContributorAuthorizationService, - VirtualContributorResolverQueries, VirtualContributorResolverMutations, + VirtualContributorResolverQueries, VirtualContributorResolverFields, VirtualStorageAggregatorLoaderCreator, ], diff --git a/src/domain/community/virtual-contributor/virtual.contributor.resolver.fields.ts b/src/domain/community/virtual-contributor/virtual.contributor.resolver.fields.ts index bc0451a9f4..afdaeb4f07 100644 --- a/src/domain/community/virtual-contributor/virtual.contributor.resolver.fields.ts +++ b/src/domain/community/virtual-contributor/virtual.contributor.resolver.fields.ts @@ -6,11 +6,7 @@ import { VirtualContributorService } from './virtual.contributor.service'; import { AuthorizationPrivilege } from '@common/enums'; import { GraphqlGuard } from '@core/authorization'; import { IProfile } from '@domain/common/profile'; -import { - AuthorizationAgentPrivilege, - CurrentUser, - Profiling, -} from '@common/decorators'; +import { AuthorizationAgentPrivilege, CurrentUser } from '@common/decorators'; import { IAgent } from '@domain/agent/agent'; import { AgentInfo } from '@core/authentication.agent.info/agent.info'; import { AuthorizationService } from '@core/authorization/authorization.service'; @@ -34,12 +30,31 @@ export class VirtualContributorResolverFields { private virtualService: VirtualContributorService ) {} + @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) + @ResolveField('account', () => IAccount, { + nullable: true, + description: 'The Account of the Virtual Contributor.', + }) + @UseGuards(GraphqlGuard) + async account( + @Parent() virtualContributor: VirtualContributor, + @Loader(AccountLoaderCreator, { parentClassRef: VirtualContributor }) + loader: ILoader + ): Promise { + let account: IAccount | never; + try { + account = await loader.load(virtualContributor.id); + } catch (error) { + return null; + } + return account; + } + @UseGuards(GraphqlGuard) @ResolveField('authorization', () => IAuthorizationPolicy, { nullable: true, description: 'The Authorization for this Virtual.', }) - @Profiling.api async authorization( @Parent() parent: VirtualContributor, @CurrentUser() agentInfo: AgentInfo @@ -86,7 +101,6 @@ export class VirtualContributorResolverFields { nullable: false, description: 'The Agent representing this User.', }) - @Profiling.api async agent( @Parent() virtualContributor: VirtualContributor, @Loader(AgentLoaderCreator, { parentClassRef: VirtualContributor }) @@ -109,25 +123,4 @@ export class VirtualContributorResolverFields { ): Promise { return loader.load(virtualContributor.id); } - - @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) - @ResolveField('account', () => IAccount, { - nullable: true, - description: 'The Account of the Virtual Contributor.', - }) - @Profiling.api - @UseGuards(GraphqlGuard) - async account( - @Parent() virtualContributor: VirtualContributor, - @Loader(AccountLoaderCreator, { parentClassRef: VirtualContributor }) - loader: ILoader - ): Promise { - let account: IAccount | never; - try { - account = await loader.load(virtualContributor.id); - } catch (error) { - return null; - } - return account; - } } diff --git a/src/domain/community/virtual-contributor/virtual.contributor.resolver.mutations.ts b/src/domain/community/virtual-contributor/virtual.contributor.resolver.mutations.ts index f38966c3bd..4923457b0a 100644 --- a/src/domain/community/virtual-contributor/virtual.contributor.resolver.mutations.ts +++ b/src/domain/community/virtual-contributor/virtual.contributor.resolver.mutations.ts @@ -33,7 +33,7 @@ export class VirtualContributorResolverMutations { await this.virtualContributorService.getVirtualContributorOrFail( virtualContributorData.ID ); - await this.authorizationService.grantAccessOrFail( + this.authorizationService.grantAccessOrFail( agentInfo, virtual.authorization, AuthorizationPrivilege.UPDATE, @@ -57,7 +57,7 @@ export class VirtualContributorResolverMutations { await this.virtualContributorService.getVirtualContributorOrFail( deleteData.ID ); - await this.authorizationService.grantAccessOrFail( + this.authorizationService.grantAccessOrFail( agentInfo, virtual.authorization, AuthorizationPrivilege.DELETE, diff --git a/src/domain/community/virtual-contributor/virtual.contributor.resolver.queries.ts b/src/domain/community/virtual-contributor/virtual.contributor.resolver.queries.ts index 74aecd95ae..bd939af04e 100644 --- a/src/domain/community/virtual-contributor/virtual.contributor.resolver.queries.ts +++ b/src/domain/community/virtual-contributor/virtual.contributor.resolver.queries.ts @@ -1,22 +1,46 @@ import { UUID_NAMEID } from '@domain/common/scalars'; import { Args, Query, Resolver } from '@nestjs/graphql'; -import { Profiling } from '@src/common/decorators'; +import { CurrentUser, Profiling } from '@src/common/decorators'; import { IVirtualContributor } from './virtual.contributor.interface'; import { VirtualContributorService } from './virtual.contributor.service'; import { ContributorQueryArgs } from '../contributor/dto/contributor.query.args'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { AuthorizationService } from '@core/authorization/authorization.service'; +import { AuthorizationPrivilege } from '@common/enums'; +import { PlatformAuthorizationPolicyService } from '@platform/authorization/platform.authorization.policy.service'; +import { UseGuards } from '@nestjs/common'; +import { GraphqlGuard } from '@core/authorization'; @Resolver() export class VirtualContributorResolverQueries { - constructor(private virtualContributorService: VirtualContributorService) {} + constructor( + private virtualContributorService: VirtualContributorService, + private authorizationService: AuthorizationService, + private platformAuthorizationPolicyService: PlatformAuthorizationPolicyService + ) {} + @UseGuards(GraphqlGuard) @Query(() => [IVirtualContributor], { nullable: false, description: 'The VirtualContributors on this platform', }) @Profiling.api async virtualContributors( - @Args({ nullable: true }) args: ContributorQueryArgs + @Args({ nullable: true }) args: ContributorQueryArgs, + @CurrentUser() agentInfo: AgentInfo ): Promise { + const platformPolicy = + await this.platformAuthorizationPolicyService.getPlatformAuthorizationPolicy(); + + const hasAccess = this.authorizationService.isAccessGranted( + agentInfo, + platformPolicy, + AuthorizationPrivilege.PLATFORM_ADMIN + ); + + if (!hasAccess) { + return []; + } return await this.virtualContributorService.getVirtualContributors(args); } diff --git a/src/domain/community/virtual-contributor/virtual.contributor.service.ts b/src/domain/community/virtual-contributor/virtual.contributor.service.ts index 49489d98b3..4797858d86 100644 --- a/src/domain/community/virtual-contributor/virtual.contributor.service.ts +++ b/src/domain/community/virtual-contributor/virtual.contributor.service.ts @@ -26,16 +26,15 @@ import { CreateVirtualContributorInput } from './dto/virtual.contributor.dto.cre import { UpdateVirtualContributorInput } from './dto/virtual.contributor.dto.update'; import { limitAndShuffle } from '@common/utils/limitAndShuffle'; import { CommunicationAdapter } from '@services/adapters/communication-adapter/communication.adapter'; -import { EventBus } from '@nestjs/cqrs'; -import { - IngestSpace, - SpaceIngestionPurpose, -} from '@services/infrastructure/event-bus/commands'; -import { VirtualPersonaService } from '@platform/virtual-persona/virtual.persona.service'; -import { IVirtualPersona } from '@platform/virtual-persona'; -import { VirtualContributorEngine } from '@common/enums/virtual.contributor.engine'; -import { BodyOfKnowledgeType } from '@common/enums/virtual.contributor.body.of.knowledge.type'; import { NamingService } from '@services/infrastructure/naming/naming.service'; +import { AiPersonaService } from '../ai-persona/ai.persona.service'; +import { CreateAiPersonaInput } from '../ai-persona/dto'; +import { VirtualContributorQuestionInput } from './dto/virtual.contributor.dto.question.input'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { IAiPersonaQuestionResult } from '../ai-persona/dto/ai.persona.question.dto.result'; +import { AiServerAdapter } from '@services/adapters/ai-server-adapter/ai.server.adapter'; +import { AiServerAdapterAskQuestionInput } from '@services/adapters/ai-server-adapter/dto/ai.server.adapter.dto.ask.question'; +import { SearchVisibility } from '@common/enums/search.visibility'; @Injectable() export class VirtualContributorService { @@ -44,10 +43,10 @@ export class VirtualContributorService { private agentService: AgentService, private profileService: ProfileService, private storageAggregatorService: StorageAggregatorService, - private virtualPersonaService: VirtualPersonaService, private communicationAdapter: CommunicationAdapter, private namingService: NamingService, - private eventBus: EventBus, + private aiPersonaService: AiPersonaService, + private aiServerAdapter: AiServerAdapter, @InjectRepository(VirtualContributor) private virtualContributorRepository: Repository, @Inject(WINSTON_MODULE_NEST_PROVIDER) @@ -71,10 +70,13 @@ export class VirtualContributorService { virtualContributorData.profileData?.displayName ); - const virtualContributor: IVirtualContributor = VirtualContributor.create( + let virtualContributor: IVirtualContributor = VirtualContributor.create( virtualContributorData ); + virtualContributor.listedInStore = true; + virtualContributor.searchVisibility = SearchVisibility.ACCOUNT; + virtualContributor.authorization = new AuthorizationPolicy(); const communicationID = await this.communicationAdapter.tryRegisterNewUser( `virtual-contributor-${virtualContributor.nameID}@alkem.io` @@ -83,24 +85,14 @@ export class VirtualContributorService { virtualContributor.communicationID = communicationID; } - let virtualPersona: IVirtualPersona; - if (virtualContributorData.virtualPersonaID) { - virtualPersona = await this.virtualPersonaService.getVirtualPersonaOrFail( - virtualContributorData.virtualPersonaID - ); - } else { - //toDo fix this: https://app.zenhub.com/workspaces/alkemio-development-5ecb98b262ebd9f4aec4194c/issues/gh/alkem-io/server/4010 - virtualPersona = - await this.virtualPersonaService.getVirtualPersonaByEngineOrFail( - VirtualContributorEngine.EXPERT - ); - } - - if (virtualContributorData.bodyOfKnowledgeType === undefined) { - virtualContributor.bodyOfKnowledgeType = BodyOfKnowledgeType.OTHER; - } - - virtualContributor.virtualPersona = virtualPersona; + this.logger.log(virtualContributorData); + const aiPersonaInput: CreateAiPersonaInput = { + ...virtualContributorData.aiPersona, + description: `AI Persona for virtual contributor ${virtualContributor.nameID}`, + }; + virtualContributor.aiPersona = await this.aiPersonaService.createAiPersona( + aiPersonaInput + ); virtualContributor.storageAggregator = await this.storageAggregatorService.createStorageAggregator(); @@ -135,23 +127,13 @@ export class VirtualContributorService { parentDisplayID: `virtual-${virtualContributor.nameID}`, }); - const savedVC = await this.virtualContributorRepository.save( - virtualContributor - ); + virtualContributor = await this.save(virtualContributor); this.logger.verbose?.( `Created new virtual with id ${virtualContributor.id}`, LogContext.COMMUNITY ); - if (virtualContributorData.bodyOfKnowledgeID) - this.eventBus.publish( - new IngestSpace( - virtualContributorData.bodyOfKnowledgeID, - SpaceIngestionPurpose.Knowledge - ) - ); - - return savedVC; + return virtualContributor; } async checkNameIdOrFail(nameID: string) { @@ -225,7 +207,7 @@ export class VirtualContributorService { } } - return await this.virtualContributorRepository.save(virtual); + return await this.save(virtual); } async deleteVirtualContributor( @@ -266,6 +248,13 @@ export class VirtualContributorService { virtualContributor as VirtualContributor ); result.id = virtualContributorID; + + if (virtualContributor.aiPersona) { + await this.aiPersonaService.deleteAiPersona({ + ID: virtualContributor.aiPersona.id, + }); + } + return result; } @@ -324,6 +313,44 @@ export class VirtualContributorService { }; } + public async askQuestion( + vcQuestionInput: VirtualContributorQuestionInput, + agentInfo: AgentInfo, + contextSpaceNameID: string + ): Promise { + const virtualContributor = await this.getVirtualContributorOrFail( + vcQuestionInput.virtualContributorID, + { + relations: { + authorization: true, + aiPersona: true, + agent: true, + }, + } + ); + if (!virtualContributor.agent) { + throw new EntityNotInitializedException( + `Virtual Contributor Agent not initialized: ${vcQuestionInput.virtualContributorID}`, + LogContext.AUTH + ); + } + this.logger.verbose?.( + `still need to use the context ${contextSpaceNameID}, ${agentInfo.agentID}`, + LogContext.VIRTUAL_CONTRIBUTOR_ENGINE + ); + const aiServerAdapterQuestionInput: AiServerAdapterAskQuestionInput = { + personaServiceID: virtualContributor.aiPersona.aiPersonaServiceID, + question: vcQuestionInput.question, + }; + + return await this.aiServerAdapter.askQuestion( + aiServerAdapterQuestionInput, + agentInfo, + contextSpaceNameID + ); + } + + // TODO: move to store async getVirtualContributors( args: ContributorQueryArgs ): Promise { diff --git a/src/domain/license/feature-flag/dto/feature.flag.dto.create.ts b/src/domain/license/feature-flag/dto/feature.flag.dto.create.ts deleted file mode 100644 index 6ffa8fec0b..0000000000 --- a/src/domain/license/feature-flag/dto/feature.flag.dto.create.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { SMALL_TEXT_LENGTH } from '@common/constants'; -import { Field, InputType } from '@nestjs/graphql'; -import { IsBoolean, MaxLength } from 'class-validator'; - -@InputType() -export abstract class CreateFeatureFlagInput { - @Field(() => String, { - description: 'The name of the feature flag', - nullable: false, - }) - @MaxLength(SMALL_TEXT_LENGTH) - name!: string; - - @Field(() => Boolean, { - description: 'Is this feature flag enabled?', - nullable: false, - }) - @IsBoolean() - enabled!: boolean; -} diff --git a/src/domain/license/feature-flag/dto/feature.flag.dto.update.ts b/src/domain/license/feature-flag/dto/feature.flag.dto.update.ts deleted file mode 100644 index 6643615b5a..0000000000 --- a/src/domain/license/feature-flag/dto/feature.flag.dto.update.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { SMALL_TEXT_LENGTH } from '@common/constants/entity.field.length.constants'; -import { Field, InputType } from '@nestjs/graphql'; -import { IsBoolean, MaxLength } from 'class-validator'; - -@InputType() -export abstract class UpdateFeatureFlagInput { - @Field(() => String, { - description: 'The name of the feature flag', - nullable: false, - }) - @MaxLength(SMALL_TEXT_LENGTH) - name!: string; - - @Field(() => Boolean, { - description: 'Is this feature flag enabled?', - nullable: false, - }) - @IsBoolean() - enabled!: boolean; -} diff --git a/src/domain/license/feature-flag/feature.flag.entity.ts b/src/domain/license/feature-flag/feature.flag.entity.ts deleted file mode 100644 index cc385ae4c4..0000000000 --- a/src/domain/license/feature-flag/feature.flag.entity.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { BaseAlkemioEntity } from '@domain/common/entity/base-entity'; -import { Entity, Column, ManyToOne } from 'typeorm'; -import { License } from '../license/license.entity'; -import { ILicenseFeatureFlag } from './feature.flag.interface'; - -@Entity() -export class FeatureFlag - extends BaseAlkemioEntity - implements ILicenseFeatureFlag -{ - @Column('text', { nullable: false }) - name!: string; - - @Column('boolean', { nullable: false }) - enabled!: boolean; - - @ManyToOne(() => License, license => license.featureFlags) - license!: License; -} diff --git a/src/domain/license/feature-flag/feature.flag.interface.ts b/src/domain/license/feature-flag/feature.flag.interface.ts deleted file mode 100644 index 95fc8de551..0000000000 --- a/src/domain/license/feature-flag/feature.flag.interface.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { LicenseFeatureFlagName } from '@common/enums/license.feature.flag.name'; -import { Field, ObjectType } from '@nestjs/graphql'; - -@ObjectType('LicenseFeatureFlag') -export abstract class ILicenseFeatureFlag { - @Field(() => LicenseFeatureFlagName, { - description: 'The name of the feature flag', - nullable: false, - }) - name!: string; - - @Field(() => Boolean, { - description: 'Is this feature flag enabled?', - nullable: false, - }) - enabled!: boolean; -} diff --git a/src/domain/license/feature-flag/feature.flag.service.ts b/src/domain/license/feature-flag/feature.flag.service.ts deleted file mode 100644 index 7025ea73ee..0000000000 --- a/src/domain/license/feature-flag/feature.flag.service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { LogContext } from '@common/enums'; -import { EntityNotFoundException } from '@common/exceptions'; -import { Injectable, Inject, LoggerService } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; -import { Repository, FindOneOptions } from 'typeorm'; -import { FeatureFlag } from './feature.flag.entity'; -import { ILicenseFeatureFlag } from './feature.flag.interface'; -import { CreateFeatureFlagInput } from './dto/feature.flag.dto.create'; -import { UpdateFeatureFlagInput } from './dto/feature.flag.dto.update'; - -@Injectable() -export class FeatureFlagService { - constructor( - @InjectRepository(FeatureFlag) - private featureFlagRepository: Repository, - @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService - ) {} - - public async createFeatureFlag( - createFeatureFlagInput: CreateFeatureFlagInput - ): Promise { - const featureFlag: ILicenseFeatureFlag = FeatureFlag.create(); - featureFlag.name = createFeatureFlagInput.name; - featureFlag.enabled = createFeatureFlagInput.enabled; - - return await this.featureFlagRepository.save(featureFlag); - } - - async delete(featureFlagID: string): Promise { - const featureFlag = await this.getFeatureFlagOrFail(featureFlagID); - - return await this.featureFlagRepository.remove(featureFlag as FeatureFlag); - } - - async getFeatureFlagOrFail( - featureFlagID: string, - options?: FindOneOptions - ): Promise { - const featureFlag = await this.featureFlagRepository.findOne({ - where: { id: featureFlagID }, - ...options, - }); - if (!featureFlag) - throw new EntityNotFoundException( - `Feature Flag not found: ${featureFlagID}`, - LogContext.LICENSE - ); - return featureFlag; - } - - public async updateFeatureFlag( - featureFlag: ILicenseFeatureFlag, - licenseUpdateData: UpdateFeatureFlagInput - ): Promise { - featureFlag.enabled = licenseUpdateData.enabled; - return await this.save(featureFlag); - } - - async save(featureFlag: ILicenseFeatureFlag): Promise { - return await this.featureFlagRepository.save(featureFlag); - } -} diff --git a/src/domain/license/license/dto/license.dto.update.ts b/src/domain/license/license/dto/license.dto.update.ts index 04e717ed24..8c0c6c2ab7 100644 --- a/src/domain/license/license/dto/license.dto.update.ts +++ b/src/domain/license/license/dto/license.dto.update.ts @@ -1,17 +1,9 @@ import { Field, InputType } from '@nestjs/graphql'; import { SpaceVisibility } from '@common/enums/space.visibility'; import { IsOptional } from 'class-validator'; -import { UpdateFeatureFlagInput } from '@domain/license/feature-flag/dto/feature.flag.dto.update'; @InputType() export class UpdateLicenseInput { - @Field(() => [UpdateFeatureFlagInput], { - nullable: true, - description: 'Update the feature flags for the License.', - }) - @IsOptional() - featureFlags?: UpdateFeatureFlagInput[]; - @Field(() => SpaceVisibility, { nullable: true, description: 'Visibility of the Space.', diff --git a/src/domain/license/license/license.entity.ts b/src/domain/license/license/license.entity.ts index 7a56ae19c6..0b5b18c757 100644 --- a/src/domain/license/license/license.entity.ts +++ b/src/domain/license/license/license.entity.ts @@ -1,8 +1,7 @@ import { AuthorizableEntity } from '@domain/common/entity/authorizable-entity'; -import { Column, Entity, OneToMany } from 'typeorm'; +import { Column, Entity } from 'typeorm'; import { ILicense } from './license.interface'; import { SpaceVisibility } from '@common/enums/space.visibility'; -import { FeatureFlag } from '../feature-flag/feature.flag.entity'; @Entity() export class License extends AuthorizableEntity implements ILicense { @@ -12,12 +11,4 @@ export class License extends AuthorizableEntity implements ILicense { default: SpaceVisibility.ACTIVE, }) visibility!: SpaceVisibility; - - @OneToMany(() => FeatureFlag, featureFlag => featureFlag.license, { - eager: false, - cascade: true, - nullable: true, - onDelete: 'SET NULL', - }) - featureFlags?: FeatureFlag[]; } diff --git a/src/domain/license/license/license.interface.ts b/src/domain/license/license/license.interface.ts index 91817cd2df..264cbb2a4b 100644 --- a/src/domain/license/license/license.interface.ts +++ b/src/domain/license/license/license.interface.ts @@ -1,12 +1,9 @@ import { SpaceVisibility } from '@common/enums/space.visibility'; import { IAuthorizable } from '@domain/common/entity/authorizable-entity'; import { Field, ObjectType } from '@nestjs/graphql'; -import { ILicenseFeatureFlag } from '../feature-flag/feature.flag.interface'; @ObjectType('License') export abstract class ILicense extends IAuthorizable { - featureFlags?: ILicenseFeatureFlag[]; - @Field(() => SpaceVisibility, { description: 'Visibility of the Space.', nullable: false, diff --git a/src/domain/license/license/license.module.ts b/src/domain/license/license/license.module.ts index 8bf108165c..7868828137 100644 --- a/src/domain/license/license/license.module.ts +++ b/src/domain/license/license/license.module.ts @@ -3,27 +3,16 @@ import { AuthorizationPolicyModule } from '@domain/common/authorization-policy/a import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { License } from './license.entity'; -import { LicenseResolverFields } from './license.resolver.fields'; import { LicenseService } from './license.service'; import { LicenseAuthorizationService } from './license.service.authorization'; -import { FeatureFlagService } from '../feature-flag/feature.flag.service'; -import { FeatureFlag } from '../feature-flag/feature.flag.entity'; -import { LicenseEngineModule } from '@core/license-engine/license.engine.module'; @Module({ imports: [ AuthorizationModule, - LicenseEngineModule, AuthorizationPolicyModule, TypeOrmModule.forFeature([License]), - TypeOrmModule.forFeature([FeatureFlag]), - ], - providers: [ - LicenseResolverFields, - LicenseService, - LicenseAuthorizationService, - FeatureFlagService, ], + providers: [LicenseService, LicenseAuthorizationService], exports: [LicenseService, LicenseAuthorizationService], }) export class LicenseModule {} diff --git a/src/domain/license/license/license.resolver.fields.ts b/src/domain/license/license/license.resolver.fields.ts deleted file mode 100644 index a74b9a3029..0000000000 --- a/src/domain/license/license/license.resolver.fields.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; -import { ILicense } from './license.interface'; -import { LicenseService } from './license.service'; -import { ILicenseFeatureFlag } from '../feature-flag/feature.flag.interface'; -import { LicensePrivilege } from '@common/enums/license.privilege'; - -@Resolver(() => ILicense) -export class LicenseResolverFields { - constructor(private licenseService: LicenseService) {} - - @ResolveField('featureFlags', () => [ILicenseFeatureFlag], { - nullable: false, - description: 'The FeatureFlags for the license', - }) - async featureFlags( - @Parent() license: ILicense - ): Promise { - return await this.licenseService.getFeatureFlags(license.id); - } - - @ResolveField('privileges', () => [LicensePrivilege], { - nullable: true, - description: 'The privileges granted based on this License.', - }) - async privileges(@Parent() license: ILicense): Promise { - return this.licenseService.getLicensePrivileges(license); - } -} diff --git a/src/domain/license/license/license.service.ts b/src/domain/license/license/license.service.ts index b9124fc2ee..ed67f46e4c 100644 --- a/src/domain/license/license/license.service.ts +++ b/src/domain/license/license/license.service.ts @@ -8,24 +8,14 @@ import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { FindOneOptions, Repository } from 'typeorm'; import { License } from './license.entity'; import { ILicense } from './license.interface'; -import { ILicenseFeatureFlag } from '../feature-flag/feature.flag.interface'; -import { LicenseFeatureFlagName } from '@common/enums/license.feature.flag.name'; import { UpdateLicenseInput } from './dto/license.dto.update'; import { SpaceVisibility } from '@common/enums/space.visibility'; -import { CreateFeatureFlagInput } from '../feature-flag/dto/feature.flag.dto.create'; -import { FeatureFlagService } from '../feature-flag/feature.flag.service'; -import { FeatureFlag } from '../feature-flag/feature.flag.entity'; -import { matchEnumString } from '@common/utils/match.enum'; import { CreateLicenseInput } from './dto/license.dto.create'; -import { LicensePrivilege } from '@common/enums/license.privilege'; -import { LicenseEngineService } from '@core/license-engine/license.engine.service'; @Injectable() export class LicenseService { constructor( private authorizationPolicyService: AuthorizationPolicyService, - private featureFlagService: FeatureFlagService, - private licenseEngineService: LicenseEngineService, @InjectRepository(License) private licenseRepository: Repository, @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService @@ -41,52 +31,15 @@ export class LicenseService { // default to active space license.visibility = licenseInput.visibility || SpaceVisibility.ACTIVE; - // Set the feature flags - const whiteboardRtFeatureFlag: CreateFeatureFlagInput = { - name: LicenseFeatureFlagName.WHITEBOARD_MULTI_USER, - enabled: false, - }; - const calloutToCalloutTemplateFeatureFlag: CreateFeatureFlagInput = { - name: LicenseFeatureFlagName.CALLOUT_TO_CALLOUT_TEMPLATE, - enabled: false, - }; - const vcFeatureFlag: CreateFeatureFlagInput = { - name: LicenseFeatureFlagName.VIRTUAL_CONTRIBUTORS, - enabled: false, - }; - - const featureFlagInputs: ILicenseFeatureFlag[] = [ - whiteboardRtFeatureFlag, - calloutToCalloutTemplateFeatureFlag, - vcFeatureFlag, - ]; - license.featureFlags = []; - for (const featureFlagInput of featureFlagInputs) { - const featureFlag = await this.featureFlagService.createFeatureFlag( - featureFlagInput - ); - license.featureFlags.push(featureFlag); - } - return await this.licenseRepository.save(license); } async delete(licenseID: string): Promise { - const license = await this.getLicenseOrFail(licenseID, { - relations: { - featureFlags: true, - }, - }); + const license = await this.getLicenseOrFail(licenseID); if (license.authorization) await this.authorizationPolicyService.delete(license.authorization); - if (license.featureFlags) { - for (const featureFlag of license.featureFlags) { - await this.featureFlagService.delete((featureFlag as FeatureFlag).id); - } - } - return await this.licenseRepository.remove(license as License); } @@ -113,57 +66,11 @@ export class LicenseService { if (licenseUpdateData.visibility) { license.visibility = licenseUpdateData.visibility; } - if (licenseUpdateData.featureFlags) { - const featureFlags = await this.getFeatureFlags(license.id); - const updatedFeatureFlags: ILicenseFeatureFlag[] = []; - for (const featureFlag of featureFlags) { - const { name } = featureFlag; - const matchResult = matchEnumString(LicenseFeatureFlagName, name); - const featureFlagInput = licenseUpdateData.featureFlags.find( - f => f.name === matchResult?.key || f.name === name - ); - if (featureFlagInput) { - const { enabled } = featureFlagInput; - const updatedFF = await this.featureFlagService.updateFeatureFlag( - featureFlag, - { - name, - enabled, - } - ); - updatedFeatureFlags.push(updatedFF); - } else { - updatedFeatureFlags.push(featureFlag); - } - } - license.featureFlags = updatedFeatureFlags; - } return await this.save(license); } async save(license: ILicense): Promise { return await this.licenseRepository.save(license); } - - async getFeatureFlags(licenseID: string): Promise { - const license = await this.getLicenseOrFail(licenseID, { - relations: { featureFlags: true }, - }); - - if (!license || !license.featureFlags) - throw new EntityNotFoundException( - `Feature flags for license with id: ${license.id} not found!`, - LogContext.LICENSE - ); - - return license.featureFlags; - } - - async getLicensePrivileges(license: ILicense): Promise { - const privileges = await this.licenseEngineService.getGrantedPrivileges( - license - ); - return privileges; - } } diff --git a/src/domain/space/account/account.module.ts b/src/domain/space/account/account.module.ts index 00efb30a4d..5d1b32ce7e 100644 --- a/src/domain/space/account/account.module.ts +++ b/src/domain/space/account/account.module.ts @@ -21,6 +21,7 @@ import { LicensingModule } from '@platform/licensing/licensing.module'; import { VirtualContributorModule } from '@domain/community/virtual-contributor/virtual.contributor.module'; import { LicenseIssuerModule } from '@platform/license-issuer/license.issuer.module'; import { AccountHostModule } from './account.host.module'; +import { LicenseEngineModule } from '@core/license-engine/license.engine.module'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { AccountHostModule } from './account.host.module'; LicenseModule, LicensingModule, LicenseIssuerModule, + LicenseEngineModule, NameReporterModule, TypeOrmModule.forFeature([Account]), ], diff --git a/src/domain/space/account/account.resolver.fields.ts b/src/domain/space/account/account.resolver.fields.ts index 20cb4f9121..ebb36d00de 100644 --- a/src/domain/space/account/account.resolver.fields.ts +++ b/src/domain/space/account/account.resolver.fields.ts @@ -34,6 +34,8 @@ import { VirtualContributor, } from '@domain/community/virtual-contributor'; import { AccountHostService } from './account.host.service'; +import { LicensePrivilege } from '@common/enums/license.privilege'; +import { LicensePlanType } from '@common/enums/license.plan.type'; @Resolver(() => IAccount) export class AccountResolverFields { @@ -117,6 +119,17 @@ export class AccountResolverFields { return loader.load(account.id); } + @ResolveField('licensePrivileges', () => [LicensePrivilege], { + nullable: true, + description: + 'The privileges granted based on the License credentials held by this Account.', + }) + async licensePrivileges( + @Parent() account: IAccount + ): Promise { + return this.accountService.getLicensePrivileges(account); + } + @ResolveField('host', () => IContributor, { nullable: true, description: 'The Account host.', @@ -171,7 +184,7 @@ export class AccountResolverFields { ), }; }) - .filter(item => item.plan) + .filter(item => item.plan?.type === LicensePlanType.SPACE_PLAN) .sort((a, b) => b.plan!.sortOrder - a.plan!.sortOrder)?.[0].subscription; } diff --git a/src/domain/space/account/account.resolver.mutations.ts b/src/domain/space/account/account.resolver.mutations.ts index fe54bcfcad..036d37053b 100644 --- a/src/domain/space/account/account.resolver.mutations.ts +++ b/src/domain/space/account/account.resolver.mutations.ts @@ -30,10 +30,7 @@ import { VirtualContributorAuthorizationService } from '@domain/community/virtua import { VirtualContributorService } from '@domain/community/virtual-contributor/virtual.contributor.service'; import { IngestSpaceInput } from '../space/dto/space.dto.ingest'; import { EventBus } from '@nestjs/cqrs'; -import { - IngestSpace, - SpaceIngestionPurpose, -} from '@services/infrastructure/event-bus/commands'; +import { IngestSpace } from '@services/infrastructure/event-bus/commands'; import { CommunityContributorType } from '@common/enums/community.contributor.type'; import { CommunityRole } from '@common/enums/community.role'; import { AccountHostService } from './account.host.service'; @@ -65,7 +62,7 @@ export class AccountResolverMutations { ): Promise { const authorizationPolicy = await this.platformAuthorizationService.getPlatformAuthorizationPolicy(); - await this.authorizationService.grantAccessOrFail( + this.authorizationService.grantAccessOrFail( agentInfo, authorizationPolicy, AuthorizationPrivilege.CREATE_SPACE, @@ -168,7 +165,7 @@ export class AccountResolverMutations { const account = await this.accountService.getAccountOrFail( updateData.accountID ); - await this.authorizationService.grantAccessOrFail( + this.authorizationService.grantAccessOrFail( agentInfo, account.authorization, AuthorizationPrivilege.PLATFORM_ADMIN, @@ -213,7 +210,7 @@ export class AccountResolverMutations { LogContext.ACCOUNT ); } - await this.authorizationService.grantAccessOrFail( + this.authorizationService.grantAccessOrFail( agentInfo, spaceDefaults.authorization, AuthorizationPrivilege.UPDATE, @@ -323,9 +320,7 @@ export class AccountResolverMutations { `ingest space: ${space.nameID}(${space.id})` ); - this.eventBus.publish( - new IngestSpace(space.id, SpaceIngestionPurpose.Knowledge) - ); + this.eventBus.publish(new IngestSpace(space.id, ingestSpaceData.purpose)); return space; } } diff --git a/src/domain/space/account/account.service.ts b/src/domain/space/account/account.service.ts index 83b94eafe6..8b47cca87b 100644 --- a/src/domain/space/account/account.service.ts +++ b/src/domain/space/account/account.service.ts @@ -37,9 +37,11 @@ import { CreateVirtualContributorOnAccountInput } from './dto/account.dto.create import { IVirtualContributor } from '@domain/community/virtual-contributor'; import { VirtualContributorService } from '@domain/community/virtual-contributor/virtual.contributor.service'; import { User } from '@domain/community/user'; -import { LicenseFeatureFlagName } from '@common/enums/license.feature.flag.name'; import { LicenseIssuerService } from '@platform/license-issuer/license.issuer.service'; import { AccountHostService } from './account.host.service'; +import { Organization } from '@domain/community/organization/organization.entity'; +import { LicensePrivilege } from '@common/enums/license.privilege'; +import { LicenseEngineService } from '@core/license-engine/license.engine.service'; @Injectable() export class AccountService { @@ -52,6 +54,7 @@ export class AccountService { private licenseService: LicenseService, private contributorService: ContributorService, private licensingService: LicensingService, + private licenseEngineService: LicenseEngineService, private licenseIssuerService: LicenseIssuerService, private virtualContributorService: VirtualContributorService, @InjectRepository(Account) @@ -70,23 +73,6 @@ export class AccountService { const licensingFramework = await this.licensingService.getDefaultLicensingOrFail(); - const licensePlansToAssign: ILicensePlan[] = []; - const basePlan = await this.licensingService.getBasePlan( - licensingFramework.id - ); - licensePlansToAssign.push(basePlan); - if ( - accountData.licensePlanID && - accountData.licensePlanID !== basePlan.id - ) { - licensePlansToAssign.push( - await this.licensingService.getLicensePlanOrFail( - licensingFramework.id, - accountData.licensePlanID - ) - ); - } - const account: IAccount = new Account(); account.authorization = new AuthorizationPolicy(); account.library = await this.templatesSetService.createTemplatesSet(); @@ -104,24 +90,26 @@ export class AccountService { agentInfo ); const host = await this.setAccountHost(account, accountData.hostID); - if (host instanceof User) { - account.license = await this.licenseService.updateLicense( - account.license, - { - featureFlags: [ - { - name: LicenseFeatureFlagName.VIRTUAL_CONTRIBUTORS, - enabled: true, - }, - ], - } - ); - } account.agent = await this.agentService.createAgent({ parentDisplayID: `account-${account.space.nameID}`, }); + const licensePlansToAssign: ILicensePlan[] = []; + const licensePlans = await this.licensingService.getLicensePlans( + licensingFramework.id + ); + for (const plan of licensePlans) { + if (host instanceof User && plan.assignToNewUserAccounts) { + licensePlansToAssign.push(plan); + } else if ( + host instanceof Organization && + plan.assignToNewOrganizationAccounts + ) { + licensePlansToAssign.push(plan); + } + } + for (const licensePlan of licensePlansToAssign) { account.agent = await this.licenseIssuerService.assignLicensePlan( account.agent, @@ -240,7 +228,7 @@ export class AccountService { agent: true, space: true, library: true, - license: { featureFlags: true }, + license: true, defaults: true, virtualContributors: true, }, @@ -250,7 +238,6 @@ export class AccountService { !account.agent || !account.space || !account.license || - !account.license?.featureFlags || !account.defaults || !account.library || !account.virtualContributors @@ -322,6 +309,29 @@ export class AccountService { return accounts; } + async getLicensePrivileges(account: IAccount): Promise { + let accountAgent = account.agent; + if (!account.agent) { + const accountWithAgent = await this.getAccountOrFail(account.id, { + relations: { + agent: { + credentials: true, + }, + }, + }); + accountAgent = accountWithAgent.agent; + } + if (!accountAgent) { + throw new EntityNotFoundException( + `Unable to find agent with credentials for account: ${account.id}`, + LogContext.ACCOUNT + ); + } + const privileges = await this.licenseEngineService.getGrantedPrivileges( + accountAgent + ); + return privileges; + } async getLibraryOrFail(accountId: string): Promise { const accountWithTemplates = await this.getAccountOrFail(accountId, { @@ -346,7 +356,7 @@ export class AccountService { async getLicenseOrFail(accountId: string): Promise { const account = await this.getAccountOrFail(accountId, { relations: { - license: { featureFlags: true }, + license: true, }, }); const license = account.license; diff --git a/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.callout.groups.blank.slate.ts b/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.callout.groups.blank.slate.ts new file mode 100644 index 0000000000..3c15d94771 --- /dev/null +++ b/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.callout.groups.blank.slate.ts @@ -0,0 +1,9 @@ +import { CalloutGroupName } from '@common/enums/callout.group.name'; +import { ICalloutGroup } from '@domain/collaboration/callout-groups/callout.group.interface'; + +export const spaceDefaultsCalloutGroupsBlankSlate: ICalloutGroup[] = [ + { + displayName: CalloutGroupName.HOME, + description: 'The Home page.', + }, +]; diff --git a/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.callouts.blank.slate.ts b/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.callouts.blank.slate.ts new file mode 100644 index 0000000000..16cc64a873 --- /dev/null +++ b/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.callouts.blank.slate.ts @@ -0,0 +1,31 @@ +/* eslint-disable prettier/prettier */ +import { CalloutState } from '@common/enums/callout.state'; +import { CalloutType } from '@common/enums/callout.type'; +import { CalloutGroupName } from '@common/enums/callout.group.name'; +import { TagsetReservedName } from '@common/enums/tagset.reserved.name'; +import { FlowState } from './space.defaults.innovation.flow.blank.slate'; +import { CreateCalloutInput } from '@domain/collaboration/callout/dto/callout.dto.create'; + +export const spaceDefaultsCalloutsBlankSlate: CreateCalloutInput[] = [ + { + nameID: 'welcome', + type: CalloutType.POST, + contributionPolicy: { + state: CalloutState.OPEN, + }, + sortOrder: 1, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: '👋 Welcome to your space!', + description: 'An empty space for you to configure!.', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.PHASE_1], + }, + ], + }, + }, + }, +]; diff --git a/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.innovation.flow.blank.slate.ts b/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.innovation.flow.blank.slate.ts new file mode 100644 index 0000000000..aff2fea29a --- /dev/null +++ b/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.innovation.flow.blank.slate.ts @@ -0,0 +1,24 @@ +import { IInnovationFlowState } from '@domain/collaboration/innovation-flow-states/innovation.flow.state.interface'; + +export enum FlowState { + PHASE_1 = 'Phase 1', + PHASE_2 = 'Phase 2', + PHASE_3 = 'Phase 3', +} + +export const spaceDefaultsInnovationFlowStatesBlankSlate: IInnovationFlowState[] = + [ + { + displayName: FlowState.PHASE_1, + description: + '🔍 A journey of discovery! Gather insights through research and observation.', + }, + { + displayName: FlowState.PHASE_2, + description: '🔍 The next phase....', + }, + { + displayName: FlowState.PHASE_3, + description: '🔍 And another phase!', + }, + ]; diff --git a/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.settings.blank.slate.ts b/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.settings.blank.slate.ts new file mode 100644 index 0000000000..1224c2befd --- /dev/null +++ b/src/domain/space/space.defaults/definitions/blank-slate/space.defaults.settings.blank.slate.ts @@ -0,0 +1,20 @@ +import { CommunityMembershipPolicy } from '@common/enums/community.membership.policy'; +import { SpacePrivacyMode } from '@common/enums/space.privacy.mode'; +import { ISpaceSettings } from '@domain/space/space.settings/space.settings.interface'; + +export const spaceDefaultsSettingsBlankSlate: ISpaceSettings = { + privacy: { + mode: SpacePrivacyMode.PUBLIC, + allowPlatformSupportAsAdmin: true, + }, + membership: { + policy: CommunityMembershipPolicy.APPLICATIONS, + trustedOrganizations: [], // only allow to be host org for now, not on subspaces + allowSubspaceAdminsToInviteMembers: true, + }, + collaboration: { + inheritMembershipRights: false, + allowMembersToCreateSubspaces: false, + allowMembersToCreateCallouts: false, + }, +}; diff --git a/src/domain/space/space.defaults/definitions/subspace.callout.group.ts b/src/domain/space/space.defaults/definitions/challenge/space.defaults.callout.groups.challenge.ts similarity index 78% rename from src/domain/space/space.defaults/definitions/subspace.callout.group.ts rename to src/domain/space/space.defaults/definitions/challenge/space.defaults.callout.groups.challenge.ts index 7f0b543797..bd1e697f4e 100644 --- a/src/domain/space/space.defaults/definitions/subspace.callout.group.ts +++ b/src/domain/space/space.defaults/definitions/challenge/space.defaults.callout.groups.challenge.ts @@ -1,7 +1,7 @@ import { CalloutGroupName } from '@common/enums/callout.group.name'; import { ICalloutGroup } from '@domain/collaboration/callout-groups/callout.group.interface'; -export const subspaceCalloutGroups: ICalloutGroup[] = [ +export const spaceDefaultsCalloutGroupsChallenge: ICalloutGroup[] = [ { displayName: CalloutGroupName.HOME, description: 'The Subspace Home page.', diff --git a/src/domain/space/space.defaults/definitions/subspace.default.callouts.ts b/src/domain/space/space.defaults/definitions/challenge/space.defaults.callouts.challenge.ts similarity index 76% rename from src/domain/space/space.defaults/definitions/subspace.default.callouts.ts rename to src/domain/space/space.defaults/definitions/challenge/space.defaults.callouts.challenge.ts index f78f129fe8..e5e149991f 100644 --- a/src/domain/space/space.defaults/definitions/subspace.default.callouts.ts +++ b/src/domain/space/space.defaults/definitions/challenge/space.defaults.callouts.challenge.ts @@ -1,10 +1,12 @@ import { CalloutGroupName } from '@common/enums/callout.group.name'; import { CalloutState } from '@common/enums/callout.state'; import { CalloutType } from '@common/enums/callout.type'; -import { CreateCalloutInput } from '@domain/collaboration/callout'; import { EMPTY_WHITEBOARD_CONTENT } from '@domain/common/whiteboard/empty.whiteboard.content'; +import { TagsetReservedName } from '@common/enums/tagset.reserved.name'; +import { FlowState } from './space.defaults.innovation.flow.challenge'; +import { CreateCalloutInput } from '@domain/collaboration/callout/dto/callout.dto.create'; -export const subspaceDefaultCallouts: CreateCalloutInput[] = [ +export const spaceDefaultsCalloutsChallenge: CreateCalloutInput[] = [ { nameID: 'general-chat', type: CalloutType.POST, @@ -17,6 +19,12 @@ export const subspaceDefaultCallouts: CreateCalloutInput[] = [ profile: { displayName: 'General chat 💬', description: 'Things you would like to discuss with the community.', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], }, }, }, @@ -32,6 +40,12 @@ export const subspaceDefaultCallouts: CreateCalloutInput[] = [ profile: { displayName: 'Getting Started', description: '⬇️ Here are some quick links to help you get started', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], }, }, }, @@ -48,6 +62,12 @@ export const subspaceDefaultCallouts: CreateCalloutInput[] = [ displayName: '👥 This is us!', description: 'Here you will find the profiles of all contributors to this Space. Are you joining us? 👋 Nice to meet you! Please also provide your details below.', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], }, }, contributionDefaults: { @@ -68,6 +88,12 @@ export const subspaceDefaultCallouts: CreateCalloutInput[] = [ displayName: 'Relevant news, research or use cases 📰', description: 'Please share any relevant insights to help us better understand the Space. You can describe why it is relevant and add a link or upload a document with the article. You can also comment on the insights already submitted by other community members!', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], }, }, contributionDefaults: { @@ -88,6 +114,12 @@ export const subspaceDefaultCallouts: CreateCalloutInput[] = [ displayName: 'Who are the stakeholders?', description: 'Choose one of the templates from the library to map your stakeholders here!', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], }, whiteboard: { content: EMPTY_WHITEBOARD_CONTENT, @@ -110,6 +142,12 @@ export const subspaceDefaultCallouts: CreateCalloutInput[] = [ profile: { displayName: 'Reference / important documents', description: 'Please add links to documents with reference material.💥', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], }, }, }, @@ -126,6 +164,12 @@ export const subspaceDefaultCallouts: CreateCalloutInput[] = [ displayName: 'Proposals', description: 'What are the 💡 Opportunities that you think we should be working on? Please add them below and use the template provided.', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], }, }, contributionDefaults: { diff --git a/src/domain/space/space.defaults/definitions/challenge/space.defaults.innovation.flow.challenge.ts b/src/domain/space/space.defaults/definitions/challenge/space.defaults.innovation.flow.challenge.ts new file mode 100644 index 0000000000..e826e5bc8e --- /dev/null +++ b/src/domain/space/space.defaults/definitions/challenge/space.defaults.innovation.flow.challenge.ts @@ -0,0 +1,38 @@ +import { IInnovationFlowState } from '@domain/collaboration/innovation-flow-states/innovation.flow.state.interface'; + +export enum FlowState { + EXPLORE = 'Explore', + DEFINE = 'Define', + BRAINSTORM = 'Brainstorm', + VALIDATE = 'Validate', + EVALUATE = 'Evaluate', +} + +export const spaceDefaultsInnovationFlowStatesChallenge: IInnovationFlowState[] = + [ + { + displayName: FlowState.EXPLORE, + description: + '🔍 A journey of discovery! Gather insights through research and observation.', + }, + { + displayName: FlowState.DEFINE, + description: + '🎯 Sharpen your focus. Define the challenge with precision and set a clear direction.', + }, + { + displayName: FlowState.BRAINSTORM, + description: + '🎨 Ignite creativity. Generate a constellation of ideas, using concepts from diverse perspectives to get inspired.', + }, + { + displayName: FlowState.VALIDATE, + description: + '🛠️ Test assumptions. Build prototypes, seek feedback, and validate your concepts. Adapt based on real-world insights.', + }, + { + displayName: FlowState.EVALUATE, + description: + '✅ Assess impact, feasibility, and alignment to make informed choices.', + }, + ]; diff --git a/src/domain/space/space.defaults/definitions/subspace.settings.ts b/src/domain/space/space.defaults/definitions/challenge/space.defaults.settings.challenge.ts similarity index 91% rename from src/domain/space/space.defaults/definitions/subspace.settings.ts rename to src/domain/space/space.defaults/definitions/challenge/space.defaults.settings.challenge.ts index 31a286d92b..8d7c8dbc2e 100644 --- a/src/domain/space/space.defaults/definitions/subspace.settings.ts +++ b/src/domain/space/space.defaults/definitions/challenge/space.defaults.settings.challenge.ts @@ -2,7 +2,7 @@ import { CommunityMembershipPolicy } from '@common/enums/community.membership.po import { SpacePrivacyMode } from '@common/enums/space.privacy.mode'; import { ISpaceSettings } from '@domain/space/space.settings/space.settings.interface'; -export const subspaceSettingsDefaults: ISpaceSettings = { +export const spaceDefaultsSettingsChallenge: ISpaceSettings = { privacy: { mode: SpacePrivacyMode.PUBLIC, allowPlatformSupportAsAdmin: false, diff --git a/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.callout.groups.opportunity.ts b/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.callout.groups.opportunity.ts new file mode 100644 index 0000000000..ec01008ff5 --- /dev/null +++ b/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.callout.groups.opportunity.ts @@ -0,0 +1,9 @@ +import { CalloutGroupName } from '@common/enums/callout.group.name'; +import { ICalloutGroup } from '@domain/collaboration/callout-groups/callout.group.interface'; + +export const spaceDefaultsCalloutGroupsOpportunity: ICalloutGroup[] = [ + { + displayName: CalloutGroupName.HOME, + description: 'The Subspace Home page.', + }, +]; diff --git a/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.callouts.opportunity.ts b/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.callouts.opportunity.ts new file mode 100644 index 0000000000..da731112a2 --- /dev/null +++ b/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.callouts.opportunity.ts @@ -0,0 +1,180 @@ +import { CalloutGroupName } from '@common/enums/callout.group.name'; +import { CalloutState } from '@common/enums/callout.state'; +import { CalloutType } from '@common/enums/callout.type'; +import { EMPTY_WHITEBOARD_CONTENT } from '@domain/common/whiteboard/empty.whiteboard.content'; +import { TagsetReservedName } from '@common/enums/tagset.reserved.name'; +import { FlowState } from './space.defaults.innovation.flow.opportunity'; +import { CreateCalloutInput } from '@domain/collaboration/callout/dto/callout.dto.create'; + +export const spaceDefaultsCalloutsOpportunity: CreateCalloutInput[] = [ + { + nameID: 'general-chat', + type: CalloutType.POST, + contributionPolicy: { + state: CalloutState.OPEN, + }, + sortOrder: 2, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'General chat 💬', + description: 'Things you would like to discuss with the community.', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], + }, + }, + }, + { + nameID: 'getting-started', + type: CalloutType.LINK_COLLECTION, + contributionPolicy: { + state: CalloutState.CLOSED, + }, + sortOrder: 1, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Getting Started', + description: '⬇️ Here are some quick links to help you get started', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], + }, + }, + }, + { + nameID: 'contributor-profiles', + type: CalloutType.POST_COLLECTION, + contributionPolicy: { + state: CalloutState.OPEN, + }, + sortOrder: 2, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: '👥 This is us!', + description: + 'Here you will find the profiles of all contributors to this Space. Are you joining us? 👋 Nice to meet you! Please also provide your details below.', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], + }, + }, + contributionDefaults: { + postDescription: + 'Hi! I am...

In daily life I...

And I also like to...

You can contact me for anything related to...

My wish for this Space is..

And of course feel invited to insert a nice picture!', + }, + }, + { + nameID: 'news', + type: CalloutType.POST_COLLECTION, + contributionPolicy: { + state: CalloutState.OPEN, + }, + sortOrder: 1, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Relevant news, research or use cases 📰', + description: + 'Please share any relevant insights to help us better understand the Space. You can describe why it is relevant and add a link or upload a document with the article. You can also comment on the insights already submitted by other community members!', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], + }, + }, + contributionDefaults: { + postDescription: + '✍️ Please share your contribution. The more details the better!', + }, + }, + { + nameID: 'stakeholder-map', + type: CalloutType.WHITEBOARD, + contributionPolicy: { + state: CalloutState.OPEN, + }, + sortOrder: 2, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Who are the stakeholders?', + description: + 'Choose one of the templates from the library to map your stakeholders here!', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], + }, + whiteboard: { + content: EMPTY_WHITEBOARD_CONTENT, + nameID: 'stakeholders', + profileData: { + displayName: 'stakeholder map', + }, + }, + }, + }, + { + nameID: 'documents', + type: CalloutType.LINK_COLLECTION, + contributionPolicy: { + state: CalloutState.OPEN, + }, + sortOrder: 3, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Reference / important documents', + description: 'Please add links to documents with reference material.💥', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], + }, + }, + }, + { + nameID: 'proposals', + type: CalloutType.POST_COLLECTION, + contributionPolicy: { + state: CalloutState.OPEN, + }, + sortOrder: 1, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Proposals', + description: + 'What are the 💡 Opportunities that you think we should be working on? Please add them below and use the template provided.', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.EXPLORE], + }, + ], + }, + }, + contributionDefaults: { + postDescription: + '💡 Title

💬 Description

🗣️ Who to involve

🌟 Why this has great potential', + }, + }, +]; diff --git a/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.innovation.flow.opportunity.ts b/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.innovation.flow.opportunity.ts new file mode 100644 index 0000000000..bd567dcf94 --- /dev/null +++ b/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.innovation.flow.opportunity.ts @@ -0,0 +1,38 @@ +import { IInnovationFlowState } from '@domain/collaboration/innovation-flow-states/innovation.flow.state.interface'; + +export enum FlowState { + EXPLORE = 'Explore', + DEFINE = 'Define', + BRAINSTORM = 'Brainstorm', + VALIDATE = 'Validate', + EVALUATE = 'Evaluate', +} + +export const spaceDefaultsInnovationFlowStatesOpportunity: IInnovationFlowState[] = + [ + { + displayName: 'Explore', + description: + '🔍 A journey of discovery! Gather insights through research and observation.', + }, + { + displayName: 'Define', + description: + '🎯 Sharpen your focus. Define the challenge with precision and set a clear direction.', + }, + { + displayName: 'Brainstorm', + description: + '🎨 Ignite creativity. Generate a constellation of ideas, using concepts from diverse perspectives to get inspired.', + }, + { + displayName: 'Validate', + description: + '🛠️ Test assumptions. Build prototypes, seek feedback, and validate your concepts. Adapt based on real-world insights.', + }, + { + displayName: 'Evaluate', + description: + '✅ Assess impact, feasibility, and alignment to make informed choices.', + }, + ]; diff --git a/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.settings.opportunity.ts b/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.settings.opportunity.ts new file mode 100644 index 0000000000..41782bc939 --- /dev/null +++ b/src/domain/space/space.defaults/definitions/oppportunity/space.defaults.settings.opportunity.ts @@ -0,0 +1,20 @@ +import { CommunityMembershipPolicy } from '@common/enums/community.membership.policy'; +import { SpacePrivacyMode } from '@common/enums/space.privacy.mode'; +import { ISpaceSettings } from '@domain/space/space.settings/space.settings.interface'; + +export const spaceDefaultsSettingsOpportunity: ISpaceSettings = { + privacy: { + mode: SpacePrivacyMode.PUBLIC, + allowPlatformSupportAsAdmin: false, + }, + membership: { + policy: CommunityMembershipPolicy.OPEN, + trustedOrganizations: [], // only allow to be host org for now, not on subspaces + allowSubspaceAdminsToInviteMembers: false, + }, + collaboration: { + inheritMembershipRights: true, + allowMembersToCreateSubspaces: true, + allowMembersToCreateCallouts: true, + }, +}; diff --git a/src/domain/space/space.defaults/definitions/space.callout.group.ts b/src/domain/space/space.defaults/definitions/root-space/space.defaults.callout.groups.root.space.ts similarity index 88% rename from src/domain/space/space.defaults/definitions/space.callout.group.ts rename to src/domain/space/space.defaults/definitions/root-space/space.defaults.callout.groups.root.space.ts index 4f27577fa2..c1c9d529ec 100644 --- a/src/domain/space/space.defaults/definitions/space.callout.group.ts +++ b/src/domain/space/space.defaults/definitions/root-space/space.defaults.callout.groups.root.space.ts @@ -1,7 +1,7 @@ import { CalloutGroupName } from '@common/enums/callout.group.name'; import { ICalloutGroup } from '@domain/collaboration/callout-groups/callout.group.interface'; -export const spaceCalloutGroups: ICalloutGroup[] = [ +export const spaceDefaultsCalloutGroupsRootSpace: ICalloutGroup[] = [ { displayName: CalloutGroupName.HOME, description: 'The Home page.', diff --git a/src/domain/space/space.defaults/definitions/space.default.callouts.ts b/src/domain/space/space.defaults/definitions/root-space/space.defaults.callouts.root.space.ts similarity index 84% rename from src/domain/space/space.defaults/definitions/space.default.callouts.ts rename to src/domain/space/space.defaults/definitions/root-space/space.defaults.callouts.root.space.ts index 74841e9528..dd6336f67a 100644 --- a/src/domain/space/space.defaults/definitions/space.default.callouts.ts +++ b/src/domain/space/space.defaults/definitions/root-space/space.defaults.callouts.root.space.ts @@ -2,9 +2,11 @@ import { CalloutState } from '@common/enums/callout.state'; import { CalloutType } from '@common/enums/callout.type'; import { CalloutGroupName } from '@common/enums/callout.group.name'; -import { CreateCalloutInput } from '@domain/collaboration/callout'; +import { TagsetReservedName } from '@common/enums/tagset.reserved.name'; +import { FlowState } from './space.defaults.innovation.flow.root.space'; +import { CreateCalloutInput } from '@domain/collaboration/callout/dto/callout.dto.create'; -export const spaceDefaultCallouts: CreateCalloutInput[] = [ +export const spaceDefaultsCalloutsRootSpace: CreateCalloutInput[] = [ { nameID: 'welcome', type: CalloutType.POST, @@ -18,6 +20,12 @@ export const spaceDefaultCallouts: CreateCalloutInput[] = [ displayName: '👋 Welcome to your space!', description: "Take an interactive tour below to discover how our spaces are designed. We also invite you to explore the other tutorials available on this page and beyond. We're excited to have you here! \n

\n", + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.NOT_USED], + }, + ], }, }, }, @@ -34,6 +42,12 @@ export const spaceDefaultCallouts: CreateCalloutInput[] = [ displayName: '⚙️ Set it up your way!', description: "In this concise guide, you'll discover how to customize your Space to suit your needs. Learn more about how to set the visibility of the Space, how people can join, and what essential information to include on the about page. Let's get started! \n
\n", + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.NOT_USED], + }, + ], }, }, }, @@ -50,6 +64,12 @@ export const spaceDefaultCallouts: CreateCalloutInput[] = [ displayName: '🧩 Collaboration tools', description: "Collaboration tools allow you to gather existing knowledge from your community and (co-)create new insights through text and visuals. In the tour below you will learn all about the different tools and how to use them. Enjoy! \n
\n", + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.NOT_USED], + }, + ], }, }, }, @@ -66,6 +86,12 @@ export const spaceDefaultCallouts: CreateCalloutInput[] = [ displayName: '🧹 Cleaning up', description: "Done with the tutorials and ready to build up this Space your way? You can move the tutorials to your knowledge base or delete them completely.\n\n* To move:\n\n * Click on the ⚙️ icon on the block with the tutorial > Edit\n * Scroll down to 'Location'\n * Select 'Knowledge Base' or any other page\n\n* To remove:\n\n * Click on the ⚙️ icon on the block with the tutorial > Delete\n * Confirm", + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.NOT_USED], + }, + ], }, }, }, @@ -82,6 +108,12 @@ export const spaceDefaultCallouts: CreateCalloutInput[] = [ displayName: '🤝 Set up your Community', description: "In this tour, you'll discover how to define permissions, create guidelines, set up an application process, and send out invitations. Let's get started! \n
", + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.NOT_USED], + }, + ], }, }, }, @@ -98,6 +130,12 @@ export const spaceDefaultCallouts: CreateCalloutInput[] = [ displayName: '↪️ Subspaces', description: "Below, we'll explore the concept of Subspaces. You will learn more about what to use these Subspaces for, what functionality is available, and how you can guide the process using an Innovation Flow. \n
\n", + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.NOT_USED], + }, + ], }, }, }, @@ -114,6 +152,12 @@ export const spaceDefaultCallouts: CreateCalloutInput[] = [ displayName: '📚 The Knowledge Base', description: "Welcome to your knowledge base! This page serves as a central repository for valuable information and references that are relevant for the entire community.\n
\n", + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.NOT_USED], + }, + ], }, }, }, diff --git a/src/domain/space/space.defaults/definitions/root-space/space.defaults.innovation.flow.root.space.ts b/src/domain/space/space.defaults/definitions/root-space/space.defaults.innovation.flow.root.space.ts new file mode 100644 index 0000000000..5224a98ed0 --- /dev/null +++ b/src/domain/space/space.defaults/definitions/root-space/space.defaults.innovation.flow.root.space.ts @@ -0,0 +1,14 @@ +import { IInnovationFlowState } from '@domain/collaboration/innovation-flow-states/innovation.flow.state.interface'; + +export enum FlowState { + NOT_USED = 'Not used', +} + +export const spaceDefaultsInnovationFlowStatesRootSpace: IInnovationFlowState[] = + [ + { + displayName: FlowState.NOT_USED, + description: + '🔍 A journey of discovery! Gather insights through research and observation.', + }, + ]; diff --git a/src/domain/space/space.defaults/definitions/space.settings.ts b/src/domain/space/space.defaults/definitions/root-space/space.defaults.settings.root.space.ts similarity index 91% rename from src/domain/space/space.defaults/definitions/space.settings.ts rename to src/domain/space/space.defaults/definitions/root-space/space.defaults.settings.root.space.ts index d9fdde8f1e..9d391f398e 100644 --- a/src/domain/space/space.defaults/definitions/space.settings.ts +++ b/src/domain/space/space.defaults/definitions/root-space/space.defaults.settings.root.space.ts @@ -2,7 +2,7 @@ import { CommunityMembershipPolicy } from '@common/enums/community.membership.po import { SpacePrivacyMode } from '@common/enums/space.privacy.mode'; import { ISpaceSettings } from '@domain/space/space.settings/space.settings.interface'; -export const spaceSettingsDefaults: ISpaceSettings = { +export const spaceDefaultsSettingsRootSpace: ISpaceSettings = { privacy: { mode: SpacePrivacyMode.PUBLIC, allowPlatformSupportAsAdmin: false, diff --git a/src/domain/space/space.defaults/definitions/space.defaults.innovation.flow.ts b/src/domain/space/space.defaults/definitions/space.defaults.innovation.flow.ts deleted file mode 100644 index bdbb155ffa..0000000000 --- a/src/domain/space/space.defaults/definitions/space.defaults.innovation.flow.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { IInnovationFlowState } from '@domain/collaboration/innovation-flow-states/innovation.flow.state.interface'; - -export const innovationFlowStatesDefault: IInnovationFlowState[] = [ - { - displayName: 'Explore', - description: - '🔍 A journey of discovery! Gather insights through research and observation.', - }, - { - displayName: 'Define', - description: - '🎯 Sharpen your focus. Define the challenge with precision and set a clear direction.', - }, - { - displayName: 'Brainstorm', - description: - '🎨 Ignite creativity. Generate a constellation of ideas, using concepts from diverse perspectives to get inspired.', - }, - { - displayName: 'Validate', - description: - '🛠️ Test assumptions. Build prototypes, seek feedback, and validate your concepts. Adapt based on real-world insights.', - }, - { - displayName: 'Evaluate', - description: - '✅ Assess impact, feasibility, and alignment to make informed choices.', - }, -]; diff --git a/src/domain/space/space.defaults/definitions/space.defaults.templates.ts b/src/domain/space/space.defaults/definitions/space.defaults.templates.ts index 17be617a8c..ad228bfb18 100644 --- a/src/domain/space/space.defaults/definitions/space.defaults.templates.ts +++ b/src/domain/space/space.defaults/definitions/space.defaults.templates.ts @@ -1,4 +1,4 @@ -import { innovationFlowStatesDefault } from './space.defaults.innovation.flow'; +import { spaceDefaultsInnovationFlowStatesChallenge } from './challenge/space.defaults.innovation.flow.challenge'; export const templatesSetDefaults: any = { posts: [ @@ -43,7 +43,7 @@ export const templatesSetDefaults: any = { description: 'Default innovationFlow', tags: ['default'], }, - states: innovationFlowStatesDefault, + states: spaceDefaultsInnovationFlowStatesChallenge, }, { profile: { diff --git a/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.callout.groups.virtual.contributor.ts b/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.callout.groups.virtual.contributor.ts new file mode 100644 index 0000000000..2c547c61e2 --- /dev/null +++ b/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.callout.groups.virtual.contributor.ts @@ -0,0 +1,9 @@ +import { CalloutGroupName } from '@common/enums/callout.group.name'; +import { ICalloutGroup } from '@domain/collaboration/callout-groups/callout.group.interface'; + +export const spaceDefaultsCalloutGroupsVirtualContributor: ICalloutGroup[] = [ + { + displayName: CalloutGroupName.HOME, + description: 'The Subspace Home page.', + }, +]; diff --git a/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.callouts.virtual.contributor.ts b/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.callouts.virtual.contributor.ts new file mode 100644 index 0000000000..6410061cbd --- /dev/null +++ b/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.callouts.virtual.contributor.ts @@ -0,0 +1,230 @@ +import { CalloutGroupName } from '@common/enums/callout.group.name'; +import { CalloutState } from '@common/enums/callout.state'; +import { CalloutType } from '@common/enums/callout.type'; +import { TagsetReservedName } from '@common/enums/tagset.reserved.name'; +import { FlowState } from './space.defaults.innovation.flow.virtual.contributor'; +import { CreateCalloutInput } from '@domain/collaboration/callout/dto/callout.dto.create'; + +export const spaceDefaultsCalloutsVirtualContributor: CreateCalloutInput[] = [ + { + nameID: 'summary', + type: CalloutType.POST, + contributionPolicy: { + state: CalloutState.CLOSED, + }, + sortOrder: 1, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'No Time? A quick summary ⬇️', + description: + '* Virtual Contributors are dynamic entities that engage with you based on a curated body of knowledge.\n* This knowledge repository consists of texts and documents that you collect within a designated Subspace of your Space.\n* To activate a Virtual Contributor, navigate to your Space Settings and select the Account tab.\n* Once activated, anyone in your Space can interact with the Virtual Contributor by mentioning it in a comment to a post using the format @name-of-virtual-contributor.\n', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.INTRODUCTION], + }, + ], + }, + }, + }, + { + nameID: 'introduction', + type: CalloutType.POST, + contributionPolicy: { + state: CalloutState.CLOSED, + }, + sortOrder: 2, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'The Virtual Contributor', + description: + '# 🤖 What is a Virtual Contributor?\n\nThink of it as a dynamic repository of knowledge with which you can interact. Powered by generative AI, these bots provide answers based on the documents and texts they were trained on. Unlike generic chatbots, the Virtual Contributor responds only to questions it can confidently answer.\n\n# 🛠️ How to make your own:\n\n1. Learn More in this Introduction: Discover additional details about the Virtual Contributor and how you can interact with it.\n2. Build Your Knowledge Base: Click the next step in the flow to create the body of knowledge you want to train your Virtual Contributor on.\n3. Read how to Publish Your VC in Going Live: Once you’ve crafted your Virtual Contributor, publish it so anyone in your Space can interact with it!\n\n# ❗Keep in mind:\n\nAlkemio is currently in Public Preview. We invite you to join us in shaping a better future with safe technology and responsible AI usage. The Virtual Contributor is available to Spaces with a Plus subscription. Even if your subscription (or the free trial) ends, your Virtual Contributor will remain accessible, although it won’t answer new questions.\n', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.INTRODUCTION], + }, + ], + }, + }, + }, + { + nameID: 'interacting-with-vc', + type: CalloutType.POST, + contributionPolicy: { + state: CalloutState.CLOSED, + }, + sortOrder: 3, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Interacting with a Virtual Contributor', + description: + 'Once you’ve published your Virtual Contributor (VC), everyone in your Space can engage with it. The process is straightforward: simply mention or tag the VC in a comment (@name-of-your-vc), just as you would with any other contributor. The VC will then respond in a comment below. Be patient—it might take a few seconds; after all, even our VC needs a moment to think! 😉\n\nIf you’d like your VC to interact in a different Space, feel free to [contact our support team here](https://welcome.alkem.io/contact/)—they’ll be happy to assist you.\n\nRemember that you can ask your VC questions anywhere within your Space. Admins of Subspaces in your Space can also add them there (Subspace Settings > Community). However, please keep the posts in this Subspace (Virtual Contributor) closed for interaction with the VC since it could clutter your knowledge base.\n', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.INTRODUCTION], + }, + ], + }, + }, + }, + { + nameID: 'vc-profile', + type: CalloutType.POST, + contributionPolicy: { + state: CalloutState.CLOSED, + }, + sortOrder: 4, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'The Profile of your Virtual Contributor', + description: + 'Similar to users and organizations, your Virtual Contributor (VC) will have a profile that others can view by clicking on its name or avatar. When the VC is linked to your account, you will also notice a ⚙️ icon on the profile page next to the VC’s name. Click there to edit the profile and enhance it with a nice description, perhaps including instructions for people on what questions to ask.\n', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.INTRODUCTION], + }, + ], + }, + }, + }, + { + nameID: 'content-types', + type: CalloutType.POST, + contributionPolicy: { + state: CalloutState.CLOSED, + }, + sortOrder: 5, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Who are the stakeholders?', + description: + 'Currently, the Virtual Contributor can read:\n\n* Text written anywhere in this Subspace\n* PDF files uploaded in Collections of Links and Documents\n* PDF files added as a reference to a post or other collaboration tool\n* Website texts pointed to through a link in a post or other collaboration tool\n', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.INTRODUCTION], + }, + ], + }, + }, + }, + { + nameID: 'terms-conditions', + type: CalloutType.POST, + contributionPolicy: { + state: CalloutState.CLOSED, + }, + sortOrder: 6, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Terms & Conditions', + description: + 'As a host of a Space, and thus of the Body of Knowledge this Virtual Contributor is based upon, you are responsible for the content in there. To read all Terms and Conditions, [click here](https://welcome.alkem.io/legal).\n', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.INTRODUCTION], + }, + ], + }, + }, + }, + { + nameID: 'body-of-knowledge-ex1', + type: CalloutType.POST, + contributionPolicy: { + state: CalloutState.CLOSED, + }, + sortOrder: 7, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Example 1: Background information', + description: + 'Click on the ⚙️ at the top right of this post, click EDIT and then update this text with the background information or something else you want your Virtual Contributor to know about.\n', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.BODY_OF_KNOWLEDGE], + }, + ], + }, + }, + }, + { + nameID: 'body-of-knowledge-ex2', + type: CalloutType.POST, + contributionPolicy: { + state: CalloutState.CLOSED, + }, + sortOrder: 8, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Example 2: Random facts and figures', + description: + 'Use this post to add facts, figures, insights, etc, that you cannot group in a more structure place, like for instance:\n\n* Alkemio was launched in 2021\n* New Zealand Features the World’s Longest Mountain Name. The name holds the Guinness World Record and consists of 85 characters. The name of this mountain is Taumatawhakatangihangakoauauotamateapokaiwhenuakitanatahu. When translated into English, the word means “the place where Tmatea, the man with the big knees, who slid, climbed, and swallowed mountains, known as – landeater – played his nose flute to his loved one”. Source: \n* Etc.\n\nClick on the ⚙️ at the top right of this post, click EDIT and then update this text with the facts and figures you want your Virtual Contributor to know about.\n', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.BODY_OF_KNOWLEDGE], + }, + ], + }, + }, + }, + { + nameID: 'body-of-knowledge-ex3', + type: CalloutType.LINK_COLLECTION, + contributionPolicy: { + state: CalloutState.CLOSED, + }, + sortOrder: 9, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Example 3: Links and Documents', + description: + 'Aside from inserting text, you can also upload documents and add links to expand the Body of Knowledge. Click on the plus below to add a link or (PDF) document or click on the ⚙️ at the top right of this post, and click EDIT to update this text.\n', + + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.BODY_OF_KNOWLEDGE], + }, + ], + }, + }, + }, + { + nameID: 'activate', + type: CalloutType.POST, + contributionPolicy: { + state: CalloutState.CLOSED, + }, + sortOrder: 10, + groupName: CalloutGroupName.HOME, + framing: { + profile: { + displayName: 'Ready? Go!', + description: + 'To activate your Virtual Contributor,\n\n1. Click here to go to the Account tab of your Space Settings (this link will be opened in a new tab).\n2. Scroll down a little bit and click on CREATE VIRTUAL CONTRIBUTOR.\n3. Give your VC a name and select this Subspace (Your Virtual Contributor) to use as a Body of Knowledge. Thats it!\n\nAnd then of course the interesting part comes along.. Interact with your VC! It is added to the Space automatically, so add a post in your Knowledge base and ask your VC for its thoughts 👍😊 Not sure how? Read the post about interacting with your VC in the Introduction phase of this flow.\n', + tagsets: [ + { + name: TagsetReservedName.FLOW_STATE, + tags: [FlowState.GOING_LIVE], + }, + ], + }, + }, + }, +]; diff --git a/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.innovation.flow.virtual.contributor.ts b/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.innovation.flow.virtual.contributor.ts new file mode 100644 index 0000000000..eeffa0d5c5 --- /dev/null +++ b/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.innovation.flow.virtual.contributor.ts @@ -0,0 +1,25 @@ +import { IInnovationFlowState } from '@domain/collaboration/innovation-flow-states/innovation.flow.state.interface'; + +export enum FlowState { + GOING_LIVE = 'Going Live', + INTRODUCTION = 'Introduction', + BODY_OF_KNOWLEDGE = 'Body of Knowledge', +} + +export const spaceDefaultsInnovationFlowStatesVirtualContributor: IInnovationFlowState[] = + [ + { + displayName: FlowState.INTRODUCTION, + description: + 'Scroll down to read more about how to get started. Ready to add some knowledge to your Virtual Contributor? Click on Body of Knowledge ⬆️', + }, + { + displayName: FlowState.BODY_OF_KNOWLEDGE, + description: + 'Here you can share all relevant information for the Virtual Contributor to know about. To get started, three posts have already been added. Click on the ➕ Collaboration Tool to add more.', + }, + { + displayName: FlowState.GOING_LIVE, + description: '', + }, + ]; diff --git a/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.settings.virtual.contributor.ts b/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.settings.virtual.contributor.ts new file mode 100644 index 0000000000..d9b91e45ca --- /dev/null +++ b/src/domain/space/space.defaults/definitions/virtual-contributor/space.defaults.settings.virtual.contributor.ts @@ -0,0 +1,20 @@ +import { CommunityMembershipPolicy } from '@common/enums/community.membership.policy'; +import { SpacePrivacyMode } from '@common/enums/space.privacy.mode'; +import { ISpaceSettings } from '@domain/space/space.settings/space.settings.interface'; + +export const spaceDefaultsSettingsVirtualContributor: ISpaceSettings = { + privacy: { + mode: SpacePrivacyMode.PRIVATE, + allowPlatformSupportAsAdmin: false, + }, + membership: { + policy: CommunityMembershipPolicy.APPLICATIONS, + trustedOrganizations: [], // only allow to be host org for now, not on subspaces + allowSubspaceAdminsToInviteMembers: false, + }, + collaboration: { + inheritMembershipRights: false, + allowMembersToCreateSubspaces: true, + allowMembersToCreateCallouts: true, + }, +}; diff --git a/src/domain/space/space.defaults/space.defaults.service.ts b/src/domain/space/space.defaults/space.defaults.service.ts index da6d61fec4..56b7af2b4b 100644 --- a/src/domain/space/space.defaults/space.defaults.service.ts +++ b/src/domain/space/space.defaults/space.defaults.service.ts @@ -15,19 +15,13 @@ import { TemplatesSetService } from '@domain/template/templates-set/templates.se import { IInnovationFlowTemplate } from '@domain/template/innovation-flow-template/innovation.flow.template.interface'; import { CreateInnovationFlowInput } from '@domain/collaboration/innovation-flow/dto'; import { templatesSetDefaults } from './definitions/space.defaults.templates'; -import { innovationFlowStatesDefault } from './definitions/space.defaults.innovation.flow'; import { IStorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.interface'; import { CreateCalloutInput } from '@domain/collaboration/callout/dto/callout.dto.create'; import { CreateCollaborationInput } from '@domain/collaboration/collaboration/dto/collaboration.dto.create'; import { ISpaceSettings } from '../space.settings/space.settings.interface'; -import { spaceSettingsDefaults } from './definitions/space.settings'; import { ICalloutGroup } from '@domain/collaboration/callout-groups/callout.group.interface'; -import { spaceCalloutGroups } from './definitions/space.callout.group'; -import { subspaceCalloutGroups } from './definitions/subspace.callout.group'; import { Account } from '../account/account.entity'; -import { subspaceDefaultCallouts } from './definitions/subspace.default.callouts'; import { subspaceCommunityPolicy } from './definitions/subspace.community.policy'; -import { spaceDefaultCallouts } from './definitions/space.default.callouts'; import { spaceCommunityPolicy } from './definitions/space.community.policy'; import { ICommunityPolicyDefinition } from '@domain/community/community-policy/community.policy.definition'; import { CreateFormInput } from '@domain/common/form/dto/form.dto.create'; @@ -35,10 +29,30 @@ import { subspceCommunityApplicationForm } from './definitions/subspace.communit import { spaceCommunityApplicationForm } from './definitions/space.community.application.form'; import { ProfileType } from '@common/enums'; import { CalloutGroupName } from '@common/enums/callout.group.name'; -import { subspaceSettingsDefaults } from './definitions/subspace.settings'; import { SpaceLevel } from '@common/enums/space.level'; import { EntityNotInitializedException } from '@common/exceptions/entity.not.initialized.exception'; import { SpaceType } from '@common/enums/space.type'; +import { spaceDefaultsCalloutGroupsChallenge } from './definitions/challenge/space.defaults.callout.groups.challenge'; +import { spaceDefaultsCalloutGroupsOpportunity } from './definitions/oppportunity/space.defaults.callout.groups.opportunity'; +import { spaceDefaultsCalloutGroupsRootSpace } from './definitions/root-space/space.defaults.callout.groups.root.space'; +import { spaceDefaultsCalloutGroupsVirtualContributor } from './definitions/virtual-contributor/space.defaults.callout.groups.virtual.contributor'; +import { spaceDefaultsCalloutsOpportunity } from './definitions/oppportunity/space.defaults.callouts.opportunity'; +import { spaceDefaultsCalloutsChallenge } from './definitions/challenge/space.defaults.callouts.challenge'; +import { spaceDefaultsCalloutsRootSpace } from './definitions/root-space/space.defaults.callouts.root.space'; +import { spaceDefaultsCalloutsVirtualContributor } from './definitions/virtual-contributor/space.defaults.callouts.virtual.contributor'; +import { spaceDefaultsSettingsRootSpace } from './definitions/root-space/space.defaults.settings.root.space'; +import { spaceDefaultsSettingsOpportunity } from './definitions/oppportunity/space.defaults.settings.opportunity'; +import { spaceDefaultsSettingsChallenge } from './definitions/challenge/space.defaults.settings.challenge'; +import { spaceDefaultsSettingsVirtualContributor } from './definitions/virtual-contributor/space.defaults.settings.virtual.contributor'; +import { spaceDefaultsInnovationFlowStatesChallenge } from './definitions/challenge/space.defaults.innovation.flow.challenge'; +import { spaceDefaultsInnovationFlowStatesOpportunity } from './definitions/oppportunity/space.defaults.innovation.flow.opportunity'; +import { spaceDefaultsInnovationFlowStatesRootSpace } from './definitions/root-space/space.defaults.innovation.flow.root.space'; +import { spaceDefaultsInnovationFlowStatesVirtualContributor } from './definitions/virtual-contributor/space.defaults.innovation.flow.virtual.contributor'; +import { IInnovationFlowState } from '@domain/collaboration/innovation-flow-states/innovation.flow.state.interface'; +import { spaceDefaultsCalloutGroupsBlankSlate } from './definitions/blank-slate/space.defaults.callout.groups.blank.slate'; +import { spaceDefaultsCalloutsBlankSlate } from './definitions/blank-slate/space.defaults.callouts.blank.slate'; +import { spaceDefaultsSettingsBlankSlate } from './definitions/blank-slate/space.defaults.settings.blank.slate'; +import { spaceDefaultsInnovationFlowStatesBlankSlate } from './definitions/blank-slate/space.defaults.innovation.flow.blank.slate'; @Injectable() export class SpaceDefaultsService { @@ -138,10 +152,15 @@ export class SpaceDefaultsService { public getCalloutGroups(spaceType: SpaceType): ICalloutGroup[] { switch (spaceType) { case SpaceType.CHALLENGE: + return spaceDefaultsCalloutGroupsChallenge; case SpaceType.OPPORTUNITY: - return subspaceCalloutGroups; + return spaceDefaultsCalloutGroupsOpportunity; case SpaceType.SPACE: - return spaceCalloutGroups; + return spaceDefaultsCalloutGroupsRootSpace; + case SpaceType.VIRTUAL_CONTRIBUTOR: + return spaceDefaultsCalloutGroupsVirtualContributor; + case SpaceType.BLANK_SLATE: + return spaceDefaultsCalloutGroupsBlankSlate; default: throw new EntityNotInitializedException( `Invalid space type: ${spaceType}`, @@ -153,10 +172,17 @@ export class SpaceDefaultsService { public getCalloutGroupDefault(spaceType: SpaceType): CalloutGroupName { switch (spaceType) { case SpaceType.CHALLENGE: + case SpaceType.VIRTUAL_CONTRIBUTOR: case SpaceType.OPPORTUNITY: + case SpaceType.BLANK_SLATE: return CalloutGroupName.HOME; case SpaceType.SPACE: return CalloutGroupName.KNOWLEDGE; + default: + throw new EntityNotInitializedException( + `Invalid space type: ${spaceType}`, + LogContext.ROLES + ); } } @@ -198,13 +224,23 @@ export class SpaceDefaultsService { } } - public getDefaultCallouts(spaceLevel: SpaceLevel): CreateCalloutInput[] { - switch (spaceLevel) { - case SpaceLevel.CHALLENGE: - case SpaceLevel.OPPORTUNITY: - return subspaceDefaultCallouts; - case SpaceLevel.SPACE: - return spaceDefaultCallouts; + public getDefaultCallouts(spaceType: SpaceType): CreateCalloutInput[] { + switch (spaceType) { + case SpaceType.CHALLENGE: + return spaceDefaultsCalloutsChallenge; + case SpaceType.OPPORTUNITY: + return spaceDefaultsCalloutsOpportunity; + case SpaceType.SPACE: + return spaceDefaultsCalloutsRootSpace; + case SpaceType.VIRTUAL_CONTRIBUTOR: + return spaceDefaultsCalloutsVirtualContributor; + case SpaceType.BLANK_SLATE: + return spaceDefaultsCalloutsBlankSlate; + default: + throw new EntityNotInitializedException( + `Invalid space type: ${spaceType}`, + LogContext.ROLES + ); } } @@ -214,18 +250,51 @@ export class SpaceDefaultsService { return spaceDefaults.innovationFlowTemplate; } - public getDefaultSpaceSettings(spaceLevel: SpaceLevel): ISpaceSettings { - switch (spaceLevel) { - case SpaceLevel.CHALLENGE: - case SpaceLevel.OPPORTUNITY: - return subspaceSettingsDefaults; - case SpaceLevel.SPACE: - return spaceSettingsDefaults; + public getDefaultSpaceSettings(spaceType: SpaceType): ISpaceSettings { + switch (spaceType) { + case SpaceType.CHALLENGE: + return spaceDefaultsSettingsChallenge; + case SpaceType.OPPORTUNITY: + return spaceDefaultsSettingsOpportunity; + case SpaceType.SPACE: + return spaceDefaultsSettingsRootSpace; + case SpaceType.VIRTUAL_CONTRIBUTOR: + return spaceDefaultsSettingsVirtualContributor; + case SpaceType.BLANK_SLATE: + return spaceDefaultsSettingsBlankSlate; + default: + throw new EntityNotInitializedException( + `Invalid space type: ${spaceType}`, + LogContext.ROLES + ); + } + } + + public getDefaultInnovationFlowStates( + spaceType: SpaceType + ): IInnovationFlowState[] { + switch (spaceType) { + case SpaceType.CHALLENGE: + return spaceDefaultsInnovationFlowStatesChallenge; + case SpaceType.OPPORTUNITY: + return spaceDefaultsInnovationFlowStatesOpportunity; + case SpaceType.SPACE: + return spaceDefaultsInnovationFlowStatesRootSpace; + case SpaceType.VIRTUAL_CONTRIBUTOR: + return spaceDefaultsInnovationFlowStatesVirtualContributor; + case SpaceType.BLANK_SLATE: + return spaceDefaultsInnovationFlowStatesBlankSlate; + default: + throw new EntityNotInitializedException( + `Invalid space type: ${spaceType}`, + LogContext.ROLES + ); } } public async getCreateInnovationFlowInput( accountID: string, + spaceType: SpaceType, innovationFlowTemplateID?: string ): Promise { // Start with using the provided argument @@ -249,28 +318,36 @@ export class SpaceDefaultsService { } // If no argument is provided, then use the default template for the space, if set - const spaceDefaults = await this.getDefaultsForAccountOrFail(accountID); - if (spaceDefaults.innovationFlowTemplate) { - const template = - await this.innovationFlowTemplateService.getInnovationFlowTemplateOrFail( - spaceDefaults.innovationFlowTemplate.id, - { - relations: { profile: true }, - } - ); - spaceDefaults.innovationFlowTemplate; - // Note: no profile currently present, so use the one from the template for now - const result: CreateInnovationFlowInput = { - profile: { - displayName: template.profile.displayName, - description: template.profile.description, - }, - states: this.innovationFlowStatesService.getStates(template.states), - }; - return result; + // for spaces of type challenge or opportunity + if ( + spaceType === SpaceType.CHALLENGE || + spaceType === SpaceType.OPPORTUNITY + ) { + const spaceDefaults = await this.getDefaultsForAccountOrFail(accountID); + if (spaceDefaults.innovationFlowTemplate) { + const template = + await this.innovationFlowTemplateService.getInnovationFlowTemplateOrFail( + spaceDefaults.innovationFlowTemplate.id, + { + relations: { profile: true }, + } + ); + spaceDefaults.innovationFlowTemplate; + // Note: no profile currently present, so use the one from the template for now + const result: CreateInnovationFlowInput = { + profile: { + displayName: template.profile.displayName, + description: template.profile.description, + }, + states: this.innovationFlowStatesService.getStates(template.states), + }; + return result; + } } - // If no default template is set, then make up one + // If no default template is set, then pick up the default based on the specified type + const innovationFlowStatesDefault = + this.getDefaultInnovationFlowStates(spaceType); const result: CreateInnovationFlowInput = { profile: { displayName: 'default', diff --git a/src/domain/space/space/dto/space.dto.create.ts b/src/domain/space/space/dto/space.dto.create.ts index cc90bbd1bb..2f51dcc27e 100644 --- a/src/domain/space/space/dto/space.dto.create.ts +++ b/src/domain/space/space/dto/space.dto.create.ts @@ -38,5 +38,7 @@ export class CreateSpaceInput extends CreateNameableInput { level!: number; + @Field(() => SpaceType, { nullable: true }) + @IsOptional() type!: SpaceType; } diff --git a/src/domain/space/space/dto/space.dto.ingest.ts b/src/domain/space/space/dto/space.dto.ingest.ts index ad694cfba2..de1656f97d 100644 --- a/src/domain/space/space/dto/space.dto.ingest.ts +++ b/src/domain/space/space/dto/space.dto.ingest.ts @@ -1,5 +1,6 @@ import { InputType, Field } from '@nestjs/graphql'; import { UUID } from '@domain/common/scalars/scalar.uuid'; +import { SpaceIngestionPurpose } from '@services/infrastructure/event-bus/commands'; @InputType() export class IngestSpaceInput { @@ -8,4 +9,10 @@ export class IngestSpaceInput { description: 'The identifier for the Space to be ingested.', }) spaceID!: string; + + @Field(() => SpaceIngestionPurpose, { + nullable: false, + description: 'The purpose of the ingestions - either knowledge or context.', + }) + purpose!: SpaceIngestionPurpose; } diff --git a/src/domain/space/space/space.resolver.mutations.ts b/src/domain/space/space/space.resolver.mutations.ts index 42728de7d3..d6658a41f8 100644 --- a/src/domain/space/space/space.resolver.mutations.ts +++ b/src/domain/space/space/space.resolver.mutations.ts @@ -165,15 +165,19 @@ export class SpaceResolverMutations { const space = await this.spaceService.getSpaceOrFail(subspaceData.spaceID, { relations: { account: { - license: { - featureFlags: true, + agent: { + credentials: true, }, }, }, }); - if (!space.account || !space.account.license) { + if ( + !space.account || + !space.account.agent || + !space.account.agent.credentials + ) { throw new EntityNotInitializedException( - `Unabl to load license for Space: ${space.id}`, + `Unabl to load agent with credentials for Account for Space: ${space.id}`, LogContext.SPACES ); } diff --git a/src/domain/space/space/space.service.authorization.ts b/src/domain/space/space/space.service.authorization.ts index e2a9a77280..bcec80eacc 100644 --- a/src/domain/space/space/space.service.authorization.ts +++ b/src/domain/space/space/space.service.authorization.ts @@ -11,7 +11,6 @@ import { ISpace } from './space.interface'; import { SpaceVisibility } from '@common/enums/space.visibility'; import { RelationshipNotFoundException } from '@common/exceptions/relationship.not.found.exception'; import { CommunityPolicyService } from '@domain/community/community-policy/community.policy.service'; -import { ILicense } from '@domain/license/license/license.interface'; import { CommunityAuthorizationService } from '@domain/community/community/community.service.authorization'; import { StorageAggregatorAuthorizationService } from '@domain/storage/storage-aggregator/storage.aggregator.service.authorization'; import { ProfileAuthorizationService } from '@domain/common/profile/profile.service.authorization'; @@ -43,6 +42,7 @@ import { ICredentialDefinition } from '@domain/agent/credential/credential.defin import { SpaceSettingsService } from '../space.settings/space.settings.service'; import { SpaceLevel } from '@common/enums/space.level'; import { AgentAuthorizationService } from '@domain/agent/agent/agent.service.authorization'; +import { IAgent } from '@domain/agent/agent/agent.interface'; @Injectable() export class SpaceAuthorizationService { @@ -68,6 +68,9 @@ export class SpaceAuthorizationService { }, account: { license: true, + agent: { + credentials: true, + }, authorization: true, }, parentSpace: { @@ -81,6 +84,8 @@ export class SpaceAuthorizationService { !space.community.policy || !space.account || !space.account.license || + !space.account.agent || + !space.account.agent.credentials || !space.account.authorization ) throw new RelationshipNotFoundException( @@ -94,6 +99,7 @@ export class SpaceAuthorizationService { const communityPolicyWithFlags = this.getCommunityPolicyWithSettings(space); const license = space.account.license; + const accountAgent = space.account.agent; const privateSpace = space.community.policy.settings.privacy.mode === SpacePrivacyMode.PRIVATE; const accountAuthorization = space.account.authorization; @@ -156,7 +162,10 @@ export class SpaceAuthorizationService { // Cascade down // propagate authorization rules for child entities - space = await this.propagateAuthorizationToChildEntities(space, license); + space = await this.propagateAuthorizationToChildEntities( + space, + accountAgent + ); if (!space.community) throw new RelationshipNotFoundException( `Unable to load Community on space after child entities propagation: ${space.id} `, @@ -183,13 +192,10 @@ export class SpaceAuthorizationService { public async propagateAuthorizationToChildEntities( spaceInput: ISpace, - license: ILicense + accountAgent: IAgent ): Promise { const space = await this.spaceService.getSpaceOrFail(spaceInput.id, { relations: { - account: { - license: true, - }, agent: true, collaboration: true, community: { @@ -202,8 +208,6 @@ export class SpaceAuthorizationService { }, }); if ( - !space.account || - !space.account.license || !space.agent || !space.collaboration || !space.community || @@ -232,7 +236,7 @@ export class SpaceAuthorizationService { await this.communityAuthorizationService.applyAuthorizationPolicy( space.community, space.authorization, - license, + accountAgent, communityPolicy ); @@ -253,7 +257,7 @@ export class SpaceAuthorizationService { space.collaboration, space.authorization, communityPolicy, - license + accountAgent ); space.agent = this.agentAuthorizationService.applyAuthorizationPolicy( diff --git a/src/domain/space/space/space.service.spec.ts b/src/domain/space/space/space.service.spec.ts index 2bc1499364..44141dd153 100644 --- a/src/domain/space/space/space.service.spec.ts +++ b/src/domain/space/space/space.service.spec.ts @@ -16,6 +16,7 @@ import { License } from '@domain/license/license/license.entity'; import { Collaboration } from '@domain/collaboration/collaboration/collaboration.entity'; import { Account } from '../account/account.entity'; import { SpaceType } from '@common/enums/space.type'; +import { SpaceLevel } from '@common/enums/space.level'; const moduleMocker = new ModuleMocker(global); @@ -98,7 +99,7 @@ const getSubspacesMock = ( ...getEntityMock(), }, type: SpaceType.CHALLENGE, - level: 1, + level: SpaceLevel.CHALLENGE, collaboration: { id: '', groupsStr: JSON.stringify([ @@ -187,7 +188,7 @@ const getSubsubspacesMock = (subsubspaceId: string, count: number): Space[] => { ...getEntityMock(), }, type: SpaceType.OPPORTUNITY, - level: 2, + level: SpaceLevel.OPPORTUNITY, collaboration: { id: '', groupsStr: JSON.stringify([ @@ -292,7 +293,6 @@ const getSpaceMock = ({ license: { id, visibility, - featureFlags: [], ...getEntityMock(), }, diff --git a/src/domain/space/space/space.service.ts b/src/domain/space/space/space.service.ts index 5766b9c233..0e216b96bc 100644 --- a/src/domain/space/space/space.service.ts +++ b/src/domain/space/space/space.service.ts @@ -85,12 +85,33 @@ export class SpaceService { account: IAccount, agentInfo?: AgentInfo ): Promise { - // Temporary setup that matches 1-1; later the type and level will be separately assigned - spaceData.type = SpaceType.SPACE; - if (spaceData.level === SpaceLevel.CHALLENGE) - spaceData.type = SpaceType.CHALLENGE; - if (spaceData.level === SpaceLevel.OPPORTUNITY) - spaceData.type = SpaceType.OPPORTUNITY; + if (!spaceData.type) { + // default to match the level if not specified + switch (spaceData.level) { + case SpaceLevel.SPACE: + spaceData.type = SpaceType.SPACE; + break; + case SpaceLevel.CHALLENGE: + spaceData.type = SpaceType.CHALLENGE; + break; + case SpaceLevel.OPPORTUNITY: + spaceData.type = SpaceType.OPPORTUNITY; + break; + default: + spaceData.type = SpaceType.CHALLENGE; + break; + } + } + // Hard code / overwrite for now for root space level + if ( + spaceData.level === SpaceLevel.SPACE && + spaceData.type !== SpaceType.SPACE + ) { + throw new NotSupportedException( + `Root space must have a type of SPACE: '${spaceData.type}'`, + LogContext.SPACES + ); + } const space: ISpace = Space.create(spaceData); @@ -106,7 +127,7 @@ export class SpaceService { space.authorization = new AuthorizationPolicy(); space.account = account; space.settingsStr = this.spaceSettingsService.serializeSettings( - this.spaceDefaultsService.getDefaultSpaceSettings(spaceData.level) + this.spaceDefaultsService.getDefaultSpaceSettings(spaceData.type) ); const parentStorageAggregator = spaceData.storageAggregatorParent; @@ -194,7 +215,7 @@ export class SpaceService { spaceData.collaborationData?.collaborationTemplateID ); const defaultCallouts = this.spaceDefaultsService.getDefaultCallouts( - space.level + space.type ); const calloutInputs = await this.spaceDefaultsService.getCreateCalloutInputs( @@ -886,7 +907,7 @@ export class SpaceService { ); } - await this.communityService.assignContributorToRole( + await this.communityService.assignContributorAgentToRole( space.community, contributor.agent, role, diff --git a/src/domain/storage/document/document.entity.ts b/src/domain/storage/document/document.entity.ts index cb83a3a39f..fddf908264 100644 --- a/src/domain/storage/document/document.entity.ts +++ b/src/domain/storage/document/document.entity.ts @@ -30,7 +30,7 @@ export class Document extends AuthorizableEntity implements IDocument { @Column('text', { nullable: true }) displayName = ''; - @Column('varchar', { length: 36, default: '' }) + @Column('varchar', { length: 128, default: '' }) mimeType!: MimeFileType; @Column('int') diff --git a/src/domain/storage/storage-aggregator/dto/storage.aggregator.dto.parent.ts b/src/domain/storage/storage-aggregator/dto/storage.aggregator.dto.parent.ts index 00b285630e..3b526d7fdd 100644 --- a/src/domain/storage/storage-aggregator/dto/storage.aggregator.dto.parent.ts +++ b/src/domain/storage/storage-aggregator/dto/storage.aggregator.dto.parent.ts @@ -1,4 +1,4 @@ -import { SpaceType } from '@common/enums/space.type'; +import { SpaceLevel } from '@common/enums/space.level'; import { UUID } from '@domain/common/scalars'; import { Field, ObjectType } from '@nestjs/graphql'; @@ -22,9 +22,9 @@ export abstract class IStorageAggregatorParent { }) url!: string; - @Field(() => SpaceType, { + @Field(() => SpaceLevel, { nullable: false, - description: 'The Type of the parent Entity.', + description: 'The level of the parent Entity.', }) - type!: string; + level!: number; } diff --git a/src/domain/storage/storage-aggregator/storage.aggregator.service.ts b/src/domain/storage/storage-aggregator/storage.aggregator.service.ts index 6ac87d772d..04b1b0ce18 100644 --- a/src/domain/storage/storage-aggregator/storage.aggregator.service.ts +++ b/src/domain/storage/storage-aggregator/storage.aggregator.service.ts @@ -14,7 +14,7 @@ import { IStorageAggregatorParent } from './dto/storage.aggregator.dto.parent'; import { StorageAggregatorResolverService } from '@services/infrastructure/storage-aggregator-resolver/storage.aggregator.resolver.service'; import { IStorageBucket } from '../storage-bucket/storage.bucket.interface'; import { EntityNotInitializedException } from '@common/exceptions'; -import { SpaceType } from '@common/enums/space.type'; +import { SpaceLevel } from '@common/enums/space.level'; @Injectable() export class StorageAggregatorService { constructor( @@ -217,18 +217,18 @@ export class StorageAggregatorService { ); let url = ''; - switch (journeyInfo.type) { - case SpaceType.OPPORTUNITY: + switch (journeyInfo.level) { + case SpaceLevel.OPPORTUNITY: url = await this.urlGeneratorService.generateUrlForSubsubspace( journeyInfo.id ); break; - case SpaceType.CHALLENGE: + case SpaceLevel.CHALLENGE: url = await this.urlGeneratorService.generateUrlForSubspace( journeyInfo.id ); break; - case SpaceType.SPACE: + case SpaceLevel.SPACE: url = this.urlGeneratorService.generateUrlForSpace(journeyInfo.nameID); break; } diff --git a/src/domain/storage/storage-bucket/storage.bucket.service.ts b/src/domain/storage/storage-bucket/storage.bucket.service.ts index 37ec78719f..677474bef3 100644 --- a/src/domain/storage/storage-bucket/storage.bucket.service.ts +++ b/src/domain/storage/storage-bucket/storage.bucket.service.ts @@ -17,7 +17,10 @@ import { DocumentService } from '../document/document.service'; import { StorageBucket } from './storage.bucket.entity'; import { IStorageBucket } from './storage.bucket.interface'; import { StorageBucketArgsDocuments } from './dto/storage.bucket.args.documents'; -import { MimeFileType } from '@common/enums/mime.file.type'; +import { + DEFAULT_ALLOWED_MIME_TYPES, + MimeFileType, +} from '@common/enums/mime.file.type'; import { CreateDocumentInput } from '../document/dto/document.dto.create'; import { ReadStream } from 'fs'; import { ValidationException } from '@common/exceptions'; @@ -33,18 +36,6 @@ import { StorageUploadFailedException } from '@common/exceptions/storage/storage export class StorageBucketService { DEFAULT_MAX_ALLOWED_FILE_SIZE = 15728640; - DEFAULT_VISUAL_ALLOWED_MIME_TYPES: MimeFileType[] = [ - MimeFileType.JPG, - MimeFileType.JPEG, - MimeFileType.XPNG, - MimeFileType.PNG, - MimeFileType.GIF, - MimeFileType.WEBP, - MimeFileType.SVG, - MimeFileType.AVIF, - MimeFileType.PDF, - ]; - constructor( private documentService: DocumentService, private authorizationPolicyService: AuthorizationPolicyService, @@ -67,8 +58,7 @@ export class StorageBucketService { storage.authorization = new AuthorizationPolicy(); storage.documents = []; storage.allowedMimeTypes = - storageBucketData?.allowedMimeTypes || - this.DEFAULT_VISUAL_ALLOWED_MIME_TYPES; + storageBucketData?.allowedMimeTypes || DEFAULT_ALLOWED_MIME_TYPES; storage.maxFileSize = storageBucketData?.maxFileSize || this.DEFAULT_MAX_ALLOWED_FILE_SIZE; storage.storageAggregator = storageBucketData.storageAggregator; diff --git a/src/library/library/library.resolver.fields.ts b/src/library/library/library.resolver.fields.ts index 2823fad402..b0998c1172 100644 --- a/src/library/library/library.resolver.fields.ts +++ b/src/library/library/library.resolver.fields.ts @@ -10,6 +10,7 @@ import { IInnovationPack } from '@library/innovation-pack/innovation.pack.interf import { LibraryService } from './library.service'; import { IStorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.interface'; import { InnovationPacksInput } from './dto/library.dto.innovationPacks.input'; +import { IVirtualContributor } from '@domain/community/virtual-contributor/virtual.contributor.interface'; @Resolver(() => ILibrary) export class LibraryResolverFields { @@ -65,4 +66,14 @@ export class LibraryResolverFields { queryData?.orderBy ); } + + // TODO: these may want later to be on a Store entity + @UseGuards(GraphqlGuard) + @ResolveField(() => [IVirtualContributor], { + nullable: false, + description: 'The VirtualContributors listed on this platform', + }) + async virtualContributors(): Promise { + return await this.libraryService.getListedVirtualContributors(); + } } diff --git a/src/library/library/library.service.authorization.ts b/src/library/library/library.service.authorization.ts index 0996867dd2..f5510c1272 100644 --- a/src/library/library/library.service.authorization.ts +++ b/src/library/library/library.service.authorization.ts @@ -41,6 +41,8 @@ export class LibraryAuthorizationService { library.authorization, parentAuthorization ); + // For now the library is world visible + library.authorization.anonymousReadAccess = true; // Cascade down const libraryPropagated = await this.propagateAuthorizationToChildEntities( diff --git a/src/library/library/library.service.ts b/src/library/library/library.service.ts index 3ed54c0151..0fe32b1bdd 100644 --- a/src/library/library/library.service.ts +++ b/src/library/library/library.service.ts @@ -5,21 +5,27 @@ import { ValidationException } from '@common/exceptions/validation.exception'; import { IInnovationPack } from '@library/innovation-pack/innovation.pack.interface'; import { InnovationPackService } from '@library/innovation-pack/innovaton.pack.service'; import { Inject, Injectable, LoggerService } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; +import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm'; import { NamingService } from '@services/infrastructure/naming/naming.service'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; -import { FindOneOptions, Repository } from 'typeorm'; +import { EntityManager, FindOneOptions, Repository } from 'typeorm'; import { CreateInnovationPackOnLibraryInput } from './dto/library.dto.create.innovation.pack'; import { Library } from './library.entity'; import { ILibrary } from './library.interface'; import { IStorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.interface'; import { InnovationPacksOrderBy } from '@common/enums/innovation.packs.orderBy'; +import { + IVirtualContributor, + VirtualContributor, +} from '@domain/community/virtual-contributor'; @Injectable() export class LibraryService { constructor( private innovationPackService: InnovationPackService, private namingService: NamingService, + @InjectEntityManager('default') + private entityManager: EntityManager, @InjectRepository(Library) private libraryRepository: Repository, @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService @@ -37,6 +43,22 @@ export class LibraryService { return library; } + public async getListedVirtualContributors(): Promise { + const virtualContributors = await this.entityManager.find( + VirtualContributor, + { + where: { + listedInStore: true, + }, + relations: { + aiPersona: true, + account: true, + }, + } + ); + return virtualContributors; + } + public async getInnovationPacks( library: ILibrary, limit?: number, diff --git a/src/migrations/1717750717135-extendMimeTypes.ts b/src/migrations/1717750717135-extendMimeTypes.ts new file mode 100644 index 0000000000..44e7121438 --- /dev/null +++ b/src/migrations/1717750717135-extendMimeTypes.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +const DEFAULT_ALLOWED_MIME_TYPES = [ + 'application/pdf', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.text', + 'image/bmp', + 'image/jpg', + 'image/jpeg', + 'image/x-png', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'image/avif', +]; + +export class extendMimeTypes1717750717135 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('UPDATE storage_bucket SET allowedMimeTypes = ?', [ + DEFAULT_ALLOWED_MIME_TYPES.join(','), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('UPDATE storage_bucket SET allowedMimeTypes = ?', [ + 'image/jpg,image/jpeg,image/x-png,image/png,image/gif,image/webp,image/svg+xml,image/avif,application/pdf', + ]); + } +} diff --git a/src/migrations/1717751497484-updateDocumentMimeTypeLength.ts b/src/migrations/1717751497484-updateDocumentMimeTypeLength.ts new file mode 100644 index 0000000000..8dc3882661 --- /dev/null +++ b/src/migrations/1717751497484-updateDocumentMimeTypeLength.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class updateDocumentMimeTypeLength1717751497484 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE document MODIFY COLUMN mimeType VARCHAR(128) NULL` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE document MODIFY COLUMN mimeType VARCHAR(36) NULL` + ); + } +} diff --git a/src/migrations/1718174556242-invitationContributor.ts b/src/migrations/1718174556242-invitationContributor.ts new file mode 100644 index 0000000000..300efaab3a --- /dev/null +++ b/src/migrations/1718174556242-invitationContributor.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class invitationContributor1718174556242 implements MigrationInterface { + name = 'invitationContributor1718174556242'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`invitation\` RENAME COLUMN \`invitedUser\` TO \`invitedContributor\`` + ); + await queryRunner.query( + `ALTER TABLE \`invitation\` ADD \`contributorType\` char(36) NOT NULL` + ); + const invitations: { + id: string; + }[] = await queryRunner.query(`SELECT id FROM invitation`); + for (const invitation of invitations) { + await queryRunner.query( + `UPDATE invitation SET contributorType = 'user' WHERE id = '${invitation.id}'` + ); + } + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/src/migrations/1718174556250-discussionPrivacy.ts b/src/migrations/1718174556250-discussionPrivacy.ts new file mode 100644 index 0000000000..5955590e4a --- /dev/null +++ b/src/migrations/1718174556250-discussionPrivacy.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class discussionPrivacy1718174556250 implements MigrationInterface { + name = 'discussionPrivacy1718174556250'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`discussion\` ADD \`privacy\` varchar(255) NOT NULL DEFAULT 'authenticated'` + ); + + const discussions: { + id: string; + }[] = await queryRunner.query(`SELECT id FROM discussion`); + for (const discussion of discussions) { + await queryRunner.query( + `UPDATE discussion SET privacy = 'authenticated' WHERE id = '${discussion.id}'` + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`discussion\` DROP COLUMN \`privacy\`` + ); + } +} diff --git a/src/migrations/1718860939735-aiServerSetup.ts b/src/migrations/1718860939735-aiServerSetup.ts new file mode 100644 index 0000000000..f0ec7dc26a --- /dev/null +++ b/src/migrations/1718860939735-aiServerSetup.ts @@ -0,0 +1,227 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { randomUUID } from 'crypto'; + +export class aiServerSetup1718860939735 implements MigrationInterface { + name = 'aiServerSetup1718860939735'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE IF NOT EXISTS \`ai_persona\` ( + \`id\` char(36) NOT NULL, + \`aiPersonaServiceID\` varchar(128) NOT NULL, + \`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + \`version\` int NOT NULL, + \`description\` text DEFAULT NULL, + \`authorizationId\` char(36) NULL, + \`interactionModes\` text DEFAULT NULL, + \`dataAccessMode\` varchar(64) DEFAULT NULL, + \`bodyOfKnowledgeType\` varchar(64) DEFAULT NULL, + UNIQUE INDEX \`REL_293f0d3ef60cb0ca0006044ecf\` (\`authorizationId\`), + PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS \`ai_server\` ( + \`id\` char(36) NOT NULL, + \`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + \`version\` int NOT NULL, + \`authorizationId\` char(36) NULL, + UNIQUE INDEX \`REL_9d520fa5fed56042918e48fc4b\` (\`authorizationId\`), + PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + + await queryRunner.query(`CREATE TABLE IF NOT EXISTS \`ai_persona_service\` ( + \`id\` char(36) NOT NULL, + \`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + \`version\` int NOT NULL, + \`engine\` varchar(128) NOT NULL, + \`dataAccessMode\` varchar(64) NOT NULL DEFAULT 'space_profile', + \`prompt\` text NOT NULL, + \`bodyOfKnowledgeType\` varchar(64) NULL, + \`bodyOfKnowledgeID\` varchar(255) NULL, + \`authorizationId\` char(36) NULL, + \`aiServerId\` char(36) NULL, + UNIQUE INDEX \`REL_79206feb0038b1c5597668dc4b\` (\`authorizationId\`), + PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + + await queryRunner.query( + `ALTER TABLE \`virtual_contributor\` ADD \`listedInStore\` tinyint NOT NULL` + ); + await queryRunner.query( + `ALTER TABLE \`virtual_contributor\` ADD \`searchVisibility\` varchar(36) NOT NULL DEFAULT 'account'` + ); + await queryRunner.query( + `ALTER TABLE \`virtual_contributor\` ADD \`aiPersonaId\` char(36) NOT NULL` + ); + + // Drop the existing FK constraints related to Virtual Persona + await queryRunner.query( + `ALTER TABLE \`virtual_persona\` DROP FOREIGN KEY \`FK_0e5ff0df260179127b43731bb68\`` + ); + await queryRunner.query( + `ALTER TABLE \`virtual_persona\` DROP FOREIGN KEY \`FK_f5b93c5a204483c3563c7c434a4\` ` + ); + + ////////////////////////////////////// + // Migrate the data + + // The approach being taken is to create a new AI Persona and AI Persona Service for each Virtual Contributor + // This may result in maybe too many AI Persona and AI Persona Service records, but it is the most straight forward way to do it, and also guarantees that we get the account setup right + + // Create the AI Server entity + const aiServerID = randomUUID(); + const aiServerAuthID = randomUUID(); + await queryRunner.query( + `INSERT INTO authorization_policy (id, version, credentialRules, verifiedCredentialRules, anonymousReadAccess, privilegeRules) VALUES + ('${aiServerAuthID}', + 1, '', '', 0, '')` + ); + await queryRunner.query( + `INSERT INTO ai_server (id, version, authorizationId) VALUES + ('${aiServerID}', + 1, + '${aiServerAuthID}')` + ); + + // Loop over all VCs + const virtualContributors: { + id: string; + virtualPersonaId: string; + bodyOfKnowledgeType: string; + bodyOfKnowledgeID: string; + }[] = await queryRunner.query( + `SELECT id, virtualPersonaId, bodyOfKnowledgeType, bodyOfKnowledgeID FROM virtual_contributor` + ); + + let aiPersonaServiceID; + for (const vc of virtualContributors) { + const [virtualPersona]: { + id: string; + engine: string; + prompt: string; + dataAccessMode: string; + }[] = await queryRunner.query( + `SELECT id, engine, prompt, dataAccessMode FROM virtual_persona WHERE id = '${vc.virtualPersonaId}'` + ); + if (!virtualPersona) { + console.log( + `unable to identify virtual persona for virtual contributor ${vc.id}` + ); + continue; + } + + // Create + populate the AI Persona Service + aiPersonaServiceID = randomUUID(); + const aiPersonaServiceAuthID = randomUUID(); + await queryRunner.query( + `INSERT INTO authorization_policy (id, version, credentialRules, verifiedCredentialRules, anonymousReadAccess, privilegeRules) VALUES + ('${aiPersonaServiceAuthID}', + 1, '', '', 0, '')` + ); + + await queryRunner.query( + `INSERT INTO ai_persona_service (\ + id,\ + version,\ + authorizationId,\ + aiServerId,\ + engine,\ + dataAccessMode,\ + prompt,\ + bodyOfKnowledgeType,\ + bodyOfKnowledgeID\ + )\ + VALUES (\ + '${aiPersonaServiceID}',\ + 1,\ + '${aiPersonaServiceAuthID}',\ + '${aiServerID}',\ + '${virtualPersona.engine}',\ + '${virtualPersona.dataAccessMode}',\ + '${virtualPersona.prompt}',\ + '${vc.bodyOfKnowledgeType}',\ + ${vc.bodyOfKnowledgeID ? `'${vc.bodyOfKnowledgeID}'` : 'NULL'}\ + )` + ); + + // Create + populate the AI Persona + const aiPersonaID = randomUUID(); + const aiPersonaAuthID = randomUUID(); + + await queryRunner.query( + `INSERT INTO authorization_policy (id, version, credentialRules, verifiedCredentialRules, anonymousReadAccess, privilegeRules) VALUES + ('${aiPersonaAuthID}', + 1, '', '', 0, '')` + ); + await queryRunner.query( + `INSERT INTO ai_persona (\ + id, \ + version, \ + aiPersonaServiceID, \ + authorizationId,\ + bodyOfKnowledgeType,\ + dataAccessMode,\ + interactionModes,\ + description\ + )\ + VALUES (\ + '${aiPersonaID}',\ + 1,\ + '${aiPersonaServiceID}',\ + '${aiPersonaAuthID}',\ + '${vc.bodyOfKnowledgeType}',\ + '${virtualPersona.dataAccessMode}',\ + '[${['discussion-tagging']}]',\ + ''\ + )` + ); + await queryRunner.query( + `UPDATE virtual_contributor SET aiPersonaId = '${aiPersonaID}' WHERE id = '${vc.id}'` + ); + } + + // update persona indicies after data is populated + await queryRunner.query( + `ALTER TABLE \`virtual_contributor\` ADD UNIQUE INDEX \`IDX_55b8101bdf4f566645e928c26e\` (\`aiPersonaId\`)` + ); + await queryRunner.query( + `CREATE UNIQUE INDEX \`REL_55b8101bdf4f566645e928c26e\` ON \`virtual_contributor\` (\`aiPersonaId\`)` + ); + await queryRunner.query( + `ALTER TABLE \`virtual_contributor\` ADD CONSTRAINT \`FK_55b8101bdf4f566645e928c26e3\` FOREIGN KEY (\`aiPersonaId\`) REFERENCES \`ai_persona\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`ai_persona\` ADD CONSTRAINT \`FK_293f0d3ef60cb0ca0006044ecfd\` FOREIGN KEY (\`authorizationId\`) REFERENCES \`authorization_policy\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`ai_server\` ADD CONSTRAINT \`FK_9d520fa5fed56042918e48fc4b5\` FOREIGN KEY (\`authorizationId\`) REFERENCES \`authorization_policy\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION` + ); + + await queryRunner.query( + `ALTER TABLE \`ai_persona_service\` ADD CONSTRAINT \`FK_79206feb0038b1c5597668dc4b5\` FOREIGN KEY (\`authorizationId\`) REFERENCES \`authorization_policy\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`ai_persona_service\` ADD CONSTRAINT \`FK_b9f20da98058d7bd474152ed6ce\` FOREIGN KEY (\`aiServerId\`) REFERENCES \`ai_server\`(\`id\`) ON DELETE NO ACTION ON UPDATE NO ACTION` + ); + + //////////////////////////////////////// + // Clean up the old structure / data + + // And clean up data as last... + await queryRunner.query( + `ALTER TABLE virtual_contributor DROP CONSTRAINT FK_5c6f158a128406aafb9808b3a82` + ); + + await queryRunner.query( + `ALTER TABLE \`virtual_contributor\` DROP COLUMN \`bodyOfKnowledgeID\`` + ); + await queryRunner.query( + `ALTER TABLE \`virtual_contributor\` DROP COLUMN \`bodyOfKnowledgeType\`` + ); + await queryRunner.query( + `ALTER TABLE \`virtual_contributor\` DROP COLUMN \`virtualPersonaId\`` + ); + await queryRunner.query(`DROP TABLE \`virtual_persona\``); + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/src/migrations/1719032308707-forum.ts b/src/migrations/1719032308707-forum.ts new file mode 100644 index 0000000000..6262cee3da --- /dev/null +++ b/src/migrations/1719032308707-forum.ts @@ -0,0 +1,106 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { randomUUID } from 'crypto'; + +export class forum1719032308707 implements MigrationInterface { + name = 'forum1719032308707'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE \`forum\` ( + \`id\` char(36) NOT NULL, + \`createdDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updatedDate\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + \`version\` int NOT NULL, + \`discussionCategories\` text NOT NULL, + \`authorizationId\` char(36) NULL, + UNIQUE INDEX \`REL_3b0c92945f36d06f37de80285d\` (\`authorizationId\`), + PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + + await queryRunner.query( + `ALTER TABLE \`discussion\` ADD \`forumId\` char(36) NULL` + ); + await queryRunner.query( + `ALTER TABLE \`platform\` ADD \`forumId\` char(36) NULL` + ); + await queryRunner.query( + `ALTER TABLE \`platform\` ADD UNIQUE INDEX \`IDX_dd88d373c64b04e24705d575c9\` (\`forumId\`)` + ); + + await queryRunner.query( + `CREATE UNIQUE INDEX \`REL_dd88d373c64b04e24705d575c9\` ON \`platform\` (\`forumId\`)` + ); + + await queryRunner.query( + `ALTER TABLE \`discussion\` ADD CONSTRAINT \`FK_0de78853c1ee793f61bda7eff79\` FOREIGN KEY (\`forumId\`) REFERENCES \`forum\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`forum\` ADD CONSTRAINT \`FK_3b0c92945f36d06f37de80285dd\` FOREIGN KEY (\`authorizationId\`) REFERENCES \`authorization_policy\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION` + ); + await queryRunner.query( + `ALTER TABLE \`platform\` ADD CONSTRAINT \`FK_dd88d373c64b04e24705d575c99\` FOREIGN KEY (\`forumId\`) REFERENCES \`forum\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION` + ); + + await queryRunner.query( + `ALTER TABLE \`discussion\` DROP FOREIGN KEY \`FK_c6a084fe80d01c41d9f142d51aa\` ` + ); + + // communicationId + await queryRunner.query( + `ALTER TABLE \`platform\` DROP FOREIGN KEY \`FK_55333901817dd09d5906537e088\`` + ); + + await queryRunner.query( + `DROP INDEX \`REL_3eb4c1d5063176a184485399f1\` ON \`platform\`` + ); + + const [platform]: { + id: string; + communicationId: string; + }[] = await queryRunner.query(`SELECT id, communicationId FROM platform`); + if (platform) { + const [communication]: { + id: string; + authorizationId: string; + discussionCategories: string; + }[] = await queryRunner.query( + `SELECT id, authorizationId, discussionCategories FROM communication where id = '${platform.communicationId}'` + ); + if (communication) { + const forumID = randomUUID(); + await queryRunner.query( + `INSERT INTO forum (id, version, discussionCategories, authorizationId) VALUES (?, ?, ?, ?)`, + [ + forumID, + 1, // version + communication.discussionCategories, // discussionCategories + communication.authorizationId, // authorizationId + ] + ); + // Move over all the Discussions + await queryRunner.query( + `UPDATE discussion SET forumId = '${forumID}' WHERE communicationId = '${platform.communicationId}'` + ); + + await queryRunner.query( + `UPDATE platform SET forumId = '${forumID}' WHERE id = '${platform.id}'` + ); + + // delete the communication + await queryRunner.query( + `DELETE FROM communication WHERE id = '${platform.communicationId}'` + ); + } + } + + await queryRunner.query( + `ALTER TABLE \`communication\` DROP COLUMN \`discussionCategories\`` + ); + await queryRunner.query( + `ALTER TABLE \`discussion\` DROP COLUMN \`communicationId\`` + ); + await queryRunner.query( + `ALTER TABLE \`platform\` DROP COLUMN \`communicationId\`` + ); + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/src/migrations/1719038314268-featureFlagsCredentials.ts b/src/migrations/1719038314268-featureFlagsCredentials.ts new file mode 100644 index 0000000000..fabcad0581 --- /dev/null +++ b/src/migrations/1719038314268-featureFlagsCredentials.ts @@ -0,0 +1,241 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { randomUUID } from 'crypto'; + +export class featureFlagsCredentials1719038314268 + implements MigrationInterface +{ + name = 'featureFlagsCredentials1719038314268'; + + public async up(queryRunner: QueryRunner): Promise { + // Add the new flags on license_plan + await queryRunner.query( + `ALTER TABLE \`license_plan\` ADD \`assignToNewOrganizationAccounts\` tinyint NOT NULL DEFAULT '0'` + ); + await queryRunner.query( + `ALTER TABLE \`license_plan\` ADD \`assignToNewUserAccounts\` tinyint NOT NULL DEFAULT '0'` + ); + + // Update existing plans to have new flags + const existingPlans: { + id: string; + name: string; + }[] = await queryRunner.query(`SELECT id, name FROM \`license_plan\``); + for (const existingPlan of existingPlans) { + let assignToNewOrganizationAccounts = false; + let assignToNewUserAccounts = false; + if (existingPlan.name === 'FREE') { + assignToNewOrganizationAccounts = true; + assignToNewUserAccounts = true; + } + await queryRunner.query( + `UPDATE \`license_plan\` + SET \`assignToNewOrganizationAccounts\` = ?, + \`assignToNewUserAccounts\` = ? + WHERE id = ?; + `, + [ + assignToNewOrganizationAccounts, + assignToNewUserAccounts, + existingPlan.id, + ] + ); + } + + // Create a new plan for each of the planDefinitions + const [platform]: { + id: string; + licensingId: string; + }[] = await queryRunner.query(`SELECT id, licensingId FROM platform`); + + for (const plan of plans) { + const planID = randomUUID(); + await queryRunner.query( + `INSERT INTO \`license_plan\` + ( \`id\`, + \`version\`, + \`name\`, + \`enabled\`, + \`licensingId\`, + \`sortOrder\`, + \`pricePerMonth\`, + \`isFree\`, + \`trialEnabled\`, + \`requiresPaymentMethod\`, + \`requiresContactSupport\`, + \`licenseCredential\`, + \`assignToNewOrganizationAccounts\`, + \`assignToNewUserAccounts\`) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + `, + [ + planID, // id + 1, // version + plan.name, // name + plan.enabled, // enabled + platform.licensingId, // licensingId + plan.sortOrder, // sortOrder + plan.pricePerMonth, // pricePerMonth + plan.isFree, // isFree + plan.trialEnabled, // trialEnabled + plan.requiresPaymentMethod, // requiresPaymentMethod + plan.requiresContactSupport, // requiresContactSupport + plan.credential, + plan.assignToNewOrganizationAccounts, + plan.assignToNewUserAccounts, + ] + ); + } + + const accounts: { + id: string; + agentId: string; + licenseId: string; + }[] = await queryRunner.query( + `SELECT \`id\`, agentId, licenseId FROM \`account\`` + ); + for (const account of accounts) { + const featureFlags: { + id: string; + name: string; + enabled: string; + }[] = await queryRunner.query( + `SELECT id, name, enabled FROM feature_flag where licenseId = '${account.id}'` + ); + for (const flag of featureFlags) { + // Assign a credential to the agent under account if enabled + if (flag.enabled) { + const plan = plans.find(p => p.name === flag.name); + if (plan) { + const credentialID = randomUUID(); + let credentialType: string = ''; + switch (flag.name) { + case LicenseCredential.FEATURE_WHITEBOARD_MULTI_USER: + credentialType = + LicenseCredential.FEATURE_WHITEBOARD_MULTI_USER; + break; + case LicenseCredential.FEATURE_CALLOUT_TO_CALLOUT_TEMPLATE: + credentialType = + LicenseCredential.FEATURE_CALLOUT_TO_CALLOUT_TEMPLATE; + break; + case LicenseCredential.FEATURE_VIRTUAL_CONTRIBUTORS: + credentialType = LicenseCredential.FEATURE_VIRTUAL_CONTRIBUTORS; + break; + } + + await queryRunner.query( + `INSERT INTO \`credential\` + ( \`id\`, \`version\`, \`agentId\`, \`type\`, \`resourceID\`) + VALUES + (?, ?, ?, ?, ?); + `, + [ + `${credentialID}`, + 1, // version + account.agentId, + credentialType, + account.id, + ] + ); + } + } + } + } + + // Update the license policy to include the new credential rules + await queryRunner.query( + `ALTER TABLE \`license_policy\` ADD \`credentialRulesStr\` text NOT NULL` + ); + const [license_policy]: { + id: string; + }[] = await queryRunner.query(`SELECT id FROM license_policy`); + await queryRunner.query( + `UPDATE license_policy SET credentialRulesStr = '${JSON.stringify( + licenseCredentialRules + )}' WHERE id = '${license_policy.id}'` + ); + await queryRunner.query( + `ALTER TABLE \`license_policy\` DROP COLUMN \`featureFlagRules\` ` + ); + } + + public async down(queryRunner: QueryRunner): Promise {} +} + +export enum LicenseCredential { + FEATURE_WHITEBOARD_MULTI_USER = 'feature-whiteboard-multi-user', + FEATURE_CALLOUT_TO_CALLOUT_TEMPLATE = 'feature-callout-to-callout-template', + FEATURE_VIRTUAL_CONTRIBUTORS = 'feature-virtual-contributors', +} + +const plans = [ + { + name: 'FEATURE_WHITEBOARD_MULTI_USER', + credential: LicenseCredential.FEATURE_WHITEBOARD_MULTI_USER, + enabled: true, + sortOrder: 50, + pricePerMonth: 0, + isFree: true, + trialEnabled: false, + requiresPaymentMethod: false, + requiresContactSupport: true, + assignToNewOrganizationAccounts: false, + assignToNewUserAccounts: false, + }, + { + name: 'FEATURE_CALLOUT_TO_CALLOUT_TEMPLATE', + credential: LicenseCredential.FEATURE_CALLOUT_TO_CALLOUT_TEMPLATE, + enabled: true, + sortOrder: 60, + pricePerMonth: 0, + isFree: true, + trialEnabled: false, + requiresPaymentMethod: false, + requiresContactSupport: true, + assignToNewOrganizationAccounts: true, + assignToNewUserAccounts: true, + }, + { + name: 'FEATURE_VIRTUAL_CONTRIBUTORS', + credential: LicenseCredential.FEATURE_VIRTUAL_CONTRIBUTORS, + enabled: true, + sortOrder: 70, + pricePerMonth: 0, + isFree: true, + trialEnabled: false, + requiresPaymentMethod: false, + requiresContactSupport: true, + assignToNewOrganizationAccounts: false, + assignToNewUserAccounts: true, + }, +]; + +export type CredentialRule = { + credentialType: LicenseCredential; + grantedPrivileges: LicensePrivilege[]; + name: string; +}; + +export enum LicensePrivilege { + VIRTUAL_CONTRIBUTOR_ACCESS = 'virtual-contributor-access', + WHITEBOARD_MULTI_USER = 'whiteboard-multi-user', + CALLOUT_SAVE_AS_TEMPLATE = 'callout-save-as-template', +} + +export const licenseCredentialRules: CredentialRule[] = [ + { + credentialType: LicenseCredential.FEATURE_VIRTUAL_CONTRIBUTORS, + grantedPrivileges: [LicensePrivilege.VIRTUAL_CONTRIBUTOR_ACCESS], + name: 'Virtual Contributors', + }, + { + credentialType: LicenseCredential.FEATURE_WHITEBOARD_MULTI_USER, + grantedPrivileges: [LicensePrivilege.WHITEBOARD_MULTI_USER], + name: 'Multi-user whiteboards', + }, + { + credentialType: LicenseCredential.FEATURE_CALLOUT_TO_CALLOUT_TEMPLATE, + grantedPrivileges: [LicensePrivilege.CALLOUT_SAVE_AS_TEMPLATE], + name: 'Callout templates', + }, +]; diff --git a/src/migrations/1719225622768-licensePlanType.ts b/src/migrations/1719225622768-licensePlanType.ts new file mode 100644 index 0000000000..8d13fb4e27 --- /dev/null +++ b/src/migrations/1719225622768-licensePlanType.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class licensePlanType1719225622768 implements MigrationInterface { + name = 'licensePlanType1719225622768'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`license_plan\` ADD \`type\` varchar(255) NULL` + ); + + const licensePlans: { + id: string; + name: string; + }[] = await queryRunner.query(`SELECT id, name FROM license_plan`); + + for (const licensePlan of licensePlans) { + const type = licensePlan.name.toLowerCase().startsWith('feature_') + ? LicensePlanType.SPACE_FEATURE_FLAG + : LicensePlanType.SPACE_PLAN; + await queryRunner.query( + `UPDATE license_plan SET type = '${type}' WHERE id = '${licensePlan.id}'` + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`license_plan\` DROP COLUMN \`type\`` + ); + } +} + +export enum LicensePlanType { + SPACE_PLAN = 'space-plan', + SPACE_FEATURE_FLAG = 'space-feature-flag', +} diff --git a/src/platform/admin/authorization/admin.authorization.resolver.mutations.ts b/src/platform/admin/authorization/admin.authorization.resolver.mutations.ts index e507f4e419..d1adc61a69 100644 --- a/src/platform/admin/authorization/admin.authorization.resolver.mutations.ts +++ b/src/platform/admin/authorization/admin.authorization.resolver.mutations.ts @@ -156,6 +156,7 @@ export class AdminAuthorizationResolverMutations { ): Promise { const platformPolicy = await this.platformAuthorizationPolicyService.getPlatformAuthorizationPolicy(); + this.authorizationService.grantAccessOrFail( agentInfo, platformPolicy, diff --git a/src/platform/admin/communication/admin.communication.module.ts b/src/platform/admin/communication/admin.communication.module.ts index dd333eb927..b8b978d6cf 100644 --- a/src/platform/admin/communication/admin.communication.module.ts +++ b/src/platform/admin/communication/admin.communication.module.ts @@ -7,7 +7,6 @@ import { AdminCommunicationResolverMutations } from './admin.communication.resol import { AdminCommunicationResolverQueries } from './admin.communication.resolver.queries'; import { CommunicationModule } from '@domain/communication/communication/communication.module'; import { CommunityModule } from '@domain/community/community/community.module'; -import { DiscussionModule } from '@domain/communication/discussion/discussion.module'; @Module({ imports: [ @@ -16,7 +15,6 @@ import { DiscussionModule } from '@domain/communication/discussion/discussion.mo CommunityModule, CommunicationModule, CommunicationAdapterModule, - DiscussionModule, ], providers: [ AdminCommunicationService, diff --git a/src/platform/admin/communication/admin.communication.service.ts b/src/platform/admin/communication/admin.communication.service.ts index 2b6d87fc9e..7bdc402fed 100644 --- a/src/platform/admin/communication/admin.communication.service.ts +++ b/src/platform/admin/communication/admin.communication.service.ts @@ -14,7 +14,6 @@ import { CommunicationAdminRoomResult } from './dto/admin.communication.dto.orph import { CommunicationAdminRemoveOrphanedRoomInput } from './dto/admin.communication.dto.remove.orphaned.room'; import { ValidationException } from '@common/exceptions'; import { CommunityRole } from '@common/enums/community.role'; -import { DiscussionService } from '@domain/communication/discussion/discussion.service'; import { IRoom } from '@domain/communication/room/room.interface'; @Injectable() @@ -22,7 +21,6 @@ export class AdminCommunicationService { constructor( private communicationAdapter: CommunicationAdapter, private communicationService: CommunicationService, - private discussionService: DiscussionService, private communityService: CommunityService, @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService ) {} @@ -57,17 +55,6 @@ export class AdminCommunicationService { ); result.rooms.push(updatesResult); - const discussions = await this.communicationService.getDiscussions( - communication - ); - for (const discussion of discussions) { - const comments = await this.discussionService.getComments(discussion.id); - const discussionResult = await this.createCommunicationAdminRoomResult( - comments, - communityMembers - ); - result.rooms.push(discussionResult); - } return result; } diff --git a/src/domain/communication/discussion/discussion.entity.ts b/src/platform/forum-discussion/discussion.entity.ts similarity index 59% rename from src/domain/communication/discussion/discussion.entity.ts rename to src/platform/forum-discussion/discussion.entity.ts index 1629fe2378..4835eba2f4 100644 --- a/src/domain/communication/discussion/discussion.entity.ts +++ b/src/platform/forum-discussion/discussion.entity.ts @@ -1,8 +1,9 @@ import { Column, Entity, JoinColumn, ManyToOne, OneToOne } from 'typeorm'; import { IDiscussion } from './discussion.interface'; -import { Communication } from '../communication/communication.entity'; -import { Room } from '../room/room.entity'; +import { Room } from '../../domain/communication/room/room.entity'; import { NameableEntity } from '@domain/common/entity/nameable-entity/nameable.entity'; +import { Forum } from '@platform/forum/forum.entity'; +import { ForumDiscussionPrivacy } from '@common/enums/forum.discussion.privacy'; @Entity() export class Discussion extends NameableEntity implements IDiscussion { @@ -20,10 +21,17 @@ export class Discussion extends NameableEntity implements IDiscussion { @Column('char', { length: 36, nullable: true }) createdBy!: string; - @ManyToOne(() => Communication, communication => communication.discussions, { + @ManyToOne(() => Forum, communication => communication.discussions, { eager: false, cascade: false, onDelete: 'CASCADE', }) - communication?: Communication; + forum?: Forum; + + @Column('varchar', { + length: 255, + nullable: false, + default: ForumDiscussionPrivacy.AUTHENTICATED, + }) + privacy!: string; } diff --git a/src/platform/forum-discussion/discussion.interface.ts b/src/platform/forum-discussion/discussion.interface.ts new file mode 100644 index 0000000000..211f04eb72 --- /dev/null +++ b/src/platform/forum-discussion/discussion.interface.ts @@ -0,0 +1,23 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { IRoom } from '../../domain/communication/room/room.interface'; +import { INameable } from '@domain/common/entity/nameable-entity'; +import { ForumDiscussionPrivacy } from '@common/enums/forum.discussion.privacy'; +import { ForumDiscussionCategory } from '@common/enums/forum.discussion.category'; + +@ObjectType('Discussion') +export abstract class IDiscussion extends INameable { + @Field(() => ForumDiscussionCategory, { + description: 'The category assigned to this Discussion.', + }) + category!: string; + + createdBy!: string; + + comments!: IRoom; + + @Field(() => ForumDiscussionPrivacy, { + description: + 'Privacy mode for the Discussion. Note: this is not yet implemented in the authorization policy.', + }) + privacy!: string; +} diff --git a/src/domain/communication/discussion/discussion.module.ts b/src/platform/forum-discussion/discussion.module.ts similarity index 95% rename from src/domain/communication/discussion/discussion.module.ts rename to src/platform/forum-discussion/discussion.module.ts index 9b7a8e3d44..b284482fa1 100644 --- a/src/domain/communication/discussion/discussion.module.ts +++ b/src/platform/forum-discussion/discussion.module.ts @@ -4,7 +4,7 @@ import { ProfileModule } from '@domain/common/profile/profile.module'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { CommunicationAdapterModule } from '@services/adapters/communication-adapter/communication-adapter.module'; -import { RoomModule } from '../room/room.module'; +import { RoomModule } from '../../domain/communication/room/room.module'; import { Discussion } from './discussion.entity'; import { DiscussionResolverFields } from './discussion.resolver.fields'; import { DiscussionResolverMutations } from './discussion.resolver.mutations'; diff --git a/src/domain/communication/discussion/discussion.resolver.fields.ts b/src/platform/forum-discussion/discussion.resolver.fields.ts similarity index 97% rename from src/domain/communication/discussion/discussion.resolver.fields.ts rename to src/platform/forum-discussion/discussion.resolver.fields.ts index 3832ff8d21..33deb3584b 100644 --- a/src/domain/communication/discussion/discussion.resolver.fields.ts +++ b/src/platform/forum-discussion/discussion.resolver.fields.ts @@ -11,7 +11,7 @@ import { Loader } from '@core/dataloader/decorators'; import { IProfile } from '@domain/common/profile/profile.interface'; import { ILoader } from '@core/dataloader/loader.interface'; import { ProfileLoaderCreator } from '@core/dataloader/creators'; -import { IRoom } from '../room/room.interface'; +import { IRoom } from '../../domain/communication/room/room.interface'; import { DiscussionService } from './discussion.service'; @Resolver(() => IDiscussion) diff --git a/src/domain/communication/discussion/discussion.resolver.mutations.spec.ts b/src/platform/forum-discussion/discussion.resolver.mutations.spec.ts similarity index 100% rename from src/domain/communication/discussion/discussion.resolver.mutations.spec.ts rename to src/platform/forum-discussion/discussion.resolver.mutations.spec.ts diff --git a/src/domain/communication/discussion/discussion.resolver.mutations.ts b/src/platform/forum-discussion/discussion.resolver.mutations.ts similarity index 100% rename from src/domain/communication/discussion/discussion.resolver.mutations.ts rename to src/platform/forum-discussion/discussion.resolver.mutations.ts diff --git a/src/domain/communication/discussion/discussion.service.authorization.ts b/src/platform/forum-discussion/discussion.service.authorization.ts similarity index 70% rename from src/domain/communication/discussion/discussion.service.authorization.ts rename to src/platform/forum-discussion/discussion.service.authorization.ts index 9c13babad5..c8c4bbbcad 100644 --- a/src/domain/communication/discussion/discussion.service.authorization.ts +++ b/src/platform/forum-discussion/discussion.service.authorization.ts @@ -5,10 +5,13 @@ import { IDiscussion } from './discussion.interface'; import { DiscussionService } from './discussion.service'; import { IAuthorizationPolicyRuleCredential } from '@core/authorization/authorization.policy.rule.credential.interface'; import { ProfileAuthorizationService } from '@domain/common/profile/profile.service.authorization'; -import { RoomAuthorizationService } from '../room/room.service.authorization'; +import { RoomAuthorizationService } from '../../domain/communication/room/room.service.authorization'; import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; import { AuthorizationCredential } from '@common/enums/authorization.credential'; import { CREDENTIAL_RULE_TYPES_UPDATE_FORUM_DISCUSSION } from '@common/constants/authorization/credential.rule.types.constants'; +import { RelationshipNotFoundException } from '@common/exceptions/relationship.not.found.exception'; +import { LogContext } from '@common/enums/logging.context'; +import { ForumDiscussionPrivacy } from '@common/enums/forum.discussion.privacy'; @Injectable() export class DiscussionAuthorizationService { @@ -26,9 +29,24 @@ export class DiscussionAuthorizationService { ) {} async applyAuthorizationPolicy( - discussion: IDiscussion, + discussionInput: IDiscussion, parentAuthorization: IAuthorizationPolicy | undefined ): Promise { + const discussion = await this.discussionService.getDiscussionOrFail( + discussionInput.id, + { + relations: { + profile: true, + comments: true, + }, + } + ); + if (!discussion.profile || !discussion.comments) { + throw new RelationshipNotFoundException( + `Unable to load entities to reset auth for Discussion ${discussion.id} `, + LogContext.COMMUNICATION + ); + } discussion.authorization = this.authorizationPolicyService.inheritParentAuthorization( discussion.authorization, @@ -38,21 +56,33 @@ export class DiscussionAuthorizationService { discussion.authorization = this.extendAuthorizationPolicy( discussion.authorization ); + // Clone the authorization policy so can control what children get what setting + const clonedAuthorization = + this.authorizationPolicyService.cloneAuthorizationPolicy( + discussion.authorization + ); + switch (discussion.privacy) { + case ForumDiscussionPrivacy.PUBLIC: + // To ensure that the discussion + discussion profile is visible for non-authenticated users + discussion.authorization.anonymousReadAccess = true; + break; + case ForumDiscussionPrivacy.AUTHENTICATED: + break; + case ForumDiscussionPrivacy.AUTHOR: + // This actually requires a NOT in the authorization framework; for later + break; + } - discussion.profile = await this.discussionService.getProfile(discussion); discussion.profile = await this.profileAuthorizationService.applyAuthorizationPolicy( discussion.profile, discussion.authorization ); - discussion.comments = await this.discussionService.getComments( - discussion.id - ); discussion.comments = this.roomAuthorizationService.applyAuthorizationPolicy( discussion.comments, - discussion.authorization + clonedAuthorization ); discussion.comments.authorization = this.roomAuthorizationService.allowContributorsToCreateMessages( diff --git a/src/domain/communication/discussion/discussion.service.ts b/src/platform/forum-discussion/discussion.service.ts similarity index 93% rename from src/domain/communication/discussion/discussion.service.ts rename to src/platform/forum-discussion/discussion.service.ts index bd170aeb1a..0c0116596c 100644 --- a/src/domain/communication/discussion/discussion.service.ts +++ b/src/platform/forum-discussion/discussion.service.ts @@ -8,16 +8,16 @@ import { Discussion } from './discussion.entity'; import { IDiscussion } from './discussion.interface'; import { UpdateDiscussionInput } from './dto/discussion.dto.update'; import { DeleteDiscussionInput } from './dto/discussion.dto.delete'; -import { RoomService } from '../room/room.service'; -import { CommunicationCreateDiscussionInput } from '../communication/dto/communication.dto.create.discussion'; +import { RoomService } from '../../domain/communication/room/room.service'; import { AuthorizationPolicy } from '@domain/common/authorization-policy/authorization.policy.entity'; import { ProfileService } from '@domain/common/profile/profile.service'; import { UUID_LENGTH } from '@common/constants/entity.field.length.constants'; -import { IRoom } from '../room/room.interface'; +import { IRoom } from '../../domain/communication/room/room.interface'; import { RoomType } from '@common/enums/room.type'; import { IProfile } from '@domain/common/profile/profile.interface'; import { TagsetReservedName } from '@common/enums/tagset.reserved.name'; import { IStorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.interface'; +import { ForumCreateDiscussionInput } from '@platform/forum/dto/forum.dto.create.discussion'; @Injectable() export class DiscussionService { @@ -30,7 +30,7 @@ export class DiscussionService { ) {} async createDiscussion( - discussionData: CommunicationCreateDiscussionInput, + discussionData: ForumCreateDiscussionInput, userID: string, communicationDisplayName: string, roomType: RoomType, @@ -177,17 +177,17 @@ export class DiscussionService { return room; } - async isDiscussionInCommunication( + async isDiscussionInForum( discussionID: string, - communicationID: string + forumID: string ): Promise { const discussion = await this.discussionRepository .createQueryBuilder('discussion') .where('discussion.id = :discussionID') - .andWhere('discussion.communicationId = :communicationID') + .andWhere('discussion.forumId = :forumID') .setParameters({ discussionID: `${discussionID}`, - communicationID: `${communicationID}`, + forumID: `${forumID}`, }) .getOne(); if (discussion) return true; diff --git a/src/domain/communication/discussion/dto/discussion.dto.delete.ts b/src/platform/forum-discussion/dto/discussion.dto.delete.ts similarity index 100% rename from src/domain/communication/discussion/dto/discussion.dto.delete.ts rename to src/platform/forum-discussion/dto/discussion.dto.delete.ts diff --git a/src/domain/communication/discussion/dto/discussion.dto.update.ts b/src/platform/forum-discussion/dto/discussion.dto.update.ts similarity index 72% rename from src/domain/communication/discussion/dto/discussion.dto.update.ts rename to src/platform/forum-discussion/dto/discussion.dto.update.ts index 6cd8459c58..a09af8dbe1 100644 --- a/src/domain/communication/discussion/dto/discussion.dto.update.ts +++ b/src/platform/forum-discussion/dto/discussion.dto.update.ts @@ -1,10 +1,10 @@ -import { DiscussionCategory } from '@common/enums/communication.discussion.category'; +import { ForumDiscussionCategory } from '@common/enums/forum.discussion.category'; import { UpdateNameableInput } from '@domain/common/entity/nameable-entity/dto/nameable.dto.update'; import { InputType, Field } from '@nestjs/graphql'; @InputType() export class UpdateDiscussionInput extends UpdateNameableInput { - @Field(() => DiscussionCategory, { + @Field(() => ForumDiscussionCategory, { nullable: true, description: 'The category for the Discussion', }) diff --git a/src/domain/communication/communication/dto/communication.dto.create.discussion.ts b/src/platform/forum/dto/forum.dto.create.discussion.ts similarity index 71% rename from src/domain/communication/communication/dto/communication.dto.create.discussion.ts rename to src/platform/forum/dto/forum.dto.create.discussion.ts index 8d8a8c2bb0..638a69ccf2 100644 --- a/src/domain/communication/communication/dto/communication.dto.create.discussion.ts +++ b/src/platform/forum/dto/forum.dto.create.discussion.ts @@ -1,26 +1,25 @@ -import { DiscussionCategory } from '@common/enums/communication.discussion.category'; +import { ForumDiscussionCategory } from '@common/enums/forum.discussion.category'; import { CreateProfileInput } from '@domain/common/profile/dto/profile.dto.create'; import { UUID } from '@domain/common/scalars'; import { Field, InputType } from '@nestjs/graphql'; import { Type } from 'class-transformer'; - import { IsOptional, ValidateNested } from 'class-validator'; @InputType() -export class CommunicationCreateDiscussionInput { +export class ForumCreateDiscussionInput { @Field(() => UUID, { nullable: false, description: - 'The identifier for the Communication entity the Discussion is being created on.', + 'The identifier for the Forum entity the Discussion is being created on.', }) - communicationID!: string; + forumID!: string; @Field(() => CreateProfileInput, { nullable: false }) @ValidateNested({ each: true }) @Type(() => CreateProfileInput) profile!: CreateProfileInput; - @Field(() => DiscussionCategory, { + @Field(() => ForumDiscussionCategory, { nullable: false, description: 'The category for the Discussion', }) diff --git a/src/domain/communication/communication/dto/communication.dto.discussions.input.ts b/src/platform/forum/dto/forum.dto.discussions.input.ts similarity index 100% rename from src/domain/communication/communication/dto/communication.dto.discussions.input.ts rename to src/platform/forum/dto/forum.dto.discussions.input.ts diff --git a/src/domain/communication/communication/dto/communication.dto.event.discussion.updated.ts b/src/platform/forum/dto/forum.dto.event.discussion.updated.ts similarity index 73% rename from src/domain/communication/communication/dto/communication.dto.event.discussion.updated.ts rename to src/platform/forum/dto/forum.dto.event.discussion.updated.ts index 8ebfc9daad..9c9556fd76 100644 --- a/src/domain/communication/communication/dto/communication.dto.event.discussion.updated.ts +++ b/src/platform/forum/dto/forum.dto.event.discussion.updated.ts @@ -1,7 +1,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; -@ObjectType('CommunicationDiscussionUpdated') -export class CommunicationDiscussionUpdated { +@ObjectType('ForumDiscussionUpdated') +export class ForumDiscussionUpdated { // To identify the event eventID!: string; diff --git a/src/platform/forum/dto/forum.dto.event.message.received.ts b/src/platform/forum/dto/forum.dto.event.message.received.ts new file mode 100644 index 0000000000..7b371add03 --- /dev/null +++ b/src/platform/forum/dto/forum.dto.event.message.received.ts @@ -0,0 +1,13 @@ +import { IMessage } from '@domain/communication/message/message.interface'; + +export class ForumEventMessageReceived { + roomId!: string; + + roomName!: string; + + message!: IMessage; + + forumID!: string; + + communityId!: string | undefined; +} diff --git a/src/platform/forum/dto/forum.dto.send.message.community.leads.ts b/src/platform/forum/dto/forum.dto.send.message.community.leads.ts new file mode 100644 index 0000000000..0f46ac3212 --- /dev/null +++ b/src/platform/forum/dto/forum.dto.send.message.community.leads.ts @@ -0,0 +1,21 @@ +import { LONG_TEXT_LENGTH } from '@common/constants'; +import { UUID } from '@domain/common/scalars'; +import { Field, InputType } from '@nestjs/graphql'; + +import { MaxLength } from 'class-validator'; + +@InputType() +export class ForumSendMessageToCommunityLeadsInput { + @Field(() => UUID, { + nullable: false, + description: 'The Community the message is being sent to', + }) + communityId!: string; + + @Field(() => String, { + nullable: false, + description: 'The message being sent', + }) + @MaxLength(LONG_TEXT_LENGTH) + message!: string; +} diff --git a/src/platform/forum/forum.entity.ts b/src/platform/forum/forum.entity.ts new file mode 100644 index 0000000000..97b6c25f3c --- /dev/null +++ b/src/platform/forum/forum.entity.ts @@ -0,0 +1,21 @@ +import { Column, Entity, OneToMany } from 'typeorm'; +import { AuthorizableEntity } from '@domain/common/entity/authorizable-entity/authorizable.entity'; +import { Discussion } from '@platform/forum-discussion/discussion.entity'; +import { IForum } from './forum.interface'; + +@Entity() +export class Forum extends AuthorizableEntity implements IForum { + @OneToMany(() => Discussion, discussion => discussion.forum, { + eager: false, + cascade: true, + }) + discussions?: Discussion[]; + + @Column('simple-array') + discussionCategories: string[]; + + constructor() { + super(); + this.discussionCategories = []; + } +} diff --git a/src/platform/forum/forum.interface.ts b/src/platform/forum/forum.interface.ts new file mode 100644 index 0000000000..9a569849e3 --- /dev/null +++ b/src/platform/forum/forum.interface.ts @@ -0,0 +1,12 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { IAuthorizable } from '@domain/common/entity/authorizable-entity'; +import { IDiscussion } from '../forum-discussion/discussion.interface'; +import { ForumDiscussionCategory } from '@common/enums/forum.discussion.category'; + +@ObjectType('Forum') +export abstract class IForum extends IAuthorizable { + discussions?: IDiscussion[]; + + @Field(() => [ForumDiscussionCategory]) + discussionCategories!: string[]; +} diff --git a/src/platform/forum/forum.module.ts b/src/platform/forum/forum.module.ts new file mode 100644 index 0000000000..8adffc1ab3 --- /dev/null +++ b/src/platform/forum/forum.module.ts @@ -0,0 +1,42 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthorizationModule } from '@core/authorization/authorization.module'; +import { Forum } from './forum.entity'; +import { ForumResolverFields } from './forum.resolver.fields'; +import { ForumResolverMutations } from './forum.resolver.mutations'; +import { ForumService } from './forum.service'; +import { AuthorizationPolicyModule } from '@domain/common/authorization-policy/authorization.policy.module'; +import { ForumAuthorizationService } from './forum.service.authorization'; +import { DiscussionModule } from '../forum-discussion/discussion.module'; +import { ForumResolverSubscriptions } from './forum.resolver.subscriptions'; +import { NotificationAdapterModule } from '@services/adapters/notification-adapter/notification.adapter.module'; +import { EntityResolverModule } from '@services/infrastructure/entity-resolver/entity.resolver.module'; +import { PlatformAuthorizationPolicyModule } from '@platform/authorization/platform.authorization.policy.module'; +import { NamingModule } from '@services/infrastructure/naming/naming.module'; +import { StorageAggregatorResolverModule } from '@services/infrastructure/storage-aggregator-resolver/storage.aggregator.resolver.module'; +import { CommunicationAdapterModule } from '@services/adapters/communication-adapter/communication-adapter.module'; + +@Module({ + imports: [ + AuthorizationModule, + NotificationAdapterModule, + AuthorizationPolicyModule, + DiscussionModule, + EntityResolverModule, + NamingModule, + PlatformAuthorizationPolicyModule, + StorageAggregatorResolverModule, + CommunicationAdapterModule, + NamingModule, + TypeOrmModule.forFeature([Forum]), + ], + providers: [ + ForumService, + ForumResolverMutations, + ForumResolverFields, + ForumResolverSubscriptions, + ForumAuthorizationService, + ], + exports: [ForumService, ForumAuthorizationService], +}) +export class ForumModule {} diff --git a/src/platform/forum/forum.resolver.fields.ts b/src/platform/forum/forum.resolver.fields.ts new file mode 100644 index 0000000000..4783507157 --- /dev/null +++ b/src/platform/forum/forum.resolver.fields.ts @@ -0,0 +1,47 @@ +import { GraphqlGuard } from '@core/authorization'; +import { UseGuards } from '@nestjs/common'; +import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { AuthorizationAgentPrivilege, Profiling } from '@src/common/decorators'; +import { ForumService } from './forum.service'; +import { AuthorizationPrivilege } from '@common/enums'; +import { IForum } from './forum.interface'; +import { IDiscussion } from '../forum-discussion/discussion.interface'; +import { DiscussionsInput } from './dto/forum.dto.discussions.input'; + +@Resolver(() => IForum) +export class ForumResolverFields { + constructor(private forumService: ForumService) {} + + @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) + @UseGuards(GraphqlGuard) + @ResolveField('discussions', () => [IDiscussion], { + nullable: true, + description: 'The Discussions active in this Forum.', + }) + @Profiling.api + async discussions( + @Parent() forum: IForum, + @Args('queryData', { type: () => DiscussionsInput, nullable: true }) + queryData?: DiscussionsInput + ): Promise { + return await this.forumService.getDiscussions( + forum, + queryData?.limit, + queryData?.orderBy + ); + } + + @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) + @UseGuards(GraphqlGuard) + @ResolveField('discussion', () => IDiscussion, { + nullable: true, + description: 'A particular Discussions active in this Forum.', + }) + @Profiling.api + async discussion( + @Parent() forum: IForum, + @Args('ID') discussionID: string + ): Promise { + return await this.forumService.getDiscussionOrFail(forum, discussionID); + } +} diff --git a/src/platform/forum/forum.resolver.mutations.ts b/src/platform/forum/forum.resolver.mutations.ts new file mode 100644 index 0000000000..1cb8b6ea8e --- /dev/null +++ b/src/platform/forum/forum.resolver.mutations.ts @@ -0,0 +1,119 @@ +import { Inject, LoggerService, UseGuards } from '@nestjs/common'; +import { Resolver } from '@nestjs/graphql'; +import { Args, Mutation } from '@nestjs/graphql'; +import { ForumService } from './forum.service'; +import { CurrentUser } from '@src/common/decorators'; +import { GraphqlGuard } from '@core/authorization'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { AuthorizationService } from '@core/authorization/authorization.service'; +import { AuthorizationPrivilege, LogContext } from '@common/enums'; +import { IDiscussion } from '../forum-discussion/discussion.interface'; +import { ForumCreateDiscussionInput } from './dto/forum.dto.create.discussion'; +import { DiscussionService } from '../forum-discussion/discussion.service'; +import { DiscussionAuthorizationService } from '../forum-discussion/discussion.service.authorization'; +import { SUBSCRIPTION_DISCUSSION_UPDATED } from '@common/constants/providers'; +import { PubSubEngine } from 'graphql-subscriptions'; +import { ForumDiscussionUpdated } from './dto/forum.dto.event.discussion.updated'; +import { SubscriptionType } from '@common/enums/subscription.type'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { NotificationAdapter } from '@services/adapters/notification-adapter/notification.adapter'; +import { NotificationInputForumDiscussionCreated } from '@services/adapters/notification-adapter/dto/notification.dto.input.discussion.created'; +import { PlatformAuthorizationPolicyService } from '@src/platform/authorization/platform.authorization.policy.service'; +import { ValidationException } from '@common/exceptions/validation.exception'; +import { NamingService } from '@services/infrastructure/naming/naming.service'; +import { ForumDiscussionCategory } from '@common/enums/forum.discussion.category'; + +@Resolver() +export class ForumResolverMutations { + constructor( + private authorizationService: AuthorizationService, + private notificationAdapter: NotificationAdapter, + private forumService: ForumService, + private namingService: NamingService, + private discussionAuthorizationService: DiscussionAuthorizationService, + private discussionService: DiscussionService, + private platformAuthorizationService: PlatformAuthorizationPolicyService, + @Inject(SUBSCRIPTION_DISCUSSION_UPDATED) + private readonly subscriptionDiscussionMessage: PubSubEngine, + @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService + ) {} + + @UseGuards(GraphqlGuard) + @Mutation(() => IDiscussion, { + description: 'Creates a new Discussion as part of this Forum.', + }) + async createDiscussion( + @CurrentUser() agentInfo: AgentInfo, + @Args('createData') createData: ForumCreateDiscussionInput + ): Promise { + const forum = await this.forumService.getForumOrFail(createData.forumID); + await this.authorizationService.grantAccessOrFail( + agentInfo, + forum.authorization, + AuthorizationPrivilege.CREATE_DISCUSSION, + `create discussion on forum: ${forum.id}` + ); + + if (createData.category === ForumDiscussionCategory.RELEASES) { + const platformAuthorization = + await this.platformAuthorizationService.getPlatformAuthorizationPolicy(); + await this.authorizationService.grantAccessOrFail( + agentInfo, + platformAuthorization, + AuthorizationPrivilege.PLATFORM_ADMIN, + `User not authorized to create discussion with ${ForumDiscussionCategory.RELEASES} category.` + ); + } + + const displayNameAvailable = + await this.namingService.isDiscussionDisplayNameAvailableInForum( + createData.profile.displayName, + forum.id + ); + if (!displayNameAvailable) + throw new ValidationException( + `Unable to create Discussion: the provided displayName is already taken: ${createData.profile.displayName}`, + LogContext.SPACES + ); + + let discussion = await this.forumService.createDiscussion( + createData, + agentInfo.userID, + agentInfo.communicationID + ); + + discussion = + await this.discussionAuthorizationService.applyAuthorizationPolicy( + discussion, + forum.authorization + ); + + discussion = await this.discussionService.save(discussion); + + // Send the notification + const notificationInput: NotificationInputForumDiscussionCreated = { + triggeredBy: agentInfo.userID, + discussion: discussion, + }; + await this.notificationAdapter.forumDiscussionCreated(notificationInput); + + // Send out the subscription event + const eventID = `discussion-message-updated-${Math.floor( + Math.random() * 100 + )}`; + const subscriptionPayload: ForumDiscussionUpdated = { + eventID: eventID, + discussionID: discussion.id, + }; + this.logger.verbose?.( + `[Discussion updated] - event published: '${eventID}'`, + LogContext.SUBSCRIPTIONS + ); + this.subscriptionDiscussionMessage.publish( + SubscriptionType.FORUM_DISCUSSION_UPDATED, + subscriptionPayload + ); + + return discussion; + } +} diff --git a/src/domain/communication/communication/communication.resolver.subscriptions.ts b/src/platform/forum/forum.resolver.subscriptions.ts similarity index 71% rename from src/domain/communication/communication/communication.resolver.subscriptions.ts rename to src/platform/forum/forum.resolver.subscriptions.ts index d5facdf196..2f6fa95ad6 100644 --- a/src/domain/communication/communication/communication.resolver.subscriptions.ts +++ b/src/platform/forum/forum.resolver.subscriptions.ts @@ -11,18 +11,18 @@ import { UUID } from '@domain/common/scalars/scalar.uuid'; import { AuthorizationService } from '@core/authorization/authorization.service'; import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; import { SUBSCRIPTION_DISCUSSION_UPDATED } from '@common/constants/providers'; -import { IDiscussion } from '../discussion/discussion.interface'; -import { DiscussionService } from '../discussion/discussion.service'; -import { CommunicationService } from './communication.service'; -import { CommunicationDiscussionUpdated } from './dto/communication.dto.event.discussion.updated'; +import { IDiscussion } from '../forum-discussion/discussion.interface'; +import { DiscussionService } from '../forum-discussion/discussion.service'; +import { ForumService } from './forum.service'; +import { ForumDiscussionUpdated } from './dto/forum.dto.event.discussion.updated'; import { UUID_LENGTH } from '@common/constants'; import { SubscriptionUserNotAuthenticated } from '@common/exceptions/subscription.user.not.authenticated'; @Resolver() -export class CommunicationResolverSubscriptions { +export class ForumResolverSubscriptions { constructor( private authorizationService: AuthorizationService, - private communicationService: CommunicationService, + private forumService: ForumService, private discussionService: DiscussionService, @Inject(SUBSCRIPTION_DISCUSSION_UPDATED) private subscriptionDiscussionUpdated: PubSubEngine, @@ -34,8 +34,8 @@ export class CommunicationResolverSubscriptions { @Subscription(() => IDiscussion, { description: 'Receive updates on Discussions', async resolve( - this: CommunicationResolverSubscriptions, - payload: CommunicationDiscussionUpdated, + this: ForumResolverSubscriptions, + payload: ForumDiscussionUpdated, _: any, context: any ): Promise { @@ -49,15 +49,15 @@ export class CommunicationResolverSubscriptions { ); }, async filter( - this: CommunicationResolverSubscriptions, - payload: CommunicationDiscussionUpdated, + this: ForumResolverSubscriptions, + payload: ForumDiscussionUpdated, variables: any, context: any ) { const agentInfo = context.req?.user; - const isMatch = await this.discussionService.isDiscussionInCommunication( + const isMatch = await this.discussionService.isDiscussionInForum( payload.discussionID, - variables.communicationID + variables.forumID ); this.logger.verbose?.( `[User (${agentInfo.email}) Discussion Update] - Filtering event id '${payload.eventID}' - match? ${isMatch}`, @@ -66,15 +66,14 @@ export class CommunicationResolverSubscriptions { return isMatch; }, }) - async communicationDiscussionUpdated( + async forumDiscussionUpdated( @CurrentUser() agentInfo: AgentInfo, @Args({ - name: 'communicationID', + name: 'forumID', type: () => UUID, - description: - 'The IDs of the Communication to subscribe to all updates on.', + description: 'The IDs of the Forum to subscribe to all updates on.', }) - communicationID: string + forumID: string ) { // Only allow subscriptions for logged in users if (agentInfo.userID.length !== UUID_LENGTH) { @@ -85,21 +84,20 @@ export class CommunicationResolverSubscriptions { } const logMsgPrefix = `[User (${agentInfo.email}) Discussion Update] - `; this.logger.verbose?.( - `${logMsgPrefix} Subscribing to Discussions on Communication: ${communicationID}`, + `${logMsgPrefix} Subscribing to Discussions on Forum: ${forumID}`, LogContext.SUBSCRIPTIONS ); - const communication = - await this.communicationService.getCommunicationOrFail(communicationID); + const forum = await this.forumService.getForumOrFail(forumID); await this.authorizationService.grantAccessOrFail( agentInfo, - communication.authorization, + forum.authorization, AuthorizationPrivilege.READ, - `subscription to discussion updates on: ${communication.id}` + `subscription to discussion updates on: ${forum.id}` ); return this.subscriptionDiscussionUpdated.asyncIterator( - SubscriptionType.COMMUNICATION_DISCUSSION_UPDATED + SubscriptionType.FORUM_DISCUSSION_UPDATED ); } } diff --git a/src/platform/forum/forum.service.authorization.ts b/src/platform/forum/forum.service.authorization.ts new file mode 100644 index 0000000000..95bfea3373 --- /dev/null +++ b/src/platform/forum/forum.service.authorization.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; +import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; +import { IAuthorizationPolicy } from '@domain/common/authorization-policy/authorization.policy.interface'; +import { DiscussionAuthorizationService } from '../forum-discussion/discussion.service.authorization'; +import { AuthorizationPrivilege, LogContext } from '@common/enums'; +import { ForumService } from './forum.service'; +import { AuthorizationPolicyRulePrivilege } from '@core/authorization/authorization.policy.rule.privilege'; +import { + POLICY_RULE_FORUM_CONTRIBUTE, + POLICY_RULE_FORUM_CREATE, +} from '@common/constants'; +import { RelationshipNotFoundException } from '@common/exceptions/relationship.not.found.exception'; +import { IForum } from './forum.interface'; + +@Injectable() +export class ForumAuthorizationService { + constructor( + private authorizationPolicyService: AuthorizationPolicyService, + private forumService: ForumService, + private discussionAuthorizationService: DiscussionAuthorizationService + ) {} + + async applyAuthorizationPolicy( + forumInput: IForum, + parentAuthorization: IAuthorizationPolicy | undefined + ): Promise { + const forum = await this.forumService.getForumOrFail(forumInput.id, { + relations: { + discussions: { + comments: true, + }, + }, + }); + + if (!forum.discussions) { + throw new RelationshipNotFoundException( + `Unable to load entities to reset auth for forum ${forum.id} `, + LogContext.COMMUNICATION + ); + } + + forum.authorization = + this.authorizationPolicyService.inheritParentAuthorization( + forum.authorization, + parentAuthorization + ); + + forum.authorization = this.appendPrivilegeRules(forum.authorization); + + for (const discussion of forum.discussions) { + await this.discussionAuthorizationService.applyAuthorizationPolicy( + discussion, + forum.authorization + ); + } + + return forum; + } + + private appendPrivilegeRules( + authorization: IAuthorizationPolicy + ): IAuthorizationPolicy { + const privilegeRules: AuthorizationPolicyRulePrivilege[] = []; + + // Allow any contributor to this community to create discussions, and to send messages to the discussion + const contributePrivilege = new AuthorizationPolicyRulePrivilege( + [AuthorizationPrivilege.CREATE_DISCUSSION], + AuthorizationPrivilege.CONTRIBUTE, + POLICY_RULE_FORUM_CONTRIBUTE + ); + privilegeRules.push(contributePrivilege); + + const createPrivilege = new AuthorizationPolicyRulePrivilege( + [AuthorizationPrivilege.CREATE_DISCUSSION], + AuthorizationPrivilege.CREATE, + POLICY_RULE_FORUM_CREATE + ); + privilegeRules.push(createPrivilege); + return this.authorizationPolicyService.appendPrivilegeAuthorizationRules( + authorization, + privilegeRules + ); + } +} diff --git a/src/platform/forum/forum.service.spec.ts b/src/platform/forum/forum.service.spec.ts new file mode 100644 index 0000000000..55c05a56bb --- /dev/null +++ b/src/platform/forum/forum.service.spec.ts @@ -0,0 +1,28 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ForumService } from './forum.service'; +import { MockWinstonProvider } from '@test/mocks/winston.provider.mock'; +import { defaultMockerFactory } from '@test/utils/default.mocker.factory'; +import { Forum } from './forum.entity'; +import { repositoryProviderMockFactory } from '@test/utils/repository.provider.mock.factory'; + +describe('ForumService', () => { + let service: ForumService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ForumService, + MockWinstonProvider, + repositoryProviderMockFactory(Forum), + ], + }) + .useMocker(defaultMockerFactory) + .compile(); + + service = module.get(ForumService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/platform/forum/forum.service.ts b/src/platform/forum/forum.service.ts new file mode 100644 index 0000000000..025fd26e63 --- /dev/null +++ b/src/platform/forum/forum.service.ts @@ -0,0 +1,251 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { + EntityNotFoundException, + EntityNotInitializedException, +} from '@common/exceptions'; +import { LogContext } from '@common/enums'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { FindOneOptions, Repository } from 'typeorm'; +import { AuthorizationPolicy } from '@domain/common/authorization-policy'; +import { IDiscussion } from '../forum-discussion/discussion.interface'; +import { DiscussionService } from '../forum-discussion/discussion.service'; +import { IUser } from '@domain/community/user/user.interface'; +import { ForumCreateDiscussionInput } from './dto/forum.dto.create.discussion'; +import { UUID_LENGTH } from '@common/constants/entity.field.length.constants'; +import { RoomType } from '@common/enums/room.type'; +import { StorageAggregatorResolverService } from '@services/infrastructure/storage-aggregator-resolver/storage.aggregator.resolver.service'; +import { DiscussionsOrderBy } from '@common/enums/discussions.orderBy'; +import { Discussion } from '../forum-discussion/discussion.entity'; +import { NamingService } from '@services/infrastructure/naming/naming.service'; +import { Forum } from './forum.entity'; +import { ForumDiscussionCategory } from '@common/enums/forum.discussion.category'; +import { IForum } from './forum.interface'; +import { ForumDiscussionCategoryException } from '@common/exceptions/forum.discussion.category.exception'; +import { CommunicationAdapter } from '@services/adapters/communication-adapter/communication.adapter'; + +@Injectable() +export class ForumService { + constructor( + private discussionService: DiscussionService, + private communicationAdapter: CommunicationAdapter, + private storageAggregatorResolverService: StorageAggregatorResolverService, + private namingService: NamingService, + @InjectRepository(Forum) + private forumRepository: Repository, + @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService + ) {} + + async createForum( + discussionCategories: ForumDiscussionCategory[] + ): Promise { + const forum: IForum = new Forum(); + forum.authorization = new AuthorizationPolicy(); + + forum.discussions = []; + forum.discussionCategories = discussionCategories; + + return await this.save(forum); + } + + async save(forum: IForum): Promise { + return await this.forumRepository.save(forum); + } + + async createDiscussion( + discussionData: ForumCreateDiscussionInput, + userID: string, + userForumID: string + ): Promise { + const displayName = discussionData.profile.displayName; + const forumID = discussionData.forumID; + + this.logger.verbose?.( + `[Discussion] Adding discussion (${displayName}) to Forum (${forumID})`, + LogContext.PLATFORM_FORUM + ); + + // Try to find the Forum + const forum = await this.getForumOrFail(forumID, { + relations: { discussions: true }, + }); + + if (!forum.discussionCategories.includes(discussionData.category)) { + throw new ForumDiscussionCategoryException( + `Invalid discussion category supplied ('${discussionData.category}'), allowed categories: ${forum.discussionCategories}`, + LogContext.PLATFORM_FORUM + ); + } + + const storageAggregator = + await this.storageAggregatorResolverService.getStorageAggregatorForForum(); + const reservedNameIDs = await this.namingService.getReservedNameIDsInForum( + forum.id + ); + discussionData.nameID = + this.namingService.createNameIdAvoidingReservedNameIDs( + `${discussionData.profile.displayName}`, + reservedNameIDs + ); + const discussion = await this.discussionService.createDiscussion( + discussionData, + userID, + 'platform-forum', + RoomType.DISCUSSION_FORUM, + storageAggregator + ); + this.logger.verbose?.( + `[Discussion] Room created (${displayName}) and membership replicated from Updates (${forumID})`, + LogContext.PLATFORM_FORUM + ); + + forum.discussions?.push(discussion); + await this.forumRepository.save(forum); + + // Trigger a room membership request for the current user that is not awaited + const room = await this.discussionService.getComments(discussion.id); + await this.communicationAdapter.addUserToRoom( + room.externalRoomID, + userForumID + ); + + return discussion; + } + + async getDiscussions( + forum: IForum, + limit?: number, + orderBy: DiscussionsOrderBy = DiscussionsOrderBy.DISCUSSIONS_CREATEDATE_DESC + ): Promise { + const forumWithDiscussions = await this.getForumOrFail(forum.id, { + relations: { discussions: true }, + }); + const discussions = forumWithDiscussions.discussions; + if (!discussions) + throw new EntityNotInitializedException( + `Unable to load Discussions for Forum: ${forum.id} `, + LogContext.PLATFORM_FORUM + ); + + const sortedDiscussions = (discussions as Discussion[]).sort((a, b) => { + switch (orderBy) { + case DiscussionsOrderBy.DISCUSSIONS_CREATEDATE_ASC: + return a.createdDate.getTime() - b.createdDate.getTime(); + case DiscussionsOrderBy.DISCUSSIONS_CREATEDATE_DESC: + return b.createdDate.getTime() - a.createdDate.getTime(); + } + return 0; + }); + return limit && limit > 0 + ? sortedDiscussions.slice(0, limit) + : sortedDiscussions; + } + + async getDiscussionOrFail( + forum: IForum, + discussionID: string + ): Promise { + const discussions = await this.getDiscussions(forum); + let discussion: IDiscussion | undefined; + if (discussionID.length === UUID_LENGTH) { + discussion = discussions.find( + discussion => discussion.id === discussionID + ); + } + if (!discussion) { + // look up based on nameID + discussion = discussions.find( + discussion => discussion.nameID === discussionID + ); + } + + if (!discussion) { + throw new EntityNotFoundException( + `Unable to find Discussion with ID: ${discussionID}`, + LogContext.PLATFORM_FORUM + ); + } + return discussion; + } + + async getForumOrFail( + forumID: string, + options?: FindOneOptions + ): Promise { + const forum = await this.forumRepository.findOne({ + where: { + id: forumID, + }, + ...options, + }); + if (!forum) + throw new EntityNotFoundException( + `Unable to find Forum with ID: ${forumID}`, + LogContext.PLATFORM_FORUM + ); + return forum; + } + + async removeForum(forumID: string): Promise { + // Note need to load it in with all contained entities so can remove fully + const forum = await this.getForumOrFail(forumID, { + relations: { discussions: true }, + }); + + // Remove all groups + for (const discussion of await this.getDiscussions(forum)) { + await this.discussionService.removeDiscussion({ + ID: discussion.id, + }); + } + + await this.forumRepository.remove(forum as Forum); + return true; + } + + async addUserToForums(forum: IForum, forumUserID: string): Promise { + const forumRoomIDs = await this.getRoomsUsed(forum); + await this.communicationAdapter.grantUserAccessToRooms( + forumRoomIDs, + forumUserID + ); + + return true; + } + + async getRoomsUsed(forum: IForum): Promise { + const forumRoomIDs: string[] = []; + const discussions = await this.getDiscussions(forum); + for (const discussion of discussions) { + const room = await this.discussionService.getComments(discussion.id); + forumRoomIDs.push(room.displayName); + } + return forumRoomIDs; + } + + async getForumIDsUsed(): Promise { + const forumMatches = await this.forumRepository + .createQueryBuilder('forum') + .getMany(); + const forumIDs: string[] = []; + for (const forum of forumMatches) { + forumIDs.push(forum.id); + } + return forumIDs; + } + + async removeUserFromForums(forum: IForum, user: IUser): Promise { + // get the list of rooms to add the user to + const forumRoomIDs: string[] = []; + for (const discussion of await this.getDiscussions(forum)) { + const room = await this.discussionService.getComments(discussion.id); + forumRoomIDs.push(room.externalRoomID); + } + await this.communicationAdapter.removeUserFromRooms( + forumRoomIDs, + user.communicationID + ); + + return true; + } +} diff --git a/src/platform/forum/index.ts b/src/platform/forum/index.ts new file mode 100644 index 0000000000..ecae99c0ca --- /dev/null +++ b/src/platform/forum/index.ts @@ -0,0 +1,2 @@ +export * from './forum.entity'; +export * from './forum.interface'; diff --git a/src/platform/license-plan/dto/license.plan.dto.create.ts b/src/platform/license-plan/dto/license.plan.dto.create.ts index e5a5e5271f..654dbcf5e3 100644 --- a/src/platform/license-plan/dto/license.plan.dto.create.ts +++ b/src/platform/license-plan/dto/license.plan.dto.create.ts @@ -1,4 +1,6 @@ import { SMALL_TEXT_LENGTH } from '@common/constants/entity.field.length.constants'; +import { LicenseCredential } from '@common/enums/license.credential'; +import { LicensePlanType } from '@common/enums/license.plan.type'; import { Field, InputType } from '@nestjs/graphql'; import { MaxLength } from 'class-validator'; @@ -10,4 +12,70 @@ export class CreateLicensePlanInput { }) @MaxLength(SMALL_TEXT_LENGTH) name!: string; + + @Field(() => Boolean, { + description: 'Is this plan enabled?', + nullable: false, + }) + enabled!: boolean; + + @Field(() => Number, { + nullable: false, + description: 'The sorting order for this Plan.', + }) + sortOrder!: number; + + @Field(() => Number, { + nullable: true, + description: 'The price per month of this plan.', + }) + pricePerMonth!: number; + + @Field(() => Boolean, { + description: 'Is this plan free?', + nullable: false, + }) + isFree!: boolean; + + @Field(() => LicensePlanType, { + nullable: false, + description: 'The type of this License Plan.', + }) + type!: LicensePlanType; + + @Field(() => Boolean, { + description: 'Is there a trial period enabled', + nullable: false, + }) + trialEnabled!: boolean; + + @Field(() => Boolean, { + description: 'Does this plan require a payment method?', + nullable: false, + }) + requiresPaymentMethod!: boolean; + + @Field(() => Boolean, { + description: 'Does this plan require contact support', + nullable: false, + }) + requiresContactSupport!: boolean; + + @Field(() => LicenseCredential, { + description: 'The credential to represent this plan', + nullable: false, + }) + licenseCredential!: LicenseCredential; + + @Field(() => Boolean, { + description: 'Assign this plan to all new User accounts', + nullable: false, + }) + assignToNewUserAccounts!: boolean; + + @Field(() => Boolean, { + description: 'Assign this plan to all new Organization accounts', + nullable: false, + }) + assignToNewOrganizationAccounts!: boolean; } diff --git a/src/platform/license-plan/dto/license.plan.dto.update.ts b/src/platform/license-plan/dto/license.plan.dto.update.ts index 5be91b2be8..d77ba79016 100644 --- a/src/platform/license-plan/dto/license.plan.dto.update.ts +++ b/src/platform/license-plan/dto/license.plan.dto.update.ts @@ -1,5 +1,66 @@ -import { InputType } from '@nestjs/graphql'; +import { Field, InputType } from '@nestjs/graphql'; import { UpdateBaseAlkemioInput } from '@domain/common/entity/base-entity/dto/base.alkemio.dto.update'; +import { LicenseCredential } from '@common/enums/license.credential'; @InputType() -export class UpdateLicensePlanInput extends UpdateBaseAlkemioInput {} +export class UpdateLicensePlanInput extends UpdateBaseAlkemioInput { + @Field(() => Boolean, { + description: 'Is this plan enabled?', + nullable: true, + }) + enabled!: boolean; + + @Field(() => Number, { + nullable: true, + description: 'The sorting order for this Plan.', + }) + sortOrder?: number; + + @Field(() => Number, { + nullable: true, + description: 'The price per month of this plan.', + }) + pricePerMonth?: number; + + @Field(() => Boolean, { + description: 'Is this plan free?', + nullable: true, + }) + isFree?: boolean; + + @Field(() => Boolean, { + description: 'Is there a trial period enabled', + nullable: true, + }) + trialEnabled?: boolean; + + @Field(() => Boolean, { + description: 'Does this plan require a payment method?', + nullable: true, + }) + requiresPaymentMethod?: boolean; + + @Field(() => Boolean, { + description: 'Does this plan require contact support', + nullable: true, + }) + requiresContactSupport?: boolean; + + @Field(() => LicenseCredential, { + description: 'The credential to represent this plan', + nullable: true, + }) + licenseCredential?: LicenseCredential; + + @Field(() => Boolean, { + description: 'Assign this plan to all new User accounts', + nullable: true, + }) + assignToNewUserAccounts?: boolean; + + @Field(() => Boolean, { + description: 'Assign this plan to all new Organization accounts', + nullable: true, + }) + assignToNewOrganizationAccounts?: boolean; +} diff --git a/src/platform/license-plan/license.plan.entity.ts b/src/platform/license-plan/license.plan.entity.ts index 5248a9fbf5..c842badffa 100644 --- a/src/platform/license-plan/license.plan.entity.ts +++ b/src/platform/license-plan/license.plan.entity.ts @@ -3,6 +3,7 @@ import { ILicensePlan } from './license.plan.interface'; import { BaseAlkemioEntity } from '@domain/common/entity/base-entity'; import { Licensing } from '@platform/licensing/licensing.entity'; import { LicenseCredential } from '@common/enums/license.credential'; +import { LicensePlanType } from '@common/enums/license.plan.type'; @Entity() export class LicensePlan extends BaseAlkemioEntity implements ILicensePlan { @@ -39,4 +40,13 @@ export class LicensePlan extends BaseAlkemioEntity implements ILicensePlan { @Column('text', { nullable: false }) licenseCredential!: LicenseCredential; + + @Column('text', { nullable: false }) + type!: LicensePlanType; + + @Column('boolean', { nullable: false, default: false }) + assignToNewOrganizationAccounts!: boolean; + + @Column('boolean', { nullable: false, default: false }) + assignToNewUserAccounts!: boolean; } diff --git a/src/platform/license-plan/license.plan.interface.ts b/src/platform/license-plan/license.plan.interface.ts index 0bd1fe2ad2..bf4098de70 100644 --- a/src/platform/license-plan/license.plan.interface.ts +++ b/src/platform/license-plan/license.plan.interface.ts @@ -2,6 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { IBaseAlkemio } from '@domain/common/entity/base-entity'; import { ILicensing } from '@platform/licensing/licensing.interface'; import { LicenseCredential } from '@common/enums/license.credential'; +import { LicensePlanType } from '@common/enums/license.plan.type'; @ObjectType('LicensePlan') export abstract class ILicensePlan extends IBaseAlkemio { @@ -25,6 +26,12 @@ export abstract class ILicensePlan extends IBaseAlkemio { }) sortOrder!: number; + @Field(() => LicensePlanType, { + nullable: false, + description: 'The type of this License Plan.', + }) + type!: LicensePlanType; + @Field(() => Number, { nullable: true, description: 'The price per month of this plan.', @@ -60,4 +67,16 @@ export abstract class ILicensePlan extends IBaseAlkemio { nullable: false, }) licenseCredential!: LicenseCredential; + + @Field(() => Boolean, { + description: 'Assign this plan to all new User accounts', + nullable: false, + }) + assignToNewUserAccounts!: boolean; + + @Field(() => Boolean, { + description: 'Assign this plan to all new Organization accounts', + nullable: false, + }) + assignToNewOrganizationAccounts!: boolean; } diff --git a/src/platform/license-policy/license.policy.entity.ts b/src/platform/license-policy/license.policy.entity.ts index efe24b17d1..7ed845093b 100644 --- a/src/platform/license-policy/license.policy.entity.ts +++ b/src/platform/license-policy/license.policy.entity.ts @@ -8,10 +8,10 @@ export class LicensePolicy implements ILicensePolicy { @Column('text') - featureFlagRules: string; + credentialRulesStr: string; constructor() { super(); - this.featureFlagRules = ''; + this.credentialRulesStr = ''; } } diff --git a/src/platform/license-policy/license.policy.interface.ts b/src/platform/license-policy/license.policy.interface.ts index 1507b43717..a86ddb8e87 100644 --- a/src/platform/license-policy/license.policy.interface.ts +++ b/src/platform/license-policy/license.policy.interface.ts @@ -4,5 +4,5 @@ import { ObjectType } from '@nestjs/graphql'; @ObjectType('LicensePolicy') export abstract class ILicensePolicy extends IAuthorizable { // exposed via field resolver - featureFlagRules!: string; + credentialRulesStr!: string; } diff --git a/src/platform/license-policy/license.policy.resolver.fields.ts b/src/platform/license-policy/license.policy.resolver.fields.ts index 740ee66835..6d0c8ecf1c 100644 --- a/src/platform/license-policy/license.policy.resolver.fields.ts +++ b/src/platform/license-policy/license.policy.resolver.fields.ts @@ -1,20 +1,20 @@ import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { ILicensePolicy } from './license.policy.interface'; import { LicensePolicyService } from './license.policy.service'; -import { ILicensePolicyRuleFeatureFlag } from '@core/license-engine/license.policy.rule.feature.flag.interface'; +import { ILicensePolicyCredentialRule } from '@core/license-engine'; @Resolver(() => ILicensePolicy) export class LicensePolicyResolverFields { constructor(private licensePolicyService: LicensePolicyService) {} - @ResolveField('featureFlagRules', () => [ILicensePolicyRuleFeatureFlag], { - nullable: true, + @ResolveField('credentialRules', () => [ILicensePolicyCredentialRule], { + nullable: false, description: 'The set of credential rules that are contained by this License Policy.', }) - featureFlagRules( + credentialRules( @Parent() license: ILicensePolicy - ): ILicensePolicyRuleFeatureFlag[] { - return this.licensePolicyService.getFeatureFlagRules(license); + ): ILicensePolicyCredentialRule[] { + return this.licensePolicyService.getCredentialRules(license); } } diff --git a/src/platform/license-policy/license.policy.service.ts b/src/platform/license-policy/license.policy.service.ts index be0cbbf602..6fb344ad42 100644 --- a/src/platform/license-policy/license.policy.service.ts +++ b/src/platform/license-policy/license.policy.service.ts @@ -4,13 +4,12 @@ import { Repository } from 'typeorm'; import { EntityNotFoundException } from '@common/exceptions'; import { ILicensePolicy } from './license.policy.interface'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; -import { ILicenseFeatureFlag } from '@domain/license/feature-flag/feature.flag.interface'; -import { ILicensePolicyRuleFeatureFlag } from '@core/license-engine/license.policy.rule.feature.flag.interface'; import { LicensePolicy } from './license.policy.entity'; import { LicenseEngineService } from '@core/license-engine/license.engine.service'; import { LicensePrivilege } from '@common/enums/license.privilege'; import { LogContext } from '@common/enums/logging.context'; -import { LicenseFeatureFlagName } from '@common/enums/license.feature.flag.name'; +import { ILicensePolicyCredentialRule } from '@core/license-engine'; +import { LicenseCredential } from '@common/enums/license.credential'; @Injectable() export class LicensePolicyService { @@ -22,16 +21,14 @@ export class LicensePolicyService { private readonly logger: LoggerService ) {} - createFeatureFlagRule( + createCredentialRule( grantedPrivileges: LicensePrivilege[], - featureFlag: ILicenseFeatureFlag, + credentialType: LicenseCredential, name: string - ): ILicensePolicyRuleFeatureFlag { - const featureFlagName: LicenseFeatureFlagName = - featureFlag.name as LicenseFeatureFlagName; + ): ILicensePolicyCredentialRule { return { grantedPrivileges, - featureFlagName, + credentialType, name, }; } @@ -60,11 +57,9 @@ export class LicensePolicyService { return await this.licensePolicyRepository.save(licensePolicy); } - getFeatureFlagRules( - license: ILicensePolicy - ): ILicensePolicyRuleFeatureFlag[] { - const rules = this.licenseEngineService.convertFeatureFlagRulesStr( - license.featureFlagRules + getCredentialRules(license: ILicensePolicy): ILicensePolicyCredentialRule[] { + const rules = this.licenseEngineService.convertCredentialRulesStr( + license.credentialRulesStr ); return rules; } diff --git a/src/platform/licensing/licensing.entity.ts b/src/platform/licensing/licensing.entity.ts index 3791565770..b00b2c7085 100644 --- a/src/platform/licensing/licensing.entity.ts +++ b/src/platform/licensing/licensing.entity.ts @@ -12,13 +12,6 @@ export class Licensing extends AuthorizableEntity implements ILicensing { }) plans!: LicensePlan[]; - @OneToOne(() => LicensePlan, { - eager: true, - cascade: false, - }) - @JoinColumn() - basePlan?: LicensePlan; - @OneToOne(() => LicensePolicy, { eager: false, cascade: true, diff --git a/src/platform/licensing/licensing.interface.ts b/src/platform/licensing/licensing.interface.ts index 060daca03b..581ffd1cd0 100644 --- a/src/platform/licensing/licensing.interface.ts +++ b/src/platform/licensing/licensing.interface.ts @@ -7,7 +7,5 @@ import { ILicensePolicy } from '@platform/license-policy/license.policy.interfac export abstract class ILicensing extends IAuthorizable { plans!: ILicensePlan[]; - basePlan?: ILicensePlan; - licensePolicy!: ILicensePolicy; } diff --git a/src/platform/licensing/licensing.resolver.fields.ts b/src/platform/licensing/licensing.resolver.fields.ts index 49265e3edc..732baa38ac 100644 --- a/src/platform/licensing/licensing.resolver.fields.ts +++ b/src/platform/licensing/licensing.resolver.fields.ts @@ -22,17 +22,6 @@ export class LicensingResolverFields { return await this.licensingService.getLicensePlans(licensing.id); } - @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) - @ResolveField('basePlan', () => ILicensePlan, { - nullable: false, - description: - 'The base License Plan assigned to all Accounts in use on the platform.', - }) - @UseGuards(GraphqlGuard) - async basePlan(@Parent() licensing: ILicensing): Promise { - return await this.licensingService.getBasePlan(licensing.id); - } - @ResolveField('policy', () => ILicensePolicy, { nullable: false, description: 'The LicensePolicy in use by the Licensing setup.', diff --git a/src/platform/licensing/licensing.service.ts b/src/platform/licensing/licensing.service.ts index 0bcf316525..bb18c6d637 100644 --- a/src/platform/licensing/licensing.service.ts +++ b/src/platform/licensing/licensing.service.ts @@ -55,24 +55,6 @@ export class LicensingService { return licensingFrameworks[0]; } - public async getBasePlan(licensingID: string): Promise { - const licensing = await this.getLicensingOrFail(licensingID, { - relations: { - basePlan: true, - }, - }); - const basePlan = licensing.basePlan; - - if (!basePlan) { - throw new EntityNotFoundException( - `Unable to find base plan: ${licensing.id}`, - LogContext.LICENSE - ); - } - - return basePlan; - } - public async save(licensing: ILicensing): Promise { return this.licensingRepository.save(licensing); } diff --git a/src/platform/platfrom/platform.entity.ts b/src/platform/platfrom/platform.entity.ts index cd43608fcb..790a95ffa7 100644 --- a/src/platform/platfrom/platform.entity.ts +++ b/src/platform/platfrom/platform.entity.ts @@ -1,21 +1,20 @@ import { AuthorizableEntity } from '@domain/common/entity/authorizable-entity'; -import { Communication } from '@domain/communication/communication/communication.entity'; import { Library } from '@library/library/library.entity'; -import { Entity, JoinColumn, OneToMany, OneToOne } from 'typeorm'; +import { Entity, JoinColumn, OneToOne } from 'typeorm'; import { IPlatform } from './platform.interface'; import { StorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.entity'; import { Licensing } from '@platform/licensing/licensing.entity'; -import { VirtualPersona } from '@platform/virtual-persona/virtual.persona.entity'; +import { Forum } from '@platform/forum'; @Entity() export class Platform extends AuthorizableEntity implements IPlatform { - @OneToOne(() => Communication, { + @OneToOne(() => Forum, { eager: false, cascade: true, onDelete: 'SET NULL', }) @JoinColumn() - communication?: Communication; + forum?: Forum; @OneToOne(() => Library, { eager: false, @@ -40,10 +39,4 @@ export class Platform extends AuthorizableEntity implements IPlatform { }) @JoinColumn() licensing?: Licensing; - - @OneToMany(() => VirtualPersona, persona => persona.platform, { - eager: false, - cascade: true, - }) - virtualPersonas!: VirtualPersona[]; } diff --git a/src/platform/platfrom/platform.interface.ts b/src/platform/platfrom/platform.interface.ts index 77744b525a..6933819468 100644 --- a/src/platform/platfrom/platform.interface.ts +++ b/src/platform/platfrom/platform.interface.ts @@ -1,22 +1,20 @@ import { IAuthorizable } from '@domain/common/entity/authorizable-entity'; -import { ICommunication } from '@domain/communication/communication/communication.interface'; import { IInnovationHub } from '@domain/innovation-hub/innovation.hub.interface'; import { IStorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.interface'; import { ILibrary } from '@library/library/library.interface'; import { ObjectType } from '@nestjs/graphql'; import { IConfig } from '@platform/configuration/config/config.interface'; +import { IForum } from '@platform/forum'; import { ILicensing } from '@platform/licensing/licensing.interface'; import { IMetadata } from '@platform/metadata/metadata.interface'; -import { IVirtualPersona } from '@platform/virtual-persona/virtual.persona.interface'; @ObjectType('Platform') export abstract class IPlatform extends IAuthorizable { - communication?: ICommunication; + forum?: IForum; library?: ILibrary; configuration?: IConfig; metadata?: IMetadata; storageAggregator!: IStorageAggregator; innovationHubs?: IInnovationHub[]; licensing?: ILicensing; - virtualPersonas?: IVirtualPersona[]; } diff --git a/src/platform/platfrom/platform.module.ts b/src/platform/platfrom/platform.module.ts index a571359ff5..e7956af5f2 100644 --- a/src/platform/platfrom/platform.module.ts +++ b/src/platform/platfrom/platform.module.ts @@ -20,7 +20,7 @@ import { UserModule } from '@domain/community/user/user.module'; import { AgentModule } from '@domain/agent/agent/agent.module'; import { NotificationAdapterModule } from '@services/adapters/notification-adapter/notification.adapter.module'; import { LicensingModule } from '@platform/licensing/licensing.module'; -import { VirtualPersonaModule } from '@platform/virtual-persona/virtual.persona.module'; +import { ForumModule } from '@platform/forum/forum.module'; @Module({ imports: [ @@ -29,6 +29,7 @@ import { VirtualPersonaModule } from '@platform/virtual-persona/virtual.persona. CommunicationModule, PlatformAuthorizationPolicyModule, LibraryModule, + ForumModule, StorageAggregatorModule, KonfigModule, MetadataModule, @@ -38,7 +39,6 @@ import { VirtualPersonaModule } from '@platform/virtual-persona/virtual.persona. UserModule, AgentModule, NotificationAdapterModule, - VirtualPersonaModule, TypeOrmModule.forFeature([Platform]), ], providers: [ diff --git a/src/platform/platfrom/platform.resolver.fields.ts b/src/platform/platfrom/platform.resolver.fields.ts index 3d599b2629..79ffc4a259 100644 --- a/src/platform/platfrom/platform.resolver.fields.ts +++ b/src/platform/platfrom/platform.resolver.fields.ts @@ -1,6 +1,5 @@ import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { ILibrary } from '@library/library/library.interface'; -import { ICommunication } from '@domain/communication/communication/communication.interface'; import { InnovationHub as InnovationHubDecorator, Profiling, @@ -21,6 +20,7 @@ import { GraphqlGuard } from '@core/authorization'; import { UseGuards } from '@nestjs/common'; import { ReleaseDiscussionOutput } from './dto/release.discussion.dto'; import { ILicensing } from '@platform/licensing/licensing.interface'; +import { IForum } from '@platform/forum'; @Resolver(() => IPlatform) export class PlatformResolverFields { @@ -49,12 +49,12 @@ export class PlatformResolverFields { }); } - @ResolveField('communication', () => ICommunication, { + @ResolveField('forum', () => IForum, { nullable: false, - description: 'The Communications for the platform', + description: 'The Forum for the platform', }) - communication(): Promise { - return this.platformService.getCommunicationOrFail(); + async forum(): Promise { + return await this.platformService.getForumOrFail(); } @ResolveField('storageAggregator', () => IStorageAggregator, { diff --git a/src/platform/platfrom/platform.resolver.mutations.ts b/src/platform/platfrom/platform.resolver.mutations.ts index ffbf276501..8865b6bd44 100644 --- a/src/platform/platfrom/platform.resolver.mutations.ts +++ b/src/platform/platfrom/platform.resolver.mutations.ts @@ -16,10 +16,6 @@ import { NotificationAdapter } from '@services/adapters/notification-adapter/not import { PlatformService } from './platform.service'; import { AssignPlatformRoleToUserInput } from './dto/platform.dto.assign.role.user'; import { PlatformRole } from '@common/enums/platform.role'; -import { IVirtualPersona } from '@platform/virtual-persona/virtual.persona.interface'; -import { CreateVirtualPersonaInput } from '@platform/virtual-persona/dto/virtual.persona.dto.create'; -import { VirtualPersonaService } from '@platform/virtual-persona/virtual.persona.service'; -import { VirtualPersonaAuthorizationService } from '@platform/virtual-persona/virtual.persona.service.authorization'; @Resolver() export class PlatformResolverMutations { @@ -28,9 +24,7 @@ export class PlatformResolverMutations { private notificationAdapter: NotificationAdapter, private platformService: PlatformService, private platformAuthorizationService: PlatformAuthorizationService, - private platformAuthorizationPolicyService: PlatformAuthorizationPolicyService, - private virtualPersonaService: VirtualPersonaService, - private virtualPersonaAuthorizationService: VirtualPersonaAuthorizationService + private platformAuthorizationPolicyService: PlatformAuthorizationPolicyService ) {} @UseGuards(GraphqlGuard) @@ -118,42 +112,6 @@ export class PlatformResolverMutations { return user; } - @UseGuards(GraphqlGuard) - @Mutation(() => IVirtualPersona, { - description: 'Creates a new VirtualPersona on the platform.', - }) - @Profiling.api - async createVirtualPersona( - @CurrentUser() agentInfo: AgentInfo, - @Args('virtualPersonaData') - virtualPersonaData: CreateVirtualPersonaInput - ): Promise { - const platformPolicy = - await this.platformAuthorizationPolicyService.getPlatformAuthorizationPolicy(); - await this.authorizationService.grantAccessOrFail( - agentInfo, - platformPolicy, - AuthorizationPrivilege.PLATFORM_ADMIN, - `create Virtual persona: ${virtualPersonaData.engine}` - ); - const virtual = await this.virtualPersonaService.createVirtualPersona( - virtualPersonaData - ); - - const virtualWithAuth = - await this.virtualPersonaAuthorizationService.applyAuthorizationPolicy( - virtual, - platformPolicy - ); - - const platform = await this.platformService.getPlatformOrFail(); - virtualWithAuth.platform = platform; - - await this.virtualPersonaService.save(virtualWithAuth); - - return virtualWithAuth; - } - private async notifyPlatformGlobalRoleChange( triggeredBy: string, user: IUser, diff --git a/src/platform/platfrom/platform.service.authorization.ts b/src/platform/platfrom/platform.service.authorization.ts index de2ff70a73..39bab09caa 100644 --- a/src/platform/platfrom/platform.service.authorization.ts +++ b/src/platform/platfrom/platform.service.authorization.ts @@ -7,7 +7,6 @@ import { Platform } from './platform.entity'; import { PlatformAuthorizationPolicyService } from '@platform/authorization/platform.authorization.policy.service'; import { LibraryAuthorizationService } from '@library/library/library.service.authorization'; import { PlatformService } from './platform.service'; -import { CommunicationAuthorizationService } from '@domain/communication/communication/communication.service.authorization'; import { IAuthorizationPolicy } from '@domain/common/authorization-policy/authorization.policy.interface'; import { EntityNotInitializedException } from '@common/exceptions/entity.not.initialized.exception'; import { @@ -32,8 +31,7 @@ import { import { StorageAggregatorAuthorizationService } from '@domain/storage/storage-aggregator/storage.aggregator.service.authorization'; import { RelationshipNotFoundException } from '@common/exceptions/relationship.not.found.exception'; import { LicensingAuthorizationService } from '@platform/licensing/licensing.service.authorization'; -import { VirtualPersonaAuthorizationService } from '@platform/virtual-persona/virtual.persona.service.authorization'; -import { IVirtualPersona } from '@platform/virtual-persona'; +import { ForumAuthorizationService } from '@platform/forum/forum.service.authorization'; @Injectable() export class PlatformAuthorizationService { @@ -41,13 +39,12 @@ export class PlatformAuthorizationService { private authorizationPolicyService: AuthorizationPolicyService, private platformAuthorizationPolicyService: PlatformAuthorizationPolicyService, private libraryAuthorizationService: LibraryAuthorizationService, - private communicationAuthorizationService: CommunicationAuthorizationService, + private forumAuthorizationService: ForumAuthorizationService, private platformService: PlatformService, private innovationHubService: InnovationHubService, private innovationHubAuthorizationService: InnovationHubAuthorizationService, private storageAggregatorAuthorizationService: StorageAggregatorAuthorizationService, private licensingAuthorizationService: LicensingAuthorizationService, - private virtualPersonaAuthorizationService: VirtualPersonaAuthorizationService, @InjectRepository(Platform) private platformRepository: Repository @@ -57,7 +54,6 @@ export class PlatformAuthorizationService { const platform = await this.platformService.getPlatformOrFail({ relations: { authorization: true, - virtualPersonas: true, }, }); @@ -74,7 +70,6 @@ export class PlatformAuthorizationService { this.platformAuthorizationPolicyService.inheritRootAuthorizationPolicy( platform.authorization ); - platform.authorization.anonymousReadAccess = true; platform.authorization = await this.appendCredentialRules( platform.authorization ); @@ -116,19 +111,17 @@ export class PlatformAuthorizationService { library: { innovationPacks: true, }, - communication: true, + forum: true, storageAggregator: true, licensing: true, - virtualPersonas: true, }, }); if ( !platform.library || - !platform.communication || + !platform.forum || !platform.storageAggregator || - !platform.licensing || - !platform.virtualPersonas + !platform.licensing ) throw new RelationshipNotFoundException( `Unable to load entities for platform auth: ${platform.id} `, @@ -151,9 +144,9 @@ export class PlatformAuthorizationService { const extendedAuthPolicy = await this.appendCredentialRulesCommunication( copyPlatformAuthorization ); - platform.communication = - await this.communicationAuthorizationService.applyAuthorizationPolicy( - platform.communication, + platform.forum = + await this.forumAuthorizationService.applyAuthorizationPolicy( + platform.forum, extendedAuthPolicy ); @@ -176,17 +169,6 @@ export class PlatformAuthorizationService { ); } - const updatedPersonas: IVirtualPersona[] = []; - for (const virtualPersona of platform.virtualPersonas) { - const updatedPersona = - await this.virtualPersonaAuthorizationService.applyAuthorizationPolicy( - virtualPersona, - platform.authorization - ); - updatedPersonas.push(updatedPersona); - } - platform.virtualPersonas = updatedPersonas; - platform.licensing = await this.licensingAuthorizationService.applyAuthorizationPolicy( platform.licensing, @@ -214,6 +196,9 @@ export class PlatformAuthorizationService { ); newRules.push(communicationRules); + // Set globally visible to replicate what already + authorization.anonymousReadAccess = true; + this.authorizationPolicyService.appendCredentialAuthorizationRules( authorization, newRules diff --git a/src/platform/platfrom/platform.service.ts b/src/platform/platfrom/platform.service.ts index 8a7fc71421..25d3e6d520 100644 --- a/src/platform/platfrom/platform.service.ts +++ b/src/platform/platfrom/platform.service.ts @@ -1,9 +1,5 @@ -import { COMMUNICATION_PLATFORM_SPACEID } from '@common/constants'; -import { DiscussionCategoryPlatform } from '@common/enums/communication.discussion.category.platform'; import { LogContext } from '@common/enums/logging.context'; import { EntityNotFoundException } from '@common/exceptions/entity.not.found.exception'; -import { ICommunication } from '@domain/communication/communication/communication.interface'; -import { CommunicationService } from '@domain/communication/communication/communication.service'; import { ILibrary } from '@library/library/library.interface'; import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; @@ -18,8 +14,6 @@ import { Platform } from './platform.entity'; import { IPlatform } from './platform.interface'; import { IStorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.interface'; import { IAuthorizationPolicy } from '@domain/common/authorization-policy'; -import { DiscussionCategory } from '@common/enums/communication.discussion.category'; -import { Discussion } from '@domain/communication/discussion/discussion.entity'; import { ReleaseDiscussionOutput } from './dto/release.discussion.dto'; import { PlatformRole } from '@common/enums/platform.role'; import { ForbiddenException } from '@common/exceptions/forbidden.exception'; @@ -31,13 +25,17 @@ import { UserService } from '@domain/community/user/user.service'; import { AgentService } from '@domain/agent/agent/agent.service'; import { AssignPlatformRoleToUserInput } from './dto/platform.dto.assign.role.user'; import { ILicensing } from '@platform/licensing/licensing.interface'; +import { ForumService } from '@platform/forum/forum.service'; +import { IForum } from '@platform/forum/forum.interface'; +import { ForumDiscussionCategory } from '@common/enums/forum.discussion.category'; +import { Discussion } from '@platform/forum-discussion/discussion.entity'; @Injectable() export class PlatformService { constructor( private userService: UserService, private agentService: AgentService, - private communicationService: CommunicationService, + private forumService: ForumService, private entityManager: EntityManager, @InjectRepository(Platform) private platformRepository: Repository, @@ -81,36 +79,33 @@ export class PlatformService { return library; } - async getCommunicationOrFail(): Promise { + async getForumOrFail(): Promise { const platform = await this.getPlatformOrFail({ - relations: { communication: true }, + relations: { forum: true }, }); - const communication = platform.communication; - if (!communication) { + const forum = platform.forum; + if (!forum) { throw new EntityNotFoundException( - 'No Platform Communication found!', + 'No Platform Forum found!', LogContext.PLATFORM ); } - return communication; + return forum; } - async ensureCommunicationCreated(): Promise { + async ensureForumCreated(): Promise { const platform = await this.getPlatformOrFail({ - relations: { communication: true }, + relations: { forum: true }, }); - const communication = platform.communication; - if (!communication) { - platform.communication = - await this.communicationService.createCommunication( - 'platform', - COMMUNICATION_PLATFORM_SPACEID, - Object.values(DiscussionCategoryPlatform) - ); + const forum = platform.forum; + if (!forum) { + platform.forum = await this.forumService.createForum( + Object.values(ForumDiscussionCategory) + ); await this.savePlatform(platform); - return platform.communication; + return platform.forum; } - return communication; + return forum; } async getStorageAggregator( @@ -172,7 +167,7 @@ export class PlatformService { latestDiscussion = await this.entityManager .getRepository(Discussion) .findOneOrFail({ - where: { category: DiscussionCategory.RELEASES }, + where: { category: ForumDiscussionCategory.RELEASES }, order: { createdDate: 'DESC' }, }); } catch (error) { diff --git a/src/platform/virtual-persona/dto/index.ts b/src/platform/virtual-persona/dto/index.ts deleted file mode 100644 index 725ac09e6d..0000000000 --- a/src/platform/virtual-persona/dto/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './virtual.persona.dto.create'; -export * from './virtual.persona.dto.update'; -export * from './virtual.persona.dto.delete'; diff --git a/src/platform/virtual-persona/dto/virtual.persona.dto.update.ts b/src/platform/virtual-persona/dto/virtual.persona.dto.update.ts deleted file mode 100644 index fe50d7eec8..0000000000 --- a/src/platform/virtual-persona/dto/virtual.persona.dto.update.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Field, InputType } from '@nestjs/graphql'; -import { MaxLength } from 'class-validator'; -import { LONG_TEXT_LENGTH, SMALL_TEXT_LENGTH } from '@src/common/constants'; -import { UpdateNameableInput } from '@domain/common/entity/nameable-entity'; -import { VirtualContributorEngine } from '@common/enums/virtual.contributor.engine'; -import JSON from 'graphql-type-json'; - -@InputType() -export class UpdateVirtualPersonaInput extends UpdateNameableInput { - @Field(() => VirtualContributorEngine, { nullable: false }) - @MaxLength(SMALL_TEXT_LENGTH) - engine!: VirtualContributorEngine; - - @Field(() => JSON, { nullable: true }) - @MaxLength(LONG_TEXT_LENGTH) - prompt!: string; -} diff --git a/src/platform/virtual-persona/index.ts b/src/platform/virtual-persona/index.ts deleted file mode 100644 index eb4aee7b5b..0000000000 --- a/src/platform/virtual-persona/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './virtual.persona.entity'; -export * from './virtual.persona.interface'; diff --git a/src/platform/virtual-persona/virtual.persona.entity.ts b/src/platform/virtual-persona/virtual.persona.entity.ts deleted file mode 100644 index 86b01044f9..0000000000 --- a/src/platform/virtual-persona/virtual.persona.entity.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; -import { IVirtualPersona } from './virtual.persona.interface'; -import { VirtualContributorEngine } from '@common/enums/virtual.contributor.engine'; -import { Platform } from '@platform/platfrom/platform.entity'; -import { VirtualPersonaAccessMode } from '@common/enums/virtual.persona.access.mode'; -import { NameableEntity } from '@domain/common/entity/nameable-entity/nameable.entity'; - -@Entity() -export class VirtualPersona extends NameableEntity implements IVirtualPersona { - @ManyToOne(() => Platform, platform => platform.virtualPersonas, { - eager: true, - }) - @JoinColumn() - platform!: Platform; - - @Column({ length: 128, nullable: false }) - engine!: VirtualContributorEngine; - - @Column('text', { nullable: false }) - prompt!: string; - - @Column({ - length: 64, - nullable: false, - default: VirtualPersonaAccessMode.SPACE_PROFILE, - }) - dataAccessMode!: VirtualPersonaAccessMode; -} diff --git a/src/platform/virtual-persona/virtual.persona.interface.ts b/src/platform/virtual-persona/virtual.persona.interface.ts deleted file mode 100644 index 6fa500c2b2..0000000000 --- a/src/platform/virtual-persona/virtual.persona.interface.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Field, ObjectType } from '@nestjs/graphql'; -import { VirtualContributorEngine } from '@common/enums/virtual.contributor.engine'; -import { VirtualPersonaAccessMode } from '@common/enums/virtual.persona.access.mode'; -import { INameable } from '@domain/common/entity/nameable-entity'; -import { IPlatform } from '@platform/platfrom/platform.interface'; - -@ObjectType('VirtualPersona') -export class IVirtualPersona extends INameable { - @Field(() => VirtualContributorEngine, { - nullable: false, - description: - 'The Virtual Persona Engine being used by this virtual persona.', - }) - engine!: VirtualContributorEngine; - - @Field(() => String, { - nullable: false, - description: 'The prompt used by this Virtual Persona', - }) - prompt!: string; - - @Field(() => VirtualPersonaAccessMode, { - nullable: false, - description: 'The required data access by the Virtual Persona', - }) - dataAccessMode!: VirtualPersonaAccessMode; - - platform!: IPlatform; -} diff --git a/src/platform/virtual-persona/virtual.persona.module.ts b/src/platform/virtual-persona/virtual.persona.module.ts deleted file mode 100644 index 7a695c8ba8..0000000000 --- a/src/platform/virtual-persona/virtual.persona.module.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Module } from '@nestjs/common'; -import { VirtualPersonaService } from './virtual.persona.service'; -import { VirtualPersonaResolverMutations } from './virtual.persona.resolver.mutations'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { VirtualPersonaResolverQueries } from './virtual.persona.resolver.queries'; -import { VirtualPersonaAuthorizationService } from './virtual.persona.service.authorization'; -import { AuthorizationModule } from '@core/authorization/authorization.module'; -import { AuthorizationPolicyModule } from '@domain/common/authorization-policy/authorization.policy.module'; -import { VirtualPersona } from './virtual.persona.entity'; -import { VirtualPersonaResolverFields } from './virtual.persona.resolver.fields'; -import { VirtualPersonaEngineAdapterModule } from '@services/adapters/virtual-persona-engine-adapter/virtual.persona.engine.adapter.module'; -import { ProfileModule } from '@domain/common/profile/profile.module'; -import { StorageAggregatorModule } from '@domain/storage/storage-aggregator/storage.aggregator.module'; - -@Module({ - imports: [ - AuthorizationPolicyModule, - AuthorizationModule, - VirtualPersonaEngineAdapterModule, - ProfileModule, - StorageAggregatorModule, - TypeOrmModule.forFeature([VirtualPersona]), - ], - providers: [ - VirtualPersonaService, - VirtualPersonaAuthorizationService, - VirtualPersonaResolverQueries, - VirtualPersonaResolverMutations, - VirtualPersonaResolverFields, - ], - exports: [VirtualPersonaService, VirtualPersonaAuthorizationService], -}) -export class VirtualPersonaModule {} diff --git a/src/platform/virtual-persona/virtual.persona.resolver.fields.ts b/src/platform/virtual-persona/virtual.persona.resolver.fields.ts deleted file mode 100644 index fc49e2f943..0000000000 --- a/src/platform/virtual-persona/virtual.persona.resolver.fields.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { UseGuards } from '@nestjs/common'; -import { Resolver } from '@nestjs/graphql'; -import { Parent, ResolveField } from '@nestjs/graphql'; -import { VirtualPersona } from './virtual.persona.entity'; -import { VirtualPersonaService } from './virtual.persona.service'; -import { AuthorizationPrivilege } from '@common/enums'; -import { GraphqlGuard } from '@core/authorization'; -import { CurrentUser, Profiling } from '@common/decorators'; -import { AuthorizationService } from '@core/authorization/authorization.service'; -import { IAuthorizationPolicy } from '@domain/common/authorization-policy'; -import { IVirtualPersona } from './virtual.persona.interface'; -import { AgentInfo } from '@core/authentication.agent.info/agent.info'; -import { ProfileLoaderCreator } from '@core/dataloader/creators'; -import { Loader } from '@core/dataloader/decorators'; -import { ILoader } from '@core/dataloader/loader.interface'; -import { IProfile } from '@domain/common/profile'; - -@Resolver(() => IVirtualPersona) -export class VirtualPersonaResolverFields { - constructor( - private authorizationService: AuthorizationService, - private virtualPersonaService: VirtualPersonaService - ) {} - - @UseGuards(GraphqlGuard) - @ResolveField('authorization', () => IAuthorizationPolicy, { - nullable: true, - description: 'The Authorization for this Virtual.', - }) - @Profiling.api - async authorization( - @Parent() parent: VirtualPersona, - @CurrentUser() agentInfo: AgentInfo - ) { - // Reload to ensure the authorization is loaded - const virtualPersona = - await this.virtualPersonaService.getVirtualPersonaOrFail(parent.id); - - this.authorizationService.grantAccessOrFail( - agentInfo, - virtualPersona.authorization, - AuthorizationPrivilege.READ, - `virtual authorization access: ${virtualPersona.id}` - ); - - return virtualPersona.authorization; - } - - // Check authorization inside the field resolver - @UseGuards(GraphqlGuard) - @ResolveField('profile', () => IProfile, { - nullable: false, - description: 'The Profile for the VirtualPersona.', - }) - async profile( - @Parent() virtualPersona: VirtualPersona, - @CurrentUser() agentInfo: AgentInfo, - @Loader(ProfileLoaderCreator, { parentClassRef: VirtualPersona }) - loader: ILoader - ): Promise { - const profile = await loader.load(virtualPersona.id); - // Check if the user can read the profile entity, not the space - await this.authorizationService.grantAccessOrFail( - agentInfo, - profile.authorization, - AuthorizationPrivilege.READ, - `read profile on space: ${profile.displayName}` - ); - return profile; - } -} diff --git a/src/platform/virtual-persona/virtual.persona.resolver.mutations.ts b/src/platform/virtual-persona/virtual.persona.resolver.mutations.ts deleted file mode 100644 index e286a831e7..0000000000 --- a/src/platform/virtual-persona/virtual.persona.resolver.mutations.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { UseGuards } from '@nestjs/common'; -import { Args, Resolver, Mutation } from '@nestjs/graphql'; -import { VirtualPersonaService } from './virtual.persona.service'; -import { CurrentUser, Profiling } from '@src/common/decorators'; -import { GraphqlGuard } from '@core/authorization'; -import { AuthorizationPrivilege } from '@common/enums'; -import { AgentInfo } from '@core/authentication.agent.info/agent.info'; -import { AuthorizationService } from '@core/authorization/authorization.service'; -import { IVirtualPersona } from './virtual.persona.interface'; -import { DeleteVirtualPersonaInput, UpdateVirtualPersonaInput } from './dto'; - -@Resolver(() => IVirtualPersona) -export class VirtualPersonaResolverMutations { - constructor( - private virtualPersonaService: VirtualPersonaService, - private authorizationService: AuthorizationService - ) {} - - @UseGuards(GraphqlGuard) - @Mutation(() => IVirtualPersona, { - description: 'Updates the specified VirtualPersona.', - }) - @Profiling.api - async updateVirtualPersona( - @CurrentUser() agentInfo: AgentInfo, - @Args('virtualPersonaData') virtualPersonaData: UpdateVirtualPersonaInput - ): Promise { - const virtualPersona = - await this.virtualPersonaService.getVirtualPersonaOrFail( - virtualPersonaData.ID - ); - await this.authorizationService.grantAccessOrFail( - agentInfo, - virtualPersona.authorization, - AuthorizationPrivilege.UPDATE, - `orgUpdate: ${virtualPersona.id}` - ); - - return await this.virtualPersonaService.updateVirtualPersona( - virtualPersonaData - ); - } - - @UseGuards(GraphqlGuard) - @Mutation(() => IVirtualPersona, { - description: 'Deletes the specified VirtualPersona.', - }) - async deleteVirtualPersona( - @CurrentUser() agentInfo: AgentInfo, - @Args('deleteData') deleteData: DeleteVirtualPersonaInput - ): Promise { - const virtualPersona = - await this.virtualPersonaService.getVirtualPersonaOrFail(deleteData.ID); - await this.authorizationService.grantAccessOrFail( - agentInfo, - virtualPersona.authorization, - AuthorizationPrivilege.DELETE, - `deleteOrg: ${virtualPersona.id}` - ); - return await this.virtualPersonaService.deleteVirtualPersona(deleteData); - } - - @UseGuards(GraphqlGuard) - @Mutation(() => Boolean, { - description: 'Ingest the virtual contributor data / embeddings.', - }) - @Profiling.api - async ingest(@CurrentUser() agentInfo: AgentInfo): Promise { - return this.virtualPersonaService.ingest(agentInfo); - } -} diff --git a/src/platform/virtual-persona/virtual.persona.resolver.queries.ts b/src/platform/virtual-persona/virtual.persona.resolver.queries.ts deleted file mode 100644 index 7e39d72059..0000000000 --- a/src/platform/virtual-persona/virtual.persona.resolver.queries.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { UUID } from '@domain/common/scalars'; -import { Args, Query, Resolver } from '@nestjs/graphql'; -import { CurrentUser } from '@src/common/decorators'; -import { IVirtualPersona } from './virtual.persona.interface'; -import { VirtualPersonaService } from './virtual.persona.service'; -import { UseGuards } from '@nestjs/common'; -import { GraphqlGuard } from '@core/authorization'; -import { AgentInfo } from '@core/authentication.agent.info/agent.info'; -import { IVirtualPersonaQuestionResult } from './dto/virtual.persona.question.dto.result'; -import { VirtualPersonaQuestionInput } from './dto/virtual.persona.question.dto.input'; - -@Resolver() -export class VirtualPersonaResolverQueries { - constructor(private virtualPersonaService: VirtualPersonaService) {} - - @Query(() => [IVirtualPersona], { - nullable: false, - description: 'The VirtualPersonas on this platform', - }) - async virtualPersonas(): Promise { - return await this.virtualPersonaService.getVirtualPersonas(); - } - - @Query(() => IVirtualPersona, { - nullable: false, - description: 'A particular VirtualPersona', - }) - async virtualPersona( - @Args('ID', { type: () => UUID, nullable: false }) id: string - ): Promise { - return await this.virtualPersonaService.getVirtualPersonaOrFail(id); - } - - @UseGuards(GraphqlGuard) - @Query(() => IVirtualPersonaQuestionResult, { - nullable: false, - description: 'Ask the virtual persona engine for guidance.', - }) - async askVirtualPersonaQuestion( - @CurrentUser() agentInfo: AgentInfo, - @Args('chatData') chatData: VirtualPersonaQuestionInput - ): Promise { - return this.virtualPersonaService.askQuestion(chatData, agentInfo, '', ''); - } -} diff --git a/src/platform/virtual-persona/virtual.persona.service.ts b/src/platform/virtual-persona/virtual.persona.service.ts deleted file mode 100644 index 2e1318f8d5..0000000000 --- a/src/platform/virtual-persona/virtual.persona.service.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { Inject, Injectable, LoggerService } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; -import { FindOneOptions, Repository } from 'typeorm'; -import { EntityNotFoundException } from '@common/exceptions'; -import { AuthorizationPolicy } from '@domain/common/authorization-policy'; -import { VirtualPersona } from './virtual.persona.entity'; -import { IVirtualPersona } from './virtual.persona.interface'; -import { CreateVirtualPersonaInput as CreateVirtualPersonaInput } from './dto/virtual.persona.dto.create'; -import { DeleteVirtualPersonaInput as DeleteVirtualPersonaInput } from './dto/virtual.persona.dto.delete'; -import { UpdateVirtualPersonaInput } from './dto/virtual.persona.dto.update'; -import { IVirtualPersonaQuestionResult } from './dto/virtual.persona.question.dto.result'; -import { VirtualPersonaQuestionInput } from './dto/virtual.persona.question.dto.input'; -import { AgentInfo } from '@core/authentication.agent.info/agent.info'; -import { LogContext } from '@common/enums/logging.context'; -import { VirtualPersonaEngineAdapterQueryInput } from '@services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.question.input'; -import { VirtualPersonaEngineAdapter } from '@services/adapters/virtual-persona-engine-adapter/virtual.persona.engine.adapter'; -import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; -import { ProfileType } from '@common/enums'; -import { TagsetReservedName } from '@common/enums/tagset.reserved.name'; -import { VisualType } from '@common/enums/visual.type'; -import { ProfileService } from '@domain/common/profile/profile.service'; -import { StorageAggregatorService } from '@domain/storage/storage-aggregator/storage.aggregator.service'; -import { VirtualContributorEngine } from '@common/enums/virtual.contributor.engine'; - -@Injectable() -export class VirtualPersonaService { - constructor( - private virtualPersonaEngineAdapter: VirtualPersonaEngineAdapter, - private authorizationPolicyService: AuthorizationPolicyService, - private profileService: ProfileService, - private storageAggregatorService: StorageAggregatorService, - @InjectRepository(VirtualPersona) - private virtualPersonaRepository: Repository, - @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService - ) {} - - async createVirtualPersona( - virtualPersonaData: CreateVirtualPersonaInput - ): Promise { - if (virtualPersonaData.prompt === undefined) virtualPersonaData.prompt = ''; - const virtual: IVirtualPersona = VirtualPersona.create(virtualPersonaData); - virtual.authorization = new AuthorizationPolicy(); - - // TODO: for now just create a new storage aggregator, to be looked at later where to manage - // and store the personas (and engine definitions) - const storageAggregator = - await this.storageAggregatorService.createStorageAggregator(); - - virtual.profile = await this.profileService.createProfile( - virtualPersonaData.profileData, - ProfileType.VIRTUAL_PERSONA, - storageAggregator - ); - await this.profileService.addTagsetOnProfile(virtual.profile, { - name: TagsetReservedName.KEYWORDS, - tags: [], - }); - await this.profileService.addTagsetOnProfile(virtual.profile, { - name: TagsetReservedName.CAPABILITIES, - tags: [], - }); - // Set the visuals - let avatarURL = virtualPersonaData.profileData?.avatarURL; - if (!avatarURL) { - avatarURL = this.profileService.generateRandomAvatar( - virtual.profile.displayName, - '' - ); - } - await this.profileService.addVisualOnProfile( - virtual.profile, - VisualType.AVATAR, - avatarURL - ); - - const savedVP = await this.virtualPersonaRepository.save(virtual); - this.logger.verbose?.( - `Created new virtual persona with id ${virtual.id}`, - LogContext.PLATFORM - ); - - return savedVP; - } - - async updateVirtualPersona( - virtualPersonaData: UpdateVirtualPersonaInput - ): Promise { - const virtualPersona = await this.getVirtualPersonaOrFail( - virtualPersonaData.ID, - { relations: { profile: true } } - ); - - if (virtualPersonaData.prompt !== undefined) { - virtualPersona.prompt = virtualPersonaData.prompt; - } - - if (virtualPersonaData.engine !== undefined) { - virtualPersona.engine = virtualPersonaData.engine; - } - - if (virtualPersonaData.profileData) { - virtualPersona.profile = await this.profileService.updateProfile( - virtualPersona.profile, - virtualPersonaData.profileData - ); - } - - return await this.virtualPersonaRepository.save(virtualPersona); - } - - async deleteVirtualPersona( - deleteData: DeleteVirtualPersonaInput - ): Promise { - const personaID = deleteData.ID; - - const virtualPersona = await this.getVirtualPersonaOrFail(personaID, { - relations: { - authorization: true, - }, - }); - if (!virtualPersona.authorization) { - throw new EntityNotFoundException( - `Unable to find all fields on Virtual Persona with ID: ${deleteData.ID}`, - LogContext.PLATFORM - ); - } - await this.authorizationPolicyService.delete(virtualPersona.authorization); - const result = await this.virtualPersonaRepository.remove( - virtualPersona as VirtualPersona - ); - result.id = personaID; - return result; - } - - public async getVirtualPersona( - virtualPersonaID: string, - options?: FindOneOptions - ): Promise { - const virtualPersona = await this.virtualPersonaRepository.findOne({ - ...options, - where: { ...options?.where, id: virtualPersonaID }, - }); - - return virtualPersona; - } - - public async getVirtualPersonaOrFail( - virtualID: string, - options?: FindOneOptions - ): Promise { - const virtualPersona = await this.getVirtualPersona(virtualID, options); - if (!virtualPersona) - throw new EntityNotFoundException( - `Unable to find Virtual Persona with ID: ${virtualID}`, - LogContext.PLATFORM - ); - return virtualPersona; - } - - public async getVirtualPersonaByEngineOrFail( - engine: VirtualContributorEngine, - options?: FindOneOptions - ): Promise { - const virtualPersona = await this.virtualPersonaRepository.findOne({ - ...options, - where: { ...options?.where, engine }, - order: { createdDate: 'ASC' }, - }); - if (!virtualPersona) - throw new EntityNotFoundException( - `Unable to find Virtual Persona with engine: ${engine}`, - LogContext.PLATFORM - ); - return virtualPersona; - } - - async save(virtualPersona: IVirtualPersona): Promise { - return await this.virtualPersonaRepository.save(virtualPersona); - } - - async getVirtualPersonas(): Promise { - const virtualContributors: IVirtualPersona[] = - await this.virtualPersonaRepository.find(); - return virtualContributors; - } - - public async askQuestion( - personaQuestionInput: VirtualPersonaQuestionInput, - agentInfo: AgentInfo, - contextSpaceNameID: string, - knowledgeSpaceNameID?: string - ): Promise { - const virtualPersona = await this.getVirtualPersonaOrFail( - personaQuestionInput.virtualPersonaID - ); - - const input: VirtualPersonaEngineAdapterQueryInput = { - engine: virtualPersona.engine, - prompt: virtualPersona.prompt, - userId: agentInfo.userID, - question: personaQuestionInput.question, - knowledgeSpaceNameID, - contextSpaceNameID, - }; - - this.logger.error(input); - const response = await this.virtualPersonaEngineAdapter.sendQuery(input); - - return response; - } - - public async ingest(agentInfo: AgentInfo): Promise { - return this.virtualPersonaEngineAdapter.sendIngest({ - engine: VirtualContributorEngine.EXPERT, - userId: agentInfo.userID, - }); - } -} diff --git a/src/services/adapters/ai-server-adapter/ai.server.adapter.module.ts b/src/services/adapters/ai-server-adapter/ai.server.adapter.module.ts new file mode 100644 index 0000000000..f31afa1244 --- /dev/null +++ b/src/services/adapters/ai-server-adapter/ai.server.adapter.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AiServerAdapter } from './ai.server.adapter'; +import { AiServerModule } from '@services/ai-server/ai-server/ai.server.module'; +import { AiPersonaServiceModule } from '@services/ai-server/ai-persona-service/ai.persona.service.module'; + +@Module({ + imports: [AiServerModule, AiPersonaServiceModule], + providers: [AiServerAdapter], + exports: [AiServerAdapter], +}) +export class AiServerAdapterModule {} diff --git a/src/services/adapters/ai-server-adapter/ai.server.adapter.ts b/src/services/adapters/ai-server-adapter/ai.server.adapter.ts new file mode 100644 index 0000000000..fab4195683 --- /dev/null +++ b/src/services/adapters/ai-server-adapter/ai.server.adapter.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { AiServerAdapterAskQuestionInput } from './dto/ai.server.adapter.dto.ask.question'; +import { IAiPersonaQuestionResult } from './dto/ai.server.adapter.dto.question.result'; +import { AiServerService } from '@services/ai-server/ai-server/ai.server.service'; +import { CreateAiPersonaServiceInput } from '@services/ai-server/ai-persona-service/dto'; +import { IAiPersonaService } from '@services/ai-server/ai-persona-service'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { AiPersonaServiceQuestionInput } from '@services/ai-server/ai-persona-service/dto/ai.persona.service.question.dto.input'; +import { SpaceIngestionPurpose } from '@services/infrastructure/event-bus/commands'; + +@Injectable() +export class AiServerAdapter { + constructor( + private aiServer: AiServerService, + @Inject(WINSTON_MODULE_NEST_PROVIDER) + private readonly logger: LoggerService + ) {} + + async ensureSpaceIsUsable( + spaceID: string, + purpose: SpaceIngestionPurpose + ): Promise { + return this.aiServer.ensureSpaceIsUsable(spaceID, purpose); + } + + async ensurePersonaIsUsable( + personaServiceId: string, + purpose: SpaceIngestionPurpose + ): Promise { + return this.aiServer.ensurePersonaIsUsable(personaServiceId, purpose); + } + + async getPersonaServiceOrFail( + personaServiceId: string + ): Promise { + return this.aiServer.getAiPersonaServiceOrFail(personaServiceId); + } + + async createAiPersonaService( + personaServiceData: CreateAiPersonaServiceInput + ) { + return this.aiServer.createAiPersonaService(personaServiceData); + } + + async askQuestion( + questionInput: AiServerAdapterAskQuestionInput, + agentInfo: AgentInfo, + contextSapceNameID: string + ): Promise { + return this.aiServer.askQuestion( + questionInput as unknown as AiPersonaServiceQuestionInput, + agentInfo, + contextSapceNameID + ); + } +} diff --git a/src/services/adapters/ai-server-adapter/dto/ai.server.adapter.dto.ask.question.ts b/src/services/adapters/ai-server-adapter/dto/ai.server.adapter.dto.ask.question.ts new file mode 100644 index 0000000000..c309b172f8 --- /dev/null +++ b/src/services/adapters/ai-server-adapter/dto/ai.server.adapter.dto.ask.question.ts @@ -0,0 +1,4 @@ +export class AiServerAdapterAskQuestionInput { + question!: string; + personaServiceID!: string; +} diff --git a/src/services/adapters/ai-server-adapter/dto/ai.server.adapter.dto.question.result.ts b/src/services/adapters/ai-server-adapter/dto/ai.server.adapter.dto.question.result.ts new file mode 100644 index 0000000000..fc40a0564b --- /dev/null +++ b/src/services/adapters/ai-server-adapter/dto/ai.server.adapter.dto.question.result.ts @@ -0,0 +1,29 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { ISource } from '@services/api/chat-guidance/dto/chat.guidance.query.result.dto'; + +@ObjectType('AiPersonaResult') +export abstract class IAiPersonaQuestionResult { + @Field(() => String, { + nullable: true, + description: 'The id of the answer; null if an error was returned', + }) + id?: string; + + @Field(() => String, { + nullable: false, + description: 'The original question', + }) + question!: string; + + @Field(() => [ISource], { + nullable: true, + description: 'The sources used to answer the question', + }) + sources?: ISource[]; + + @Field(() => String, { + nullable: false, + description: 'The answer to the question', + }) + answer!: string; +} diff --git a/src/services/adapters/virtual-persona-engine-adapter/index.ts b/src/services/adapters/ai-server-adapter/index.ts similarity index 100% rename from src/services/adapters/virtual-persona-engine-adapter/index.ts rename to src/services/adapters/ai-server-adapter/index.ts diff --git a/src/services/adapters/notification-adapter/dto/notification.dto.input.discussion.created.ts b/src/services/adapters/notification-adapter/dto/notification.dto.input.discussion.created.ts index d97c47b4fa..64a667b0cb 100644 --- a/src/services/adapters/notification-adapter/dto/notification.dto.input.discussion.created.ts +++ b/src/services/adapters/notification-adapter/dto/notification.dto.input.discussion.created.ts @@ -1,4 +1,4 @@ -import { IDiscussion } from '@domain/communication/discussion/discussion.interface'; +import { IDiscussion } from '@platform/forum-discussion/discussion.interface'; import { NotificationInputBase } from './notification.dto.input.base'; export interface NotificationInputForumDiscussionCreated diff --git a/src/services/adapters/notification-adapter/dto/notification.dto.input.forum.discussion.comment.ts b/src/services/adapters/notification-adapter/dto/notification.dto.input.forum.discussion.comment.ts index 71b223a85e..2f57f95882 100644 --- a/src/services/adapters/notification-adapter/dto/notification.dto.input.forum.discussion.comment.ts +++ b/src/services/adapters/notification-adapter/dto/notification.dto.input.forum.discussion.comment.ts @@ -1,6 +1,6 @@ import { IMessage } from '@domain/communication/message/message.interface'; import { NotificationInputBase } from './notification.dto.input.base'; -import { IDiscussion } from '@domain/communication/discussion/discussion.interface'; +import { IDiscussion } from '@platform/forum-discussion/discussion.interface'; export interface NotificationInputForumDiscussionComment extends NotificationInputBase { diff --git a/src/services/adapters/notification-adapter/notification.payload.builder.ts b/src/services/adapters/notification-adapter/notification.payload.builder.ts index 31c6fb4606..b322d8e2f3 100644 --- a/src/services/adapters/notification-adapter/notification.payload.builder.ts +++ b/src/services/adapters/notification-adapter/notification.payload.builder.ts @@ -1,12 +1,11 @@ import { ConfigurationTypes, LogContext } from '@common/enums'; import { EntityNotFoundException } from '@common/exceptions'; import { NotificationEventException } from '@common/exceptions/notification.event.exception'; -import { IDiscussion } from '@domain/communication/discussion/discussion.interface'; import { ICommunity } from '@domain/community/community'; import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; -import { EntityManager, Repository } from 'typeorm'; +import { EntityManager, FindOneOptions, Repository } from 'typeorm'; import { Post } from '@domain/collaboration/post/post.entity'; import { CollaborationPostCreatedEventPayload, @@ -51,6 +50,10 @@ import { NotificationInputPostCreated } from './dto/notification.dto.input.post. import { NotificationInputPostComment } from './dto/notification.dto.input.post.comment'; import { ContributionResolverService } from '@services/infrastructure/entity-resolver/contribution.resolver.service'; import { UrlGeneratorService } from '@services/infrastructure/url-generator/url.generator.service'; +import { IDiscussion } from '@platform/forum-discussion/discussion.interface'; +import { IContributor } from '@domain/community/contributor/contributor.interface'; +import { UUID_LENGTH } from '@common/constants/entity.field.length.constants'; +import { VirtualContributor } from '@domain/community/virtual-contributor/virtual.contributor.entity'; @Injectable() export class NotificationPayloadBuilder { @@ -78,7 +81,7 @@ export class NotificationPayloadBuilder { community, applicationCreatorID ); - const applicantPayload = await this.getUserContributorPayloadOrFail( + const applicantPayload = await this.getContributorPayloadOrFail( applicantID ); const payload: CommunityApplicationCreatedEventPayload = { @@ -99,7 +102,7 @@ export class NotificationPayloadBuilder { community, invitationCreatorID ); - const inviteePayload = await this.getUserContributorPayloadOrFail( + const inviteePayload = await this.getContributorPayloadOrFail( invitedUserID ); const payload: CommunityInvitationCreatedEventPayload = { @@ -332,7 +335,7 @@ export class NotificationPayloadBuilder { async buildCommentReplyPayload( data: NotificationInputCommentReply ): Promise { - const userData = await this.getUserContributorPayloadOrFail( + const userData = await this.getContributorPayloadOrFail( data.commentOwnerID ); @@ -385,7 +388,7 @@ export class NotificationPayloadBuilder { community: ICommunity ): Promise { const spacePayload = await this.buildSpacePayload(community, triggeredBy); - const memberPayload = await this.getUserContributorPayloadOrFail(userID); + const memberPayload = await this.getContributorPayloadOrFail(userID); const payload: CommunityNewMemberPayload = { user: memberPayload, ...spacePayload, @@ -426,10 +429,8 @@ export class NotificationPayloadBuilder { role: string ): Promise { const basePayload = this.buildBaseEventPayload(triggeredBy); - const userPayload = await this.getUserContributorPayloadOrFail(userID); - const actorPayload = await this.getUserContributorPayloadOrFail( - triggeredBy - ); + const userPayload = await this.getContributorPayloadOrFail(userID); + const actorPayload = await this.getContributorPayloadOrFail(triggeredBy); const result: PlatformGlobalRoleChangeEventPayload = { user: userPayload, actor: actorPayload, @@ -445,7 +446,7 @@ export class NotificationPayloadBuilder { userID: string ): Promise { const basePayload = this.buildBaseEventPayload(triggeredBy); - const userPayload = await this.getUserContributorPayloadOrFail(userID); + const userPayload = await this.getContributorPayloadOrFail(userID); const result: PlatformUserRegistrationEventPayload = { user: userPayload, @@ -494,9 +495,7 @@ export class NotificationPayloadBuilder { message: string ): Promise { const basePayload = this.buildBaseEventPayload(senderID); - const receiverPayload = await this.getUserContributorPayloadOrFail( - receiverID - ); + const receiverPayload = await this.getContributorPayloadOrFail(receiverID); const payload: CommunicationUserMessageEventPayload = { messageReceiver: receiverPayload, message, @@ -506,51 +505,88 @@ export class NotificationPayloadBuilder { return payload; } - private async getUserContributorPayloadOrFail( - userId: string + private async getContributorPayloadOrFail( + contributorID: string ): Promise { - const user = await this.entityManager.findOne(User, { - where: [ - { - id: userId, - }, - { - nameID: userId, - }, - ], + const contributor = await this.getContributor(contributorID, { relations: { profile: true, }, }); - if (!user || !user.profile) { + if (!contributor || !contributor.profile) { throw new EntityNotFoundException( - `Unable to find User with profile for id: ${userId}`, + `Unable to find Contributor with profile for id: ${contributorID}`, LogContext.COMMUNITY ); } - const userURL = await this.urlGeneratorService.createUrlForUserNameID( - user.nameID + const userURL = await this.urlGeneratorService.createUrlForContributor( + contributor ); const result: ContributorPayload = { - id: user.id, - nameID: user.nameID, + id: contributor.id, + nameID: contributor.nameID, profile: { - displayName: user.profile.displayName, + displayName: contributor.profile.displayName, url: userURL, }, }; return result; } + private async getContributor( + contributorID: string, + options?: FindOneOptions + ): Promise { + let contributor: IContributor | null; + if (contributorID.length === UUID_LENGTH) { + contributor = await this.entityManager.findOne(User, { + ...options, + where: { ...options?.where, id: contributorID }, + }); + if (!contributor) { + contributor = await this.entityManager.findOne(Organization, { + ...options, + where: { ...options?.where, id: contributorID }, + }); + } + if (!contributor) { + contributor = await this.entityManager.findOne(VirtualContributor, { + ...options, + where: { ...options?.where, id: contributorID }, + }); + } + } else { + // look up based on nameID + // toDo https://app.zenhub.com/workspaces/alkemio-development-5ecb98b262ebd9f4aec4194c/issues/gh/alkem-io/server/4126 + contributor = await this.entityManager.findOne(User, { + ...options, + where: { ...options?.where, nameID: contributorID }, + }); + if (!contributor) { + contributor = await this.entityManager.findOne(Organization, { + ...options, + where: { ...options?.where, nameID: contributorID }, + }); + } + if (!contributor) { + contributor = await this.entityManager.findOne(VirtualContributor, { + ...options, + where: { ...options?.where, nameID: contributorID }, + }); + } + } + return contributor; + } + async buildCommunicationOrganizationMessageNotificationPayload( senderID: string, message: string, organizationID: string ): Promise { const basePayload = this.buildBaseEventPayload(senderID); - const orgContribtor = await this.getOrgContributorPayloadOrFail( + const orgContribtor = await this.getContributorPayloadOrFail( organizationID ); const payload: CommunicationOrganizationMessageEventPayload = { @@ -562,37 +598,6 @@ export class NotificationPayloadBuilder { return payload; } - private async getOrgContributorPayloadOrFail( - orgId: string - ): Promise { - const org = await this.entityManager.findOne(Organization, { - where: [{ id: orgId }, { nameID: orgId }], - relations: { - profile: true, - }, - }); - - if (!org || !org.profile) { - throw new EntityNotFoundException( - `Unable to find Organization with id: ${orgId}`, - LogContext.NOTIFICATIONS - ); - } - - const orgURL = - await this.urlGeneratorService.createUrlForOrganizationNameID(org.nameID); - const result: ContributorPayload = { - id: org.id, - nameID: org.nameID, - profile: { - displayName: org.profile.displayName, - url: orgURL, - }, - }; - - return result; - } - async buildCommunicationCommunityLeadsMessageNotificationPayload( senderID: string, message: string, @@ -616,7 +621,7 @@ export class NotificationPayloadBuilder { originEntityDisplayName: string, commentType: RoomType ): Promise { - const userContributor = await this.getUserContributorPayloadOrFail( + const userContributor = await this.getContributorPayloadOrFail( mentionedUserNameID ); @@ -648,9 +653,7 @@ export class NotificationPayloadBuilder { originEntityDisplayName: string, commentType: RoomType ): Promise { - const orgData = await this.getOrgContributorPayloadOrFail( - mentionedOrgNameID - ); + const orgData = await this.getContributorPayloadOrFail(mentionedOrgNameID); const commentOriginUrl = await this.buildCommentOriginUrl( commentType, diff --git a/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.base.response.ts b/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.base.response.ts deleted file mode 100644 index d14bfa95f9..0000000000 --- a/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.base.response.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class VirtualPersonaEngineAdapterBaseResponse { - result!: string; -} diff --git a/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.base.ts b/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.base.ts deleted file mode 100644 index 6b268a45bc..0000000000 --- a/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.base.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { VirtualContributorEngine } from '@common/enums/virtual.contributor.engine'; - -export interface VirtualPersonaEngineAdapterInputBase { - userId: string; - engine: VirtualContributorEngine; -} diff --git a/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.question.input.ts b/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.question.input.ts deleted file mode 100644 index a1e0c54428..0000000000 --- a/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.question.input.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { VirtualPersonaEngineAdapterInputBase } from './virtual.persona.engine.adapter.dto.base'; - -export interface VirtualPersonaEngineAdapterQueryInput - extends VirtualPersonaEngineAdapterInputBase { - question: string; - prompt?: string; - knowledgeSpaceNameID?: string; - contextSpaceNameID?: string; -} diff --git a/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.question.response.ts b/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.question.response.ts deleted file mode 100644 index 1d82c87ef2..0000000000 --- a/src/services/adapters/virtual-persona-engine-adapter/dto/virtual.persona.engine.adapter.dto.question.response.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { VirtualPersonaEngineAdapterBaseResponse } from './virtual.persona.engine.adapter.dto.base.response'; - -export class VirtualPersonaEngineAdapterQueryResponse extends VirtualPersonaEngineAdapterBaseResponse { - answer!: string; - sources?: string; - prompt_tokens!: number; - completion_tokens!: number; - total_tokens!: number; - total_cost!: number; -} diff --git a/src/services/adapters/virtual-persona-engine-adapter/virtual.persona.engine.adapter.module.ts b/src/services/adapters/virtual-persona-engine-adapter/virtual.persona.engine.adapter.module.ts deleted file mode 100644 index cd64cb9066..0000000000 --- a/src/services/adapters/virtual-persona-engine-adapter/virtual.persona.engine.adapter.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { VirtualPersonaEngineAdapter } from './virtual.persona.engine.adapter'; - -@Module({ - providers: [VirtualPersonaEngineAdapter], - exports: [VirtualPersonaEngineAdapter], -}) -export class VirtualPersonaEngineAdapterModule {} diff --git a/src/services/ai-server/ai-persona-engine-adapter/ai.persona.engine.adapter.module.ts b/src/services/ai-server/ai-persona-engine-adapter/ai.persona.engine.adapter.module.ts new file mode 100644 index 0000000000..13e9ace453 --- /dev/null +++ b/src/services/ai-server/ai-persona-engine-adapter/ai.persona.engine.adapter.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AiPersonaEngineAdapter } from './ai.persona.engine.adapter'; + +@Module({ + providers: [AiPersonaEngineAdapter], + exports: [AiPersonaEngineAdapter], +}) +export class AiPersonaEngineAdapterModule {} diff --git a/src/services/adapters/virtual-persona-engine-adapter/virtual.persona.engine.adapter.ts b/src/services/ai-server/ai-persona-engine-adapter/ai.persona.engine.adapter.ts similarity index 73% rename from src/services/adapters/virtual-persona-engine-adapter/virtual.persona.engine.adapter.ts rename to src/services/ai-server/ai-persona-engine-adapter/ai.persona.engine.adapter.ts index 489b5d72e4..2fe7e6121c 100644 --- a/src/services/adapters/virtual-persona-engine-adapter/virtual.persona.engine.adapter.ts +++ b/src/services/ai-server/ai-persona-engine-adapter/ai.persona.engine.adapter.ts @@ -7,18 +7,18 @@ import { VIRTUAL_CONTRIBUTOR_ENGINE_GUIDANCE, VIRTUAL_CONTRIBUTOR_ENGINE_COMMUNITY_MANAGER, } from '@common/constants'; -import { Source } from '../chat-guidance-adapter/source.type'; -import { VirtualPersonaEngineAdapterQueryInput } from './dto/virtual.persona.engine.adapter.dto.question.input'; -import { VirtualPersonaEngineAdapterQueryResponse } from './dto/virtual.persona.engine.adapter.dto.question.response'; +import { Source } from '../../adapters/chat-guidance-adapter/source.type'; +import { AiPersonaEngineAdapterQueryInput } from './dto/ai.persona.engine.adapter.dto.question.input'; +import { AiPersonaEngineAdapterQueryResponse } from './dto/ai.persona.engine.adapter.dto.question.response'; import { LogContext } from '@common/enums/logging.context'; -import { VirtualPersonaEngineAdapterInputBase } from './dto/virtual.persona.engine.adapter.dto.base'; -import { VirtualPersonaEngineAdapterBaseResponse } from './dto/virtual.persona.engine.adapter.dto.base.response'; +import { AiPersonaEngineAdapterInputBase } from './dto/ai.persona.engine.adapter.dto.base'; +import { AiPersonaEngineAdapterBaseResponse } from './dto/ai.persona.engine.adapter.dto.base.response'; import { ChatGuidanceInput } from '@services/api/chat-guidance/dto/chat.guidance.dto.input'; -import { IVirtualPersonaQuestionResult } from '@platform/virtual-persona/dto/virtual.persona.question.dto.result'; -import { VirtualContributorEngine } from '@common/enums/virtual.contributor.engine'; import { ValidationException } from '@common/exceptions'; +import { IAiPersonaQuestionResult } from '@domain/community/ai-persona/dto/ai.persona.question.dto.result'; +import { AiPersonaEngine } from '@common/enums/ai.persona.engine'; -enum VirtualContributorEngineEventType { +enum AiPersonaEngineEventType { QUERY = 'query', INGEST = 'ingest', RESET = 'reset', @@ -28,7 +28,7 @@ const successfulIngestionResponse = 'Ingest successful'; const successfulResetResponse = 'Reset function executed'; @Injectable() -export class VirtualPersonaEngineAdapter { +export class AiPersonaEngineAdapter { constructor( @Inject(VIRTUAL_CONTRIBUTOR_ENGINE_COMMUNITY_MANAGER) private virtualContributorEngineCommunityManager: ClientProxy, @@ -41,13 +41,13 @@ export class VirtualPersonaEngineAdapter { ) {} public async sendQuery( - eventData: VirtualPersonaEngineAdapterQueryInput - ): Promise { - let responseData: VirtualPersonaEngineAdapterQueryResponse | undefined; + eventData: AiPersonaEngineAdapterQueryInput + ): Promise { + let responseData: AiPersonaEngineAdapterQueryResponse | undefined; try { switch (eventData.engine) { - case VirtualContributorEngine.COMMUNITY_MANAGER: + case AiPersonaEngine.COMMUNITY_MANAGER: if (!eventData.prompt) throw new ValidationException( 'Prompt property is required for community manager engine!', @@ -55,28 +55,28 @@ export class VirtualPersonaEngineAdapter { ); const responseCommunityManager = this.virtualContributorEngineCommunityManager.send< - VirtualPersonaEngineAdapterQueryResponse, - VirtualPersonaEngineAdapterQueryInput - >({ cmd: VirtualContributorEngineEventType.QUERY }, eventData); + AiPersonaEngineAdapterQueryResponse, + AiPersonaEngineAdapterQueryInput + >({ cmd: AiPersonaEngineEventType.QUERY }, eventData); responseData = await firstValueFrom(responseCommunityManager); break; - case VirtualContributorEngine.EXPERT: + case AiPersonaEngine.EXPERT: if (!eventData.contextSpaceNameID || !eventData.knowledgeSpaceNameID) throw new ValidationException( 'ContextSpaceNameID and knowledgeSpaceNameID properties are required for expert engine!', LogContext.VIRTUAL_CONTRIBUTOR_ENGINE ); const responseExpert = this.virtualContributorEngineExpert.send< - VirtualPersonaEngineAdapterQueryResponse, - VirtualPersonaEngineAdapterQueryInput - >({ cmd: VirtualContributorEngineEventType.QUERY }, eventData); + AiPersonaEngineAdapterQueryResponse, + AiPersonaEngineAdapterQueryInput + >({ cmd: AiPersonaEngineEventType.QUERY }, eventData); responseData = await firstValueFrom(responseExpert); break; - case VirtualContributorEngine.GUIDANCE: + case AiPersonaEngine.GUIDANCE: const responseGuidance = this.virtualContributorEngineGuidance.send< - VirtualPersonaEngineAdapterQueryResponse, + AiPersonaEngineAdapterQueryResponse, ChatGuidanceInput - >({ cmd: VirtualContributorEngineEventType.QUERY }, { + >({ cmd: AiPersonaEngineEventType.QUERY }, { ...eventData, language: 'EN', } as ChatGuidanceInput); @@ -144,16 +144,16 @@ export class VirtualPersonaEngineAdapter { } public async sendReset( - eventData: VirtualPersonaEngineAdapterInputBase + eventData: AiPersonaEngineAdapterInputBase ): Promise { const response = this.virtualContributorEngineCommunityManager.send( - { cmd: VirtualContributorEngineEventType.RESET }, + { cmd: AiPersonaEngineEventType.RESET }, eventData ); try { const responseData = - await firstValueFrom(response); + await firstValueFrom(response); return responseData.result === successfulResetResponse; } catch (err: any) { @@ -167,16 +167,16 @@ export class VirtualPersonaEngineAdapter { } public async sendIngest( - eventData: VirtualPersonaEngineAdapterInputBase + eventData: AiPersonaEngineAdapterInputBase ): Promise { const response = this.virtualContributorEngineCommunityManager.send( - { cmd: VirtualContributorEngineEventType.INGEST }, + { cmd: AiPersonaEngineEventType.INGEST }, eventData ); try { const responseData = - await firstValueFrom(response); + await firstValueFrom(response); return responseData.result === successfulIngestionResponse; } catch (err: any) { this.logger.error( diff --git a/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.base.response.ts b/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.base.response.ts new file mode 100644 index 0000000000..f92daf639e --- /dev/null +++ b/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.base.response.ts @@ -0,0 +1,3 @@ +export class AiPersonaEngineAdapterBaseResponse { + result!: string; +} diff --git a/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.base.ts b/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.base.ts new file mode 100644 index 0000000000..a5ac2a361d --- /dev/null +++ b/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.base.ts @@ -0,0 +1,6 @@ +import { AiPersonaEngine } from '@common/enums/ai.persona.engine'; + +export interface AiPersonaEngineAdapterInputBase { + userId: string; + engine: AiPersonaEngine; +} diff --git a/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.question.input.ts b/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.question.input.ts new file mode 100644 index 0000000000..283772874c --- /dev/null +++ b/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.question.input.ts @@ -0,0 +1,9 @@ +import { AiPersonaEngineAdapterInputBase } from './ai.persona.engine.adapter.dto.base'; + +export interface AiPersonaEngineAdapterQueryInput + extends AiPersonaEngineAdapterInputBase { + question: string; + prompt?: string; + contextSpaceNameID?: string; + knowledgeSpaceNameID?: string; +} diff --git a/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.question.response.ts b/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.question.response.ts new file mode 100644 index 0000000000..947db45a5d --- /dev/null +++ b/src/services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.question.response.ts @@ -0,0 +1,10 @@ +import { AiPersonaEngineAdapterBaseResponse } from './ai.persona.engine.adapter.dto.base.response'; + +export class AiPersonaEngineAdapterQueryResponse extends AiPersonaEngineAdapterBaseResponse { + answer!: string; + sources?: string; + prompt_tokens!: number; + completion_tokens!: number; + total_tokens!: number; + total_cost!: number; +} diff --git a/src/services/ai-server/ai-persona-engine-adapter/index.ts b/src/services/ai-server/ai-persona-engine-adapter/index.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/platform/virtual-persona/virtual.persona.service.authorization.ts b/src/services/ai-server/ai-persona-service/ai.persona.service.authorization.ts similarity index 75% rename from src/platform/virtual-persona/virtual.persona.service.authorization.ts rename to src/services/ai-server/ai-persona-service/ai.persona.service.authorization.ts index 55be6a8516..3f9817adc0 100644 --- a/src/platform/virtual-persona/virtual.persona.service.authorization.ts +++ b/src/services/ai-server/ai-persona-service/ai.persona.service.authorization.ts @@ -1,13 +1,12 @@ import { Injectable } from '@nestjs/common'; import { AuthorizationCredential, LogContext } from '@common/enums'; import { AuthorizationPrivilege } from '@common/enums'; -import { IAuthorizationPolicy } from '@domain/common/authorization-policy'; import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; import { EntityNotInitializedException, RelationshipNotFoundException, } from '@common/exceptions'; -import { VirtualPersonaService } from './virtual.persona.service'; +import { AiPersonaServiceService } from './ai.persona.service.service'; import { IAuthorizationPolicyRuleCredential } from '@core/authorization/authorization.policy.rule.credential.interface'; import { CREDENTIAL_RULE_TYPES_ORGANIZATION_AUTHORIZATION_RESET, @@ -17,66 +16,51 @@ import { CREDENTIAL_RULE_ORGANIZATION_READ, CREDENTIAL_RULE_ORGANIZATION_SELF_REMOVAL, } from '@common/constants'; -import { IVirtualPersona } from './virtual.persona.interface'; -import { ProfileAuthorizationService } from '@domain/common/profile/profile.service.authorization'; +import { IAiPersonaService } from './ai.persona.service.interface'; +import { IAuthorizationPolicy } from '@domain/common/authorization-policy/authorization.policy.interface'; @Injectable() -export class VirtualPersonaAuthorizationService { +export class AiPersonaServiceAuthorizationService { constructor( - private virtualPersonaService: VirtualPersonaService, + private aiPersonaServiceService: AiPersonaServiceService, private authorizationPolicy: AuthorizationPolicyService, - private authorizationPolicyService: AuthorizationPolicyService, - private profileAuthorizationService: ProfileAuthorizationService + private authorizationPolicyService: AuthorizationPolicyService ) {} async applyAuthorizationPolicy( - virtualPersonaInput: IVirtualPersona, + aiPersonaServiceInput: IAiPersonaService, parentAuthorization: IAuthorizationPolicy | undefined - ): Promise { - const virtualPersona = - await this.virtualPersonaService.getVirtualPersonaOrFail( - virtualPersonaInput.id, + ): Promise { + const aiPersonaService = + await this.aiPersonaServiceService.getAiPersonaServiceOrFail( + aiPersonaServiceInput.id, { relations: { authorization: true, - profile: true, }, } ); - if (!virtualPersona.authorization) + if (!aiPersonaService.authorization) throw new RelationshipNotFoundException( - `Unable to load entities for virtual persona: ${virtualPersona.id} `, + `Unable to load entities for AI Persona Service: ${aiPersonaService.id} `, LogContext.COMMUNITY ); - virtualPersona.authorization = await this.authorizationPolicyService.reset( - virtualPersona.authorization - ); + aiPersonaService.authorization = + await this.authorizationPolicyService.reset( + aiPersonaService.authorization + ); - virtualPersona.authorization = + aiPersonaService.authorization = this.authorizationPolicyService.inheritParentAuthorization( - virtualPersona.authorization, + aiPersonaService.authorization, parentAuthorization ); - virtualPersona.authorization = this.appendCredentialRules( - virtualPersona.authorization, - virtualPersona.id + aiPersonaService.authorization = this.appendCredentialRules( + aiPersonaService.authorization, + aiPersonaService.id ); - // NOTE: Clone the authorization policy to ensure the changes are local to profile - const clonedAnonymousReadAccessAuthorization = - this.authorizationPolicyService.cloneAuthorizationPolicy( - virtualPersona.authorization - ); - // To ensure that profile + context on a space are always publicly visible, even for private spaces - clonedAnonymousReadAccessAuthorization.anonymousReadAccess = true; - // cascade - virtualPersona.profile = - await this.profileAuthorizationService.applyAuthorizationPolicy( - virtualPersona.profile, - clonedAnonymousReadAccessAuthorization // Key that this is publicly visible - ); - - return virtualPersona; + return aiPersonaService; } private appendCredentialRules( @@ -186,7 +170,7 @@ export class VirtualPersonaAuthorizationService { } public extendAuthorizationPolicyForSelfRemoval( - virtual: IVirtualPersona, + virtual: IAiPersonaService, userToBeRemovedID: string ): IAuthorizationPolicy { const newRules: IAuthorizationPolicyRuleCredential[] = []; diff --git a/src/services/ai-server/ai-persona-service/ai.persona.service.entity.ts b/src/services/ai-server/ai-persona-service/ai.persona.service.entity.ts new file mode 100644 index 0000000000..a7b6b7d5a4 --- /dev/null +++ b/src/services/ai-server/ai-persona-service/ai.persona.service.entity.ts @@ -0,0 +1,40 @@ +import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { IAiPersonaService } from './ai.persona.service.interface'; +import { AuthorizableEntity } from '@domain/common/entity/authorizable-entity'; +import { AiServer } from '../ai-server/ai.server.entity'; +import { AiPersonaDataAccessMode } from '@common/enums/ai.persona.data.access.mode'; +import { AiPersonaBodyOfKnowledgeType } from '@common/enums/ai.persona.body.of.knowledge.type'; +import { AiPersonaEngine } from '@common/enums/ai.persona.engine'; + +@Entity() +export class AiPersonaService + extends AuthorizableEntity + implements IAiPersonaService +{ + @ManyToOne(() => AiServer, aiServer => aiServer.aiPersonaServices, { + eager: true, + }) + @JoinColumn() + aiServer?: AiServer; + + @Column({ length: 128, nullable: false }) + engine!: AiPersonaEngine; + + @Column({ + length: 64, + nullable: false, + default: AiPersonaDataAccessMode.SPACE_PROFILE, + }) + dataAccessMode!: AiPersonaDataAccessMode; + + @Column('text', { nullable: false }) + prompt!: string; + + @Column({ length: 64, nullable: true }) + bodyOfKnowledgeType!: AiPersonaBodyOfKnowledgeType; + + @Column({ length: 255, nullable: true }) + bodyOfKnowledgeID!: string; + + // TODO: last updated embeddings +} diff --git a/src/services/ai-server/ai-persona-service/ai.persona.service.interface.ts b/src/services/ai-server/ai-persona-service/ai.persona.service.interface.ts new file mode 100644 index 0000000000..6eba14e9c4 --- /dev/null +++ b/src/services/ai-server/ai-persona-service/ai.persona.service.interface.ts @@ -0,0 +1,42 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { IAuthorizable } from '@domain/common/entity/authorizable-entity'; +import { UUID } from '@domain/common/scalars/scalar.uuid'; +import { AiPersonaDataAccessMode } from '@common/enums/ai.persona.data.access.mode'; +import { IAiServer } from '../ai-server/ai.server.interface'; +import { AiPersonaBodyOfKnowledgeType } from '@common/enums/ai.persona.body.of.knowledge.type'; +import { AiPersonaEngine } from '@common/enums/ai.persona.engine'; + +@ObjectType('AiPersonaService') +export class IAiPersonaService extends IAuthorizable { + aiServer?: IAiServer; + + @Field(() => AiPersonaEngine, { + nullable: false, + description: 'The AI Persona Engine being used by this AI Persona.', + }) + engine!: AiPersonaEngine; + + @Field(() => String, { + nullable: false, + description: 'The prompt used by this Virtual Persona', + }) + prompt!: string; + + @Field(() => AiPersonaDataAccessMode, { + nullable: false, + description: 'The required data access by the Virtual Persona', + }) + dataAccessMode!: AiPersonaDataAccessMode; + + @Field(() => AiPersonaBodyOfKnowledgeType, { + nullable: true, + description: 'The body of knowledge type used for the AI Persona Service', + }) + bodyOfKnowledgeType!: AiPersonaBodyOfKnowledgeType; + + @Field(() => UUID, { + nullable: true, + description: 'The body of knowledge ID used for the AI Persona Service', + }) + bodyOfKnowledgeID!: string; +} diff --git a/src/services/ai-server/ai-persona-service/ai.persona.service.module.ts b/src/services/ai-server/ai-persona-service/ai.persona.service.module.ts new file mode 100644 index 0000000000..8202b86208 --- /dev/null +++ b/src/services/ai-server/ai-persona-service/ai.persona.service.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { AiPersonaServiceService } from './ai.persona.service.service'; +import { AiPersonaServiceResolverMutations } from './ai.persona.service.resolver.mutations'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AiPersonaServiceAuthorizationService } from './ai.persona.service.authorization'; +import { AuthorizationModule } from '@core/authorization/authorization.module'; +import { AuthorizationPolicyModule } from '@domain/common/authorization-policy/authorization.policy.module'; +import { AiPersonaService } from './ai.persona.service.entity'; +import { AiPersonaServiceResolverFields } from './ai.persona.service.resolver.fields'; +import { AiPersonaEngineAdapterModule } from '../ai-persona-engine-adapter/ai.persona.engine.adapter.module'; + +@Module({ + imports: [ + AuthorizationPolicyModule, + AuthorizationModule, + TypeOrmModule.forFeature([AiPersonaService]), + AiPersonaServiceModule, + AiPersonaEngineAdapterModule, + ], + providers: [ + AiPersonaServiceService, + AiPersonaServiceAuthorizationService, + AiPersonaServiceResolverMutations, + AiPersonaServiceResolverFields, + ], + exports: [AiPersonaServiceService, AiPersonaServiceAuthorizationService], +}) +export class AiPersonaServiceModule {} diff --git a/src/services/ai-server/ai-persona-service/ai.persona.service.resolver.fields.ts b/src/services/ai-server/ai-persona-service/ai.persona.service.resolver.fields.ts new file mode 100644 index 0000000000..0fe335269f --- /dev/null +++ b/src/services/ai-server/ai-persona-service/ai.persona.service.resolver.fields.ts @@ -0,0 +1,44 @@ +import { UseGuards } from '@nestjs/common'; +import { Resolver } from '@nestjs/graphql'; +import { Parent, ResolveField } from '@nestjs/graphql'; +import { AiPersonaService } from './ai.persona.service.entity'; +import { AiPersonaServiceService } from './ai.persona.service.service'; +import { AuthorizationPrivilege } from '@common/enums'; +import { GraphqlGuard } from '@core/authorization'; +import { CurrentUser, Profiling } from '@common/decorators'; +import { AuthorizationService } from '@core/authorization/authorization.service'; +import { IAuthorizationPolicy } from '@domain/common/authorization-policy'; +import { IAiPersonaService } from './ai.persona.service.interface'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; + +@Resolver(() => IAiPersonaService) +export class AiPersonaServiceResolverFields { + constructor( + private authorizationService: AuthorizationService, + private aiPersonaServiceService: AiPersonaServiceService + ) {} + + @UseGuards(GraphqlGuard) + @ResolveField('authorization', () => IAuthorizationPolicy, { + nullable: true, + description: 'The Authorization for this Virtual.', + }) + @Profiling.api + async authorization( + @Parent() parent: AiPersonaService, + @CurrentUser() agentInfo: AgentInfo + ) { + // Reload to ensure the authorization is loaded + const aiPersonaService = + await this.aiPersonaServiceService.getAiPersonaServiceOrFail(parent.id); + + this.authorizationService.grantAccessOrFail( + agentInfo, + aiPersonaService.authorization, + AuthorizationPrivilege.READ, + `ai persona authorization access: ${aiPersonaService.id}` + ); + + return aiPersonaService.authorization; + } +} diff --git a/src/services/ai-server/ai-persona-service/ai.persona.service.resolver.mutations.ts b/src/services/ai-server/ai-persona-service/ai.persona.service.resolver.mutations.ts new file mode 100644 index 0000000000..d4e422decc --- /dev/null +++ b/src/services/ai-server/ai-persona-service/ai.persona.service.resolver.mutations.ts @@ -0,0 +1,95 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Resolver, Mutation } from '@nestjs/graphql'; +import { AiPersonaServiceService } from './ai.persona.service.service'; +import { CurrentUser, Profiling } from '@src/common/decorators'; +import { GraphqlGuard } from '@core/authorization'; +import { AuthorizationPrivilege } from '@common/enums'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { AuthorizationService } from '@core/authorization/authorization.service'; +import { IAiPersonaService } from './ai.persona.service.interface'; +import { + DeleteAiPersonaServiceInput, + UpdateAiPersonaServiceInput, +} from './dto'; +import { AiPersonaServiceIngestInput } from './dto/ai.persona.service.dto.ingest'; + +@Resolver(() => IAiPersonaService) +export class AiPersonaServiceResolverMutations { + constructor( + private aiPersonaServiceService: AiPersonaServiceService, + private authorizationService: AuthorizationService + ) {} + + @UseGuards(GraphqlGuard) + @Mutation(() => IAiPersonaService, { + description: 'Updates the specified AI Persona.', + }) + @Profiling.api + async aiServerUpdateAiPersonaService( + @CurrentUser() agentInfo: AgentInfo, + @Args('aiPersonaServiceData') + aiPersonaServiceData: UpdateAiPersonaServiceInput + ): Promise { + const aiPersonaService = + await this.aiPersonaServiceService.getAiPersonaServiceOrFail( + aiPersonaServiceData.ID + ); + await this.authorizationService.grantAccessOrFail( + agentInfo, + aiPersonaService.authorization, + AuthorizationPrivilege.UPDATE, + `orgUpdate: ${aiPersonaService.id}` + ); + + return await this.aiPersonaServiceService.updateAiPersonaService( + aiPersonaServiceData + ); + } + + @UseGuards(GraphqlGuard) + @Mutation(() => IAiPersonaService, { + description: 'Deletes the specified AiPersonaService.', + }) + async aiServerDeleteAiPersonaService( + @CurrentUser() agentInfo: AgentInfo, + @Args('deleteData') deleteData: DeleteAiPersonaServiceInput + ): Promise { + const aiPersonaService = + await this.aiPersonaServiceService.getAiPersonaServiceOrFail( + deleteData.ID + ); + await this.authorizationService.grantAccessOrFail( + agentInfo, + aiPersonaService.authorization, + AuthorizationPrivilege.DELETE, + `deleteOrg: ${aiPersonaService.id}` + ); + return await this.aiPersonaServiceService.deleteAiPersonaService( + deleteData + ); + } + + @UseGuards(GraphqlGuard) + @Mutation(() => Boolean, { + description: + 'Trigger an ingesting of data on the remove AI Persona Service.', + }) + async aiServerPersonaServiceIngest( + @CurrentUser() agentInfo: AgentInfo, + @Args('ingestData') + aiPersonaIngestData: AiPersonaServiceIngestInput + ): Promise { + const aiPersonaService = + await this.aiPersonaServiceService.getAiPersonaServiceOrFail( + aiPersonaIngestData.aiPersonaServiceID + ); + + await this.authorizationService.grantAccessOrFail( + agentInfo, + aiPersonaService.authorization, + AuthorizationPrivilege.UPDATE, // TODO: Separate privilege + `ingesting on ai persona service: ${aiPersonaService.id}` + ); + return this.aiPersonaServiceService.ingest(aiPersonaService); + } +} diff --git a/src/services/ai-server/ai-persona-service/ai.persona.service.service.ts b/src/services/ai-server/ai-persona-service/ai.persona.service.service.ts new file mode 100644 index 0000000000..f113e0e91f --- /dev/null +++ b/src/services/ai-server/ai-persona-service/ai.persona.service.service.ts @@ -0,0 +1,176 @@ +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { FindOneOptions, Repository } from 'typeorm'; +import { EntityNotFoundException } from '@common/exceptions'; +import { AuthorizationPolicy } from '@domain/common/authorization-policy'; +import { AiPersonaService } from './ai.persona.service.entity'; +import { IAiPersonaService } from './ai.persona.service.interface'; +import { CreateAiPersonaServiceInput as CreateAiPersonaServiceInput } from './dto/ai.persona.service.dto.create'; +import { DeleteAiPersonaServiceInput as DeleteAiPersonaServiceInput } from './dto/ai.persona..service.dto.delete'; +import { UpdateAiPersonaServiceInput } from './dto/ai.persona.service.dto.update'; +import { IAiPersonaServiceQuestionResult } from './dto/ai.persona.service.question.dto.result'; +import { AiPersonaServiceQuestionInput } from './dto/ai.persona.service.question.dto.input'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { LogContext } from '@common/enums/logging.context'; +import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; +import { AiPersonaEngineAdapterQueryInput } from '@services/ai-server/ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.question.input'; +import { AiPersonaEngineAdapter } from '@services/ai-server/ai-persona-engine-adapter/ai.persona.engine.adapter'; +import { AiPersonaEngine } from '@common/enums/ai.persona.engine'; +import { EventBus } from '@nestjs/cqrs'; +import { + IngestSpace, + SpaceIngestionPurpose, +} from '@services/infrastructure/event-bus/commands'; +import { AiPersonaBodyOfKnowledgeType } from '@common/enums/ai.persona.body.of.knowledge.type'; + +@Injectable() +export class AiPersonaServiceService { + constructor( + private authorizationPolicyService: AuthorizationPolicyService, + private aiPersonaEngineAdapter: AiPersonaEngineAdapter, + private eventBus: EventBus, + @InjectRepository(AiPersonaService) + private aiPersonaServiceRepository: Repository, + @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService + ) {} + + async createAiPersonaService( + aiPersonaServiceData: CreateAiPersonaServiceInput + ): Promise { + const aiPersonaService: IAiPersonaService = new AiPersonaService(); + // TODO: map in the data AiPersonaService.create(aiPersonaServiceData); + aiPersonaService.authorization = new AuthorizationPolicy(); + + aiPersonaService.bodyOfKnowledgeID = aiPersonaServiceData.bodyOfKnowledgeID; + aiPersonaService.engine = + aiPersonaServiceData.engine ?? AiPersonaEngine.EXPERT; + aiPersonaService.bodyOfKnowledgeType = + aiPersonaServiceData.bodyOfKnowledgeType ?? + AiPersonaBodyOfKnowledgeType.ALKEMIO_SPACE; + aiPersonaService.prompt = aiPersonaServiceData.prompt ?? ''; + + const savedAiPersonaService = await this.aiPersonaServiceRepository.save( + aiPersonaService + ); + this.logger.verbose?.( + `Created new AI Persona Service with id ${aiPersonaService.id}`, + LogContext.PLATFORM + ); + + if (aiPersonaServiceData.bodyOfKnowledgeID) + this.eventBus.publish( + new IngestSpace( + aiPersonaServiceData.bodyOfKnowledgeID, + SpaceIngestionPurpose.KNOWLEDGE + ) + ); + + return savedAiPersonaService; + } + + async updateAiPersonaService( + aiPersonaServiceData: UpdateAiPersonaServiceInput + ): Promise { + const aiPersonaService = await this.getAiPersonaServiceOrFail( + aiPersonaServiceData.ID + ); + + if (aiPersonaServiceData.prompt !== undefined) { + aiPersonaService.prompt = aiPersonaServiceData.prompt; + } + + if (aiPersonaServiceData.engine !== undefined) { + aiPersonaService.engine = aiPersonaServiceData.engine; + } + + return await this.aiPersonaServiceRepository.save(aiPersonaService); + } + + async deleteAiPersonaService( + deleteData: DeleteAiPersonaServiceInput + ): Promise { + const personaID = deleteData.ID; + + const aiPersonaService = await this.getAiPersonaServiceOrFail(personaID, { + relations: { + authorization: true, + }, + }); + if (!aiPersonaService.authorization) { + throw new EntityNotFoundException( + `Unable to find all fields on Virtual Persona with ID: ${deleteData.ID}`, + LogContext.PLATFORM + ); + } + await this.authorizationPolicyService.delete( + aiPersonaService.authorization + ); + const result = await this.aiPersonaServiceRepository.remove( + aiPersonaService as AiPersonaService + ); + result.id = personaID; + return result; + } + + public async getAiPersonaService( + aiPersonaServiceID: string, + options?: FindOneOptions + ): Promise { + const aiPersonaService = await this.aiPersonaServiceRepository.findOne({ + ...options, + where: { ...options?.where, id: aiPersonaServiceID }, + }); + + return aiPersonaService; + } + + public async getAiPersonaServiceOrFail( + virtualID: string, + options?: FindOneOptions + ): Promise { + const aiPersonaService = await this.getAiPersonaService(virtualID, options); + if (!aiPersonaService) + throw new EntityNotFoundException( + `Unable to find Virtual Persona with ID: ${virtualID}`, + LogContext.PLATFORM + ); + return aiPersonaService; + } + + async save(aiPersonaService: IAiPersonaService): Promise { + return await this.aiPersonaServiceRepository.save(aiPersonaService); + } + + public async askQuestion( + personaQuestionInput: AiPersonaServiceQuestionInput, + agentInfo: AgentInfo, + contextSpaceNameID: string + ): Promise { + const aiPersonaService = await this.getAiPersonaServiceOrFail( + personaQuestionInput.aiPersonaServiceID + ); + + const input: AiPersonaEngineAdapterQueryInput = { + engine: aiPersonaService.engine, + prompt: aiPersonaService.prompt, + userId: agentInfo.userID, + question: personaQuestionInput.question, + knowledgeSpaceNameID: aiPersonaService.bodyOfKnowledgeID, + contextSpaceNameID, + }; + + this.logger.error(input); + const response = await this.aiPersonaEngineAdapter.sendQuery(input); + + return response; + } + + public async ingest(aiPersonaService: IAiPersonaService): Promise { + // Todo: ??? + return this.aiPersonaEngineAdapter.sendIngest({ + engine: AiPersonaEngine.EXPERT, + userId: aiPersonaService.id, // TODO: clearly wrong, just getting code to compile + }); + } +} diff --git a/src/services/ai-server/ai-persona-service/dto/ai.persona..service.dto.delete.ts b/src/services/ai-server/ai-persona-service/dto/ai.persona..service.dto.delete.ts new file mode 100644 index 0000000000..877446335e --- /dev/null +++ b/src/services/ai-server/ai-persona-service/dto/ai.persona..service.dto.delete.ts @@ -0,0 +1,9 @@ +import { DeleteBaseAlkemioInput } from '@domain/common/entity/base-entity'; +import { UUID } from '@domain/common/scalars'; +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class DeleteAiPersonaServiceInput extends DeleteBaseAlkemioInput { + @Field(() => UUID, { nullable: false }) + ID!: string; +} diff --git a/src/services/ai-server/ai-persona-service/dto/ai.persona.service.dto.create.ts b/src/services/ai-server/ai-persona-service/dto/ai.persona.service.dto.create.ts new file mode 100644 index 0000000000..b75d2589f7 --- /dev/null +++ b/src/services/ai-server/ai-persona-service/dto/ai.persona.service.dto.create.ts @@ -0,0 +1,33 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { MaxLength } from 'class-validator'; +import { LONG_TEXT_LENGTH, SMALL_TEXT_LENGTH } from '@src/common/constants'; +import JSON from 'graphql-type-json'; +import { AiPersonaEngine } from '@common/enums/ai.persona.engine'; +import { UUID } from '@domain/common/scalars'; +import { AiPersonaBodyOfKnowledgeType } from '@common/enums/ai.persona.body.of.knowledge.type'; +import { AiPersonaDataAccessMode } from '@common/enums/ai.persona.data.access.mode'; + +@InputType() +export class CreateAiPersonaServiceInput { + @Field(() => AiPersonaEngine, { nullable: true }) + @MaxLength(SMALL_TEXT_LENGTH) + engine?: AiPersonaEngine = AiPersonaEngine.EXPERT; + + @Field(() => JSON, { nullable: true }) + @MaxLength(LONG_TEXT_LENGTH) + prompt?: string = ''; + + @Field(() => AiPersonaDataAccessMode, { nullable: true }) + @MaxLength(SMALL_TEXT_LENGTH) + dataAccessMode?: AiPersonaDataAccessMode = + AiPersonaDataAccessMode.SPACE_PROFILE_AND_CONTENTS; + + @Field(() => AiPersonaBodyOfKnowledgeType, { nullable: true }) + @MaxLength(SMALL_TEXT_LENGTH) + bodyOfKnowledgeType?: AiPersonaBodyOfKnowledgeType = + AiPersonaBodyOfKnowledgeType.ALKEMIO_SPACE; + + @Field(() => UUID, { nullable: true }) + @MaxLength(SMALL_TEXT_LENGTH) + bodyOfKnowledgeID: string = ''; +} diff --git a/src/services/ai-server/ai-persona-service/dto/ai.persona.service.dto.ingest.ts b/src/services/ai-server/ai-persona-service/dto/ai.persona.service.dto.ingest.ts new file mode 100644 index 0000000000..551a9ac5e8 --- /dev/null +++ b/src/services/ai-server/ai-persona-service/dto/ai.persona.service.dto.ingest.ts @@ -0,0 +1,11 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { MaxLength } from 'class-validator'; +import { UUID_LENGTH } from '@src/common/constants'; +import { UUID } from '@domain/common/scalars'; + +@InputType() +export class AiPersonaServiceIngestInput { + @Field(() => UUID, { nullable: false }) + @MaxLength(UUID_LENGTH) + aiPersonaServiceID!: string; +} diff --git a/src/platform/virtual-persona/dto/virtual.persona.dto.create.ts b/src/services/ai-server/ai-persona-service/dto/ai.persona.service.dto.update.ts similarity index 51% rename from src/platform/virtual-persona/dto/virtual.persona.dto.create.ts rename to src/services/ai-server/ai-persona-service/dto/ai.persona.service.dto.update.ts index 7bef00489f..28a1953d05 100644 --- a/src/platform/virtual-persona/dto/virtual.persona.dto.create.ts +++ b/src/services/ai-server/ai-persona-service/dto/ai.persona.service.dto.update.ts @@ -1,15 +1,15 @@ import { Field, InputType } from '@nestjs/graphql'; import { MaxLength } from 'class-validator'; import { LONG_TEXT_LENGTH, SMALL_TEXT_LENGTH } from '@src/common/constants'; -import { CreateNameableInput } from '@domain/common/entity/nameable-entity'; -import { VirtualContributorEngine } from '@common/enums/virtual.contributor.engine'; import JSON from 'graphql-type-json'; +import { AiPersonaEngine } from '@common/enums/ai.persona.engine'; +import { UpdateBaseAlkemioInput } from '@domain/common/entity/base-entity'; @InputType() -export class CreateVirtualPersonaInput extends CreateNameableInput { - @Field(() => VirtualContributorEngine, { nullable: false }) +export class UpdateAiPersonaServiceInput extends UpdateBaseAlkemioInput { + @Field(() => AiPersonaEngine, { nullable: false }) @MaxLength(SMALL_TEXT_LENGTH) - engine!: VirtualContributorEngine; + engine!: AiPersonaEngine; @Field(() => JSON, { nullable: true }) @MaxLength(LONG_TEXT_LENGTH) diff --git a/src/services/ai-server/ai-persona-service/dto/ai.persona.service.question.dto.input.ts b/src/services/ai-server/ai-persona-service/dto/ai.persona.service.question.dto.input.ts new file mode 100644 index 0000000000..18f16862e9 --- /dev/null +++ b/src/services/ai-server/ai-persona-service/dto/ai.persona.service.question.dto.input.ts @@ -0,0 +1,17 @@ +import { UUID } from '@domain/common/scalars'; +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class AiPersonaServiceQuestionInput { + @Field(() => UUID, { + nullable: false, + description: 'Virtual Persona Type.', + }) + aiPersonaServiceID!: string; + + @Field(() => String, { + nullable: false, + description: 'The question that is being asked.', + }) + question!: string; +} diff --git a/src/services/ai-server/ai-persona-service/dto/ai.persona.service.question.dto.result.ts b/src/services/ai-server/ai-persona-service/dto/ai.persona.service.question.dto.result.ts new file mode 100644 index 0000000000..afbc1ab284 --- /dev/null +++ b/src/services/ai-server/ai-persona-service/dto/ai.persona.service.question.dto.result.ts @@ -0,0 +1,29 @@ +import { Field, ObjectType } from '@nestjs/graphql'; +import { ISource } from '@services/api/chat-guidance/dto/chat.guidance.query.result.dto'; + +@ObjectType('AiPersonaServiceResult') +export abstract class IAiPersonaServiceQuestionResult { + @Field(() => String, { + nullable: true, + description: 'The id of the answer; null if an error was returned', + }) + id?: string; + + @Field(() => String, { + nullable: false, + description: 'The original question', + }) + question!: string; + + @Field(() => [ISource], { + nullable: true, + description: 'The sources used to answer the question', + }) + sources?: ISource[]; + + @Field(() => String, { + nullable: false, + description: 'The answer to the question', + }) + answer!: string; +} diff --git a/src/services/ai-server/ai-persona-service/dto/index.ts b/src/services/ai-server/ai-persona-service/dto/index.ts new file mode 100644 index 0000000000..6caa722ecb --- /dev/null +++ b/src/services/ai-server/ai-persona-service/dto/index.ts @@ -0,0 +1,3 @@ +export * from './ai.persona.service.dto.create'; +export * from './ai.persona.service.dto.update'; +export * from './ai.persona..service.dto.delete'; diff --git a/src/services/ai-server/ai-persona-service/index.ts b/src/services/ai-server/ai-persona-service/index.ts new file mode 100644 index 0000000000..2f1d1e3b0c --- /dev/null +++ b/src/services/ai-server/ai-persona-service/index.ts @@ -0,0 +1,2 @@ +export * from './ai.persona.service.entity'; +export * from './ai.persona.service.interface'; diff --git a/src/services/ai-server/ai-server/ai.server.entity.ts b/src/services/ai-server/ai-server/ai.server.entity.ts new file mode 100644 index 0000000000..1ea7ed2bad --- /dev/null +++ b/src/services/ai-server/ai-server/ai.server.entity.ts @@ -0,0 +1,17 @@ +import { AuthorizableEntity } from '@domain/common/entity/authorizable-entity'; +import { Entity, OneToMany } from 'typeorm'; +import { AiPersonaService } from '../ai-persona-service/ai.persona.service.entity'; +import { IAiServer } from './ai.server.interface'; + +@Entity() +export class AiServer extends AuthorizableEntity implements IAiServer { + @OneToMany( + () => AiPersonaService, + personaService => personaService.aiServer, + { + eager: false, + cascade: true, + } + ) + aiPersonaServices!: AiPersonaService[]; +} diff --git a/src/services/ai-server/ai-server/ai.server.interface.ts b/src/services/ai-server/ai-server/ai.server.interface.ts new file mode 100644 index 0000000000..d15cc8d120 --- /dev/null +++ b/src/services/ai-server/ai-server/ai.server.interface.ts @@ -0,0 +1,9 @@ +import { IAuthorizable } from '@domain/common/entity/authorizable-entity'; +import { ObjectType } from '@nestjs/graphql'; +import { IAiPersonaService } from '../ai-persona-service/ai.persona.service.interface'; + +@ObjectType('AiServer') +export abstract class IAiServer extends IAuthorizable { + aiPersonaServices?: IAiPersonaService[]; + defaultAiPersonaService?: IAiPersonaService; +} diff --git a/src/services/ai-server/ai-server/ai.server.module.ts b/src/services/ai-server/ai-server/ai.server.module.ts new file mode 100644 index 0000000000..8a7850c987 --- /dev/null +++ b/src/services/ai-server/ai-server/ai.server.module.ts @@ -0,0 +1,31 @@ +import { AuthorizationModule } from '@core/authorization/authorization.module'; +import { AuthorizationPolicyModule } from '@domain/common/authorization-policy/authorization.policy.module'; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AiServer } from './ai.server.entity'; +import { AiServerResolverFields } from './ai.server.resolver.fields'; +import { AiServerResolverMutations } from './ai.server.resolver.mutations'; +import { AiServerResolverQueries } from './ai.server.resolver.queries'; +import { AiServerService } from './ai.server.service'; +import { AiServerAuthorizationService } from './ai.server.service.authorization'; +import { AiPersonaServiceModule } from '../ai-persona-service/ai.persona.service.module'; +import { AiPersonaEngineAdapterModule } from '../ai-persona-engine-adapter/ai.persona.engine.adapter.module'; + +@Module({ + imports: [ + AuthorizationModule, + AuthorizationPolicyModule, + AiPersonaServiceModule, + TypeOrmModule.forFeature([AiServer]), + AiPersonaEngineAdapterModule, + ], + providers: [ + AiServerResolverQueries, + AiServerResolverMutations, + AiServerResolverFields, + AiServerService, + AiServerAuthorizationService, + ], + exports: [AiServerService, AiServerAuthorizationService], +}) +export class AiServerModule {} diff --git a/src/services/ai-server/ai-server/ai.server.resolver.fields.ts b/src/services/ai-server/ai-server/ai.server.resolver.fields.ts new file mode 100644 index 0000000000..710bfe0902 --- /dev/null +++ b/src/services/ai-server/ai-server/ai.server.resolver.fields.ts @@ -0,0 +1,77 @@ +import { Args, Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { + AuthorizationAgentPrivilege, + CurrentUser, +} from '@src/common/decorators'; +import { IAiServer } from './ai.server.interface'; +import { AiServerService } from './ai.server.service'; +import { IAuthorizationPolicy } from '@domain/common/authorization-policy/authorization.policy.interface'; +import { GraphqlGuard } from '@core/authorization'; +import { Injectable, UseGuards } from '@nestjs/common'; +import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; +import { IAiPersonaService } from '@services/ai-server/ai-persona-service'; +import { UUID } from '@domain/common/scalars/scalar.uuid'; +import { AiPersonaServiceQuestionInput } from '../ai-persona-service/dto/ai.persona.service.question.dto.input'; +import { IAiPersonaServiceQuestionResult } from '../ai-persona-service/dto/ai.persona.service.question.dto.result'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { AiPersonaServiceService } from '../ai-persona-service/ai.persona.service.service'; + +@Resolver(() => IAiServer) +export class AiServerResolverFields { + constructor( + private aiServerService: AiServerService, + private aiPersonaServiceService: AiPersonaServiceService + ) {} + + @ResolveField('authorization', () => IAuthorizationPolicy, { + description: 'The authorization policy for the aiServer', + nullable: false, + }) + authorization(@Parent() aiServer: IAiServer): IAuthorizationPolicy { + return this.aiServerService.getAuthorizationPolicy(aiServer); + } + + @AuthorizationAgentPrivilege(AuthorizationPrivilege.READ) + @ResolveField('defaultAiPersonaService', () => IAiPersonaService, { + nullable: false, + description: 'The default AiPersonaService in use on the aiServer.', + }) + @UseGuards(GraphqlGuard) + async defaultAiPersonaService(): Promise { + return await this.aiServerService.getDefaultAiPersonaServiceOrFail(); + } + + @ResolveField(() => [IAiPersonaService], { + nullable: false, + description: 'The AiPersonaServices on this aiServer', + }) + async aiPersonaServices(): Promise { + return await this.aiServerService.getAiPersonaServices(); + } + + @ResolveField(() => IAiPersonaService, { + nullable: false, + description: 'A particular AiPersonaService', + }) + async aiPersonaService( + @Args('ID', { type: () => UUID, nullable: false }) id: string + ): Promise { + return await this.aiServerService.getAiPersonaServiceOrFail(id); + } + + @UseGuards(GraphqlGuard) + @ResolveField(() => IAiPersonaServiceQuestionResult, { + nullable: false, + description: 'Ask the virtual persona engine for guidance.', + }) + async askAiPersonaServiceQuestion( + @CurrentUser() agentInfo: AgentInfo, + @Args('chatData') chatData: AiPersonaServiceQuestionInput + ): Promise { + return this.aiPersonaServiceService.askQuestion( + chatData, + agentInfo, + 'contextSpaceNameID' + ); + } +} diff --git a/src/services/ai-server/ai-server/ai.server.resolver.mutations.ts b/src/services/ai-server/ai-server/ai.server.resolver.mutations.ts new file mode 100644 index 0000000000..37107be1a4 --- /dev/null +++ b/src/services/ai-server/ai-server/ai.server.resolver.mutations.ts @@ -0,0 +1,76 @@ +import { UseGuards } from '@nestjs/common'; +import { Resolver, Mutation, Args } from '@nestjs/graphql'; +import { CurrentUser } from '@src/common/decorators'; +import { GraphqlGuard } from '@core/authorization'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { AuthorizationService } from '@core/authorization/authorization.service'; +import { AuthorizationPrivilege } from '@common/enums/authorization.privilege'; +import { IAiServer } from './ai.server.interface'; +import { AiServerAuthorizationService } from './ai.server.service.authorization'; +import { AiServerService } from './ai.server.service'; +import { AiPersonaServiceService } from '../ai-persona-service/ai.persona.service.service'; +import { AiPersonaServiceAuthorizationService } from '../ai-persona-service/ai.persona.service.authorization'; +import { CreateAiPersonaServiceInput } from '../ai-persona-service/dto/ai.persona.service.dto.create'; +import { IAiPersonaService } from '../ai-persona-service/ai.persona.service.interface'; + +@Resolver() +export class AiServerResolverMutations { + constructor( + private authorizationService: AuthorizationService, + private aiServerService: AiServerService, + private aiServerAuthorizationService: AiServerAuthorizationService, + private aiPersonaServiceService: AiPersonaServiceService, + private aiPersonaServiceAuthorizationService: AiPersonaServiceAuthorizationService + ) {} + + @UseGuards(GraphqlGuard) + @Mutation(() => IAiServer, { + description: 'Reset the Authorization Policy on the specified AiServer.', + }) + async aiServerAuthorizationPolicyReset( + @CurrentUser() agentInfo: AgentInfo + ): Promise { + const aiServer = await this.aiServerService.getAiServerOrFail(); + this.authorizationService.grantAccessOrFail( + agentInfo, + aiServer.authorization, + AuthorizationPrivilege.AUTHORIZATION_RESET, + `reset authorization on aiServer: ${agentInfo.email}` + ); + return await this.aiServerAuthorizationService.applyAuthorizationPolicy(); + } + + @UseGuards(GraphqlGuard) + @Mutation(() => IAiPersonaService, { + description: 'Creates a new AiPersonaService on the aiServer.', + }) + async aiServerCreateAiPersonaService( + @CurrentUser() agentInfo: AgentInfo, + @Args('aiPersonaServiceData') + aiPersonaServiceData: CreateAiPersonaServiceInput + ): Promise { + const aiServer = await this.aiServerService.getAiServerOrFail(); + this.authorizationService.grantAccessOrFail( + agentInfo, + aiServer.authorization, + AuthorizationPrivilege.PLATFORM_ADMIN, + `create Virtual persona: ${aiPersonaServiceData.engine}` + ); + let aiPersonaService = + await this.aiPersonaServiceService.createAiPersonaService( + aiPersonaServiceData + ); + + aiPersonaService = + await this.aiPersonaServiceAuthorizationService.applyAuthorizationPolicy( + aiPersonaService, + aiServer.authorization + ); + + aiPersonaService.aiServer = aiServer; + + await this.aiPersonaServiceService.save(aiPersonaService); + + return aiPersonaService; + } +} diff --git a/src/services/ai-server/ai-server/ai.server.resolver.queries.ts b/src/services/ai-server/ai-server/ai.server.resolver.queries.ts new file mode 100644 index 0000000000..ab7ed27b03 --- /dev/null +++ b/src/services/ai-server/ai-server/ai.server.resolver.queries.ts @@ -0,0 +1,16 @@ +import { Query, Resolver } from '@nestjs/graphql'; +import { IAiServer } from './ai.server.interface'; +import { AiServerService } from './ai.server.service'; + +@Resolver(() => IAiServer) +export class AiServerResolverQueries { + constructor(private aiServerService: AiServerService) {} + + @Query(() => IAiServer, { + nullable: false, + description: 'Alkemio AiServer', + }) + async aiServer(): Promise { + return await this.aiServerService.getAiServerOrFail(); + } +} diff --git a/src/services/ai-server/ai-server/ai.server.service.authorization.ts b/src/services/ai-server/ai-server/ai.server.service.authorization.ts new file mode 100644 index 0000000000..a0b3e036fd --- /dev/null +++ b/src/services/ai-server/ai-server/ai.server.service.authorization.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; +import { IAiServer } from './ai.server.interface'; +import { AiServer } from './ai.server.entity'; +import { AiServerService } from './ai.server.service'; +import { IAuthorizationPolicy } from '@domain/common/authorization-policy/authorization.policy.interface'; +import { + AuthorizationCredential, + AuthorizationPrivilege, + LogContext, +} from '@common/enums'; +import { IAuthorizationPolicyRuleCredential } from '@core/authorization/authorization.policy.rule.credential.interface'; +import { RelationshipNotFoundException } from '@common/exceptions/relationship.not.found.exception'; +import { IAiPersonaService } from '@services/ai-server/ai-persona-service'; +import { AiPersonaServiceAuthorizationService } from '../ai-persona-service/ai.persona.service.authorization'; +import { CREDENTIAL_RULE_TYPES_PLATFORM_GLOBAL_ADMINS } from '@common/constants/authorization/credential.rule.types.constants'; + +@Injectable() +export class AiServerAuthorizationService { + constructor( + private authorizationPolicyService: AuthorizationPolicyService, + private aiServerService: AiServerService, + private aiPersonaServiceAuthorizationService: AiPersonaServiceAuthorizationService, + + @InjectRepository(AiServer) + private aiServerRepository: Repository + ) {} + + async applyAuthorizationPolicy(): Promise { + let aiServer = await this.aiServerService.getAiServerOrFail({ + relations: { + authorization: true, + aiPersonaServices: true, + }, + }); + + if (!aiServer.authorization || !aiServer.aiPersonaServices) + throw new RelationshipNotFoundException( + `Unable to load entities for aiServer: ${aiServer.id} `, + LogContext.AI_SERVER + ); + + aiServer.authorization = await this.authorizationPolicyService.reset( + aiServer.authorization + ); + + aiServer.authorization.anonymousReadAccess = false; + aiServer.authorization = await this.appendCredentialRules( + aiServer.authorization + ); + + const updatedPersonas: IAiPersonaService[] = []; + for (const aiPersonaService of aiServer.aiPersonaServices) { + const updatedPersona = + await this.aiPersonaServiceAuthorizationService.applyAuthorizationPolicy( + aiPersonaService, + aiServer.authorization + ); + updatedPersonas.push(updatedPersona); + } + aiServer.aiPersonaServices = updatedPersonas; + + aiServer = await this.aiServerRepository.save(aiServer); + + return aiServer; + } + + private async appendCredentialRules( + authorization: IAuthorizationPolicy + ): Promise { + const credentialRules = this.createRootCredentialRules(); + + return this.authorizationPolicyService.appendCredentialAuthorizationRules( + authorization, + credentialRules + ); + } + + private createRootCredentialRules(): IAuthorizationPolicyRuleCredential[] { + const credentialRules: IAuthorizationPolicyRuleCredential[] = []; + const globalAdmins = + this.authorizationPolicyService.createCredentialRuleUsingTypesOnly( + [ + AuthorizationPrivilege.CREATE, + AuthorizationPrivilege.READ, + AuthorizationPrivilege.UPDATE, + AuthorizationPrivilege.DELETE, + AuthorizationPrivilege.GRANT, + ], + [AuthorizationCredential.GLOBAL_ADMIN], + CREDENTIAL_RULE_TYPES_PLATFORM_GLOBAL_ADMINS + ); + credentialRules.push(globalAdmins); + + return credentialRules; + } +} diff --git a/src/services/ai-server/ai-server/ai.server.service.ts b/src/services/ai-server/ai-server/ai.server.service.ts new file mode 100644 index 0000000000..4fab278d92 --- /dev/null +++ b/src/services/ai-server/ai-server/ai.server.service.ts @@ -0,0 +1,261 @@ +import { LogContext } from '@common/enums/logging.context'; +import { EntityNotFoundException } from '@common/exceptions/entity.not.found.exception'; +import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; +import { FindOneOptions, FindOptionsRelations, Repository } from 'typeorm'; +import { AiServer } from './ai.server.entity'; +import { IAiServer } from './ai.server.interface'; +import { IAuthorizationPolicy } from '@domain/common/authorization-policy'; +import { ForbiddenException } from '@common/exceptions/forbidden.exception'; +import { AuthorizationCredential } from '@common/enums/authorization.credential'; +import { ICredentialDefinition } from '@domain/agent/credential/credential.definition.interface'; +import { + AiPersonaService, + IAiPersonaService, +} from '@services/ai-server/ai-persona-service'; +import { AiPersonaServiceService } from '../ai-persona-service/ai.persona.service.service'; +import { AiServerRole } from '@common/enums/ai.server.role'; +import { AiPersonaEngineAdapter } from '../ai-persona-engine-adapter/ai.persona.engine.adapter'; +import { AiServerIngestAiPersonaServiceInput } from './dto/ai.server.dto.ingest.ai.persona.service'; +import { AiPersonaEngineAdapterInputBase } from '../ai-persona-engine-adapter/dto/ai.persona.engine.adapter.dto.base'; +import { CreateAiPersonaServiceInput } from '../ai-persona-service/dto'; +import { AgentInfo } from '@core/authentication.agent.info/agent.info'; +import { AiPersonaServiceQuestionInput } from '../ai-persona-service/dto/ai.persona.service.question.dto.input'; +import { + IngestSpace, + SpaceIngestionPurpose, +} from '@services/infrastructure/event-bus/commands'; +import { EventBus } from '@nestjs/cqrs'; + +@Injectable() +export class AiServerService { + constructor( + // private userService: UserService, + // private agentService: AgentService, + private aiPersonaServiceService: AiPersonaServiceService, + private aiPersonaEngineAdapter: AiPersonaEngineAdapter, + @InjectRepository(AiServer) + private aiServerRepository: Repository, + @Inject(WINSTON_MODULE_NEST_PROVIDER) + private readonly logger: LoggerService, + private eventBus: EventBus + ) {} + + async ensurePersonaIsUsable( + personaServiceId: string, + purpose: SpaceIngestionPurpose + ): Promise { + const aiPersonaService = + await this.aiPersonaServiceService.getAiPersonaServiceOrFail( + personaServiceId + ); + await this.ensureSpaceIsUsable(aiPersonaService.bodyOfKnowledgeID, purpose); + } + + async ensureSpaceIsUsable( + spaceID: string, + purpose: SpaceIngestionPurpose + ): Promise { + this.eventBus.publish(new IngestSpace(spaceID, purpose)); + } + + async askQuestion( + questionInput: AiPersonaServiceQuestionInput, + agentInfo: AgentInfo, + contextSapceNameID: string + ) { + return this.aiPersonaServiceService.askQuestion( + questionInput, + agentInfo, + contextSapceNameID + ); + } + + async createAiPersonaService( + personaServiceData: CreateAiPersonaServiceInput + ) { + const server = await this.getAiServerOrFail({ + relations: { aiPersonaServices: true }, + }); + const aiPersonaService = + await this.aiPersonaServiceService.createAiPersonaService( + personaServiceData + ); + server.aiPersonaServices = server.aiPersonaServices ?? []; + server.aiPersonaServices.push(aiPersonaService); + await this.saveAiServer(server); + return aiPersonaService; + } + + async getAiServerOrFail( + options?: FindOneOptions + ): Promise { + let aiServer: IAiServer | null = null; + aiServer = ( + await this.aiServerRepository.find({ take: 1, ...options }) + )?.[0]; + + if (!aiServer) { + throw new EntityNotFoundException( + 'No AiServer found!', + LogContext.AI_SERVER + ); + } + return aiServer; + } + + async saveAiServer(aiServer: IAiServer): Promise { + return await this.aiServerRepository.save(aiServer); + } + + async getAiPersonaServices( + relations?: FindOptionsRelations + ): Promise { + const aiServer = await this.getAiServerOrFail({ + relations: { + aiPersonaServices: true, + ...relations, + }, + }); + const aiPersonaServices = aiServer.aiPersonaServices; + if (!aiPersonaServices) { + throw new EntityNotFoundException( + 'No AI Persona Services found!', + LogContext.AI_PERSONA_SERVICE + ); + } + return aiPersonaServices; + } + + async getDefaultAiPersonaServiceOrFail( + relations?: FindOptionsRelations + ): Promise { + const aiServer = await this.getAiServerOrFail({ + relations: { + defaultAiPersonaService: true, + ...relations, + }, + }); + const defaultAiPersonaService = aiServer.defaultAiPersonaService; + if (!defaultAiPersonaService) { + throw new EntityNotFoundException( + 'No default Virtual Personas found!', + LogContext.AI_SERVER + ); + } + return defaultAiPersonaService; + } + + public async getAiPersonaServiceOrFail( + virtualID: string, + options?: FindOneOptions + ): Promise { + return await this.aiPersonaServiceService.getAiPersonaServiceOrFail( + virtualID, + options + ); + } + + getAuthorizationPolicy(aiServer: IAiServer): IAuthorizationPolicy { + const authorization = aiServer.authorization; + + if (!authorization) { + throw new EntityNotFoundException( + `Unable to find Authorization Policy for AiServer: ${aiServer.id}`, + LogContext.AI_SERVER + ); + } + + return authorization; + } + + // public async assignAiServerRoleToUser( + // assignData: AssignAiServerRoleToUserInput + // ): Promise { + // const agent = await this.userService.getAgent(assignData.userID); + + // const credential = this.getCredentialForRole(assignData.role); + + // // assign the credential + // await this.agentService.grantCredential({ + // agentID: agent.id, + // ...credential, + // }); + + // return await this.userService.getUserWithAgent(assignData.userID); + // } + + // public async removeAiServerRoleFromUser( + // removeData: RemoveAiServerRoleFromUserInput + // ): Promise { + // const agent = await this.userService.getAgent(removeData.userID); + + // // Validation logic + // if (removeData.role === AiServerRole.GLOBAL_ADMIN) { + // // Check not the last global admin + // await this.removeValidationSingleGlobalAdmin(); + // } + + // const credential = this.getCredentialForRole(removeData.role); + + // await this.agentService.revokeCredential({ + // agentID: agent.id, + // ...credential, + // }); + + // return await this.userService.getUserWithAgent(removeData.userID); + // } + + public async ingestAiPersonaService( + ingestData: AiServerIngestAiPersonaServiceInput + ): Promise { + const aiPersonaService = + await this.aiPersonaServiceService.getAiPersonaServiceOrFail( + ingestData.aiPersonaServiceID + ); + const ingestAdapterInput: AiPersonaEngineAdapterInputBase = { + engine: aiPersonaService.engine, + userId: '', + }; + const result = await this.aiPersonaEngineAdapter.sendIngest( + ingestAdapterInput + ); + return result; + } + + // private async removeValidationSingleGlobalAdmin(): Promise { + // // Check more than one + // const globalAdmins = await this.userService.usersWithCredentials({ + // type: AuthorizationCredential.GLOBAL_ADMIN, + // }); + // if (globalAdmins.length < 2) + // throw new ForbiddenException( + // `Not allowed to remove ${AuthorizationCredential.GLOBAL_ADMIN}: last AI Server global-admin`, + // LogContext.AUTH + // ); + + // return true; + // } + + private getCredentialForRole(role: AiServerRole): ICredentialDefinition { + const result: ICredentialDefinition = { + type: '', + resourceID: '', + }; + switch (role) { + case AiServerRole.GLOBAL_ADMIN: + result.type = AuthorizationCredential.GLOBAL_ADMIN; + break; + case AiServerRole.SUPPORT: + result.type = AuthorizationCredential.GLOBAL_SUPPORT; + break; + default: + throw new ForbiddenException( + `Role not supported: ${role}`, + LogContext.AI_SERVER + ); + } + return result; + } +} diff --git a/src/services/ai-server/ai-server/dto/ai.server.dto.assign.role.user.ts b/src/services/ai-server/ai-server/dto/ai.server.dto.assign.role.user.ts new file mode 100644 index 0000000000..f6d5559da9 --- /dev/null +++ b/src/services/ai-server/ai-server/dto/ai.server.dto.assign.role.user.ts @@ -0,0 +1,12 @@ +import { AiServerRole } from '@common/enums/ai.server.role'; +import { UUID_NAMEID_EMAIL } from '@domain/common/scalars/scalar.uuid.nameid.email'; +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class AssignAiServerRoleToUserInput { + @Field(() => UUID_NAMEID_EMAIL, { nullable: false }) + userID!: string; + + @Field(() => AiServerRole, { nullable: false }) + role!: AiServerRole; +} diff --git a/src/services/ai-server/ai-server/dto/ai.server.dto.ingest.ai.persona.service.ts b/src/services/ai-server/ai-server/dto/ai.server.dto.ingest.ai.persona.service.ts new file mode 100644 index 0000000000..c4d4482e5d --- /dev/null +++ b/src/services/ai-server/ai-server/dto/ai.server.dto.ingest.ai.persona.service.ts @@ -0,0 +1,8 @@ +import { UUID } from '@domain/common/scalars'; +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class AiServerIngestAiPersonaServiceInput { + @Field(() => UUID, { nullable: false }) + aiPersonaServiceID!: string; +} diff --git a/src/services/ai-server/ai-server/dto/ai.server.dto.remove.role.user.ts b/src/services/ai-server/ai-server/dto/ai.server.dto.remove.role.user.ts new file mode 100644 index 0000000000..a291b3d4b4 --- /dev/null +++ b/src/services/ai-server/ai-server/dto/ai.server.dto.remove.role.user.ts @@ -0,0 +1,12 @@ +import { AiServerRole } from '@common/enums/ai.server.role'; +import { UUID_NAMEID_EMAIL } from '@domain/common/scalars'; +import { Field, InputType } from '@nestjs/graphql'; + +@InputType() +export class RemoveAiServerRoleFromUserInput { + @Field(() => UUID_NAMEID_EMAIL, { nullable: false }) + userID!: string; + + @Field(() => AiServerRole, { nullable: false }) + role!: AiServerRole; +} diff --git a/src/services/api/conversion/conversion.service.ts b/src/services/api/conversion/conversion.service.ts index 1898b1041b..aadb20f08d 100644 --- a/src/services/api/conversion/conversion.service.ts +++ b/src/services/api/conversion/conversion.service.ts @@ -14,7 +14,6 @@ import { IOrganization } from '@domain/community/organization/organization.inter import { IUser } from '@domain/community/user/user.interface'; import { ICommunity } from '@domain/community/community/community.interface'; import { CommunicationService } from '@domain/communication/communication/communication.service'; -import { DiscussionCategoryCommunity } from '@common/enums/communication.discussion.category.community'; import { IStorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.interface'; import { ICallout } from '@domain/collaboration/callout'; import { TagsetReservedName } from '@common/enums/tagset.reserved.name'; @@ -25,6 +24,7 @@ import { AccountService } from '@domain/space/account/account.service'; import { SpaceService } from '@domain/space/space/space.service'; import { CreateSubspaceInput } from '@domain/space/space/dto/space.dto.create.subspace'; import { NamingService } from '@services/infrastructure/naming/naming.service'; +import { SpaceLevel } from '@common/enums/space.level'; export class ConversionService { constructor( @@ -96,7 +96,7 @@ export class ConversionService { profileData: { displayName: subspace.profile.displayName, }, - level: 0, + level: SpaceLevel.SPACE, type: SpaceType.SPACE, }, }; @@ -295,7 +295,7 @@ export class ConversionService { displayName: subsubspace.profile.displayName, }, storageAggregatorParent: spaceStorageAggregator, - level: 1, + level: SpaceLevel.CHALLENGE, type: SpaceType.CHALLENGE, }; const emptyChallenge = await this.spaceService.createSubspace( @@ -520,11 +520,7 @@ export class ConversionService { childCommunity.id ); const tmpCommunication = - await this.communicationService.createCommunication( - 'temp', - '', - Object.values(DiscussionCategoryCommunity) - ); + await this.communicationService.createCommunication('temp', ''); childCommunity.communication = tmpCommunication; // Need to save with temp communication to avoid db validation error re duplicate usage await this.communityService.save(childCommunity); diff --git a/src/services/api/lookup/lookup.module.ts b/src/services/api/lookup/lookup.module.ts index b86456c436..6320ed24d7 100644 --- a/src/services/api/lookup/lookup.module.ts +++ b/src/services/api/lookup/lookup.module.ts @@ -27,6 +27,7 @@ import { UserModule } from '@domain/community/user/user.module'; import { SpaceModule } from '@domain/space/space/space.module'; import { CommunityGuidelinesModule } from '@domain/community/community-guidelines/community.guidelines.module'; import { CommunityGuidelinesTemplateModule } from '@domain/template/community-guidelines-template/community.guidelines.template.module'; +import { VirtualContributorModule } from '@domain/community/virtual-contributor/virtual.contributor.module'; @Module({ imports: [ @@ -55,6 +56,7 @@ import { CommunityGuidelinesTemplateModule } from '@domain/template/community-gu SpaceModule, CommunityGuidelinesModule, CommunityGuidelinesTemplateModule, + VirtualContributorModule, ], providers: [LookupService, LookupResolverQueries, LookupResolverFields], exports: [LookupService], diff --git a/src/services/api/lookup/lookup.resolver.fields.ts b/src/services/api/lookup/lookup.resolver.fields.ts index 0d1c298973..923306580b 100644 --- a/src/services/api/lookup/lookup.resolver.fields.ts +++ b/src/services/api/lookup/lookup.resolver.fields.ts @@ -54,6 +54,8 @@ import { ICommunityGuidelines } from '@domain/community/community-guidelines/com import { CommunityGuidelinesService } from '@domain/community/community-guidelines/community.guidelines.service'; import { CommunityGuidelinesTemplateService } from '@domain/template/community-guidelines-template/community.guidelines.template.service'; import { ICommunityGuidelinesTemplate } from '@domain/template/community-guidelines-template/community.guidelines.template.interface'; +import { IVirtualContributor } from '@domain/community/virtual-contributor/virtual.contributor.interface'; +import { VirtualContributorService } from '@domain/community/virtual-contributor/virtual.contributor.service'; @Resolver(() => LookupQueryResults) export class LookupResolverFields { @@ -82,7 +84,8 @@ export class LookupResolverFields { private spaceService: SpaceService, private userService: UserService, private guidelinesService: CommunityGuidelinesService, - private guidelinesTemplateService: CommunityGuidelinesTemplateService + private guidelinesTemplateService: CommunityGuidelinesTemplateService, + private virtualContributorService: VirtualContributorService ) {} @UseGuards(GraphqlGuard) @@ -90,10 +93,7 @@ export class LookupResolverFields { nullable: true, description: 'Lookup the specified Space', }) - async space( - @CurrentUser() agentInfo: AgentInfo, - @Args('ID', { type: () => UUID }) id: string - ): Promise { + async space(@Args('ID', { type: () => UUID }) id: string): Promise { const space = await this.spaceService.getSpaceOrFail(id); return space; @@ -119,6 +119,17 @@ export class LookupResolverFields { return document; } + @UseGuards(GraphqlGuard) + @ResolveField(() => IVirtualContributor, { + nullable: true, + description: 'A particular VirtualContributor', + }) + async virtualContributor( + @Args('ID', { type: () => UUID, nullable: false }) id: string + ): Promise { + return await this.virtualContributorService.getVirtualContributorOrFail(id); + } + @UseGuards(GraphqlGuard) @ResolveField(() => IAuthorizationPolicy, { nullable: true, diff --git a/src/services/api/me/me.resolver.fields.ts b/src/services/api/me/me.resolver.fields.ts index d63cc9e54c..594e0aca8f 100644 --- a/src/services/api/me/me.resolver.fields.ts +++ b/src/services/api/me/me.resolver.fields.ts @@ -11,8 +11,8 @@ import { UserService } from '@domain/community/user/user.service'; import { ISpace } from '@domain/space/space/space.interface'; import { SpaceVisibility } from '@common/enums/space.visibility'; import { MeService } from './me.service'; -import { ApplicationForRoleResult } from '../roles/dto/roles.dto.result.application'; -import { InvitationForRoleResult } from '../roles/dto/roles.dto.result.invitation'; +import { CommunityApplicationForRoleResult } from '../roles/dto/roles.dto.result.community.application'; +import { CommunityInvitationForRoleResult } from '../roles/dto/roles.dto.result.community.invitation'; import { LogContext } from '@common/enums'; import { MySpaceResults } from './dto/my.journeys.results'; @@ -47,10 +47,14 @@ export class MeResolverFields { } @UseGuards(GraphqlGuard) - @ResolveField('invitations', () => [InvitationForRoleResult], { - description: 'The invitations of the current authenticated user', - }) - public async invitations( + @ResolveField( + 'communityInvitations', + () => [CommunityInvitationForRoleResult], + { + description: 'The invitations the current authenticated user can act on.', + } + ) + public async communityInvitations( @CurrentUser() agentInfo: AgentInfo, @Args({ name: 'states', @@ -59,15 +63,23 @@ export class MeResolverFields { description: 'The state names you want to filter on', }) states: string[] - ): Promise { - return this.meService.getUserInvitations(agentInfo.userID, states); + ): Promise { + return this.meService.getCommunityInvitationsForUser( + agentInfo.userID, + states + ); } @UseGuards(GraphqlGuard) - @ResolveField('applications', () => [ApplicationForRoleResult], { - description: 'The applications of the current authenticated user', - }) - public async applications( + @ResolveField( + 'communityApplications', + () => [CommunityApplicationForRoleResult], + { + description: + 'The community applicationscurrent authenticated user can act on.', + } + ) + public async communityAplications( @CurrentUser() agentInfo: AgentInfo, @Args({ name: 'states', @@ -76,8 +88,11 @@ export class MeResolverFields { description: 'The state names you want to filter on', }) states: string[] - ): Promise { - return this.meService.getUserApplications(agentInfo.userID, states); + ): Promise { + return this.meService.getCommunityApplicationsForUser( + agentInfo.userID, + states + ); } @UseGuards(GraphqlGuard) diff --git a/src/services/api/me/me.service.ts b/src/services/api/me/me.service.ts index b78df6e90f..d76032c691 100644 --- a/src/services/api/me/me.service.ts +++ b/src/services/api/me/me.service.ts @@ -3,8 +3,8 @@ import { SpaceVisibility } from '@common/enums/space.visibility'; import { groupCredentialsByEntity } from '@services/api/roles/util/group.credentials.by.entity'; import { SpaceService } from '@domain/space/space/space.service'; import { RolesService } from '../roles/roles.service'; -import { ApplicationForRoleResult } from '../roles/dto/roles.dto.result.application'; -import { InvitationForRoleResult } from '../roles/dto/roles.dto.result.invitation'; +import { CommunityApplicationForRoleResult } from '../roles/dto/roles.dto.result.community.application'; +import { CommunityInvitationForRoleResult } from '../roles/dto/roles.dto.result.community.invitation'; import { ISpace } from '@domain/space/space/space.interface'; import { SpacesQueryArgs } from '@domain/space/space/dto/space.args.query.spaces'; import { ActivityLogService } from '../activity-log'; @@ -29,18 +29,24 @@ export class MeService { private readonly logger: LoggerService ) {} - public async getUserInvitations( + public async getCommunityInvitationsForUser( userId: string, states?: string[] - ): Promise { - return await this.rolesService.getUserInvitations(userId, states); + ): Promise { + return await this.rolesService.getCommunityInvitationsForUser( + userId, + states + ); } - public async getUserApplications( + public async getCommunityApplicationsForUser( userId: string, states?: string[] - ): Promise { - return await this.rolesService.getUserApplications(userId, states); + ): Promise { + return await this.rolesService.getCommunityApplicationsForUser( + userId, + states + ); } public async getSpaceMemberships( diff --git a/src/services/api/registration/registration.service.ts b/src/services/api/registration/registration.service.ts index 238d6a5bc5..f5b762b8e4 100644 --- a/src/services/api/registration/registration.service.ts +++ b/src/services/api/registration/registration.service.ts @@ -126,14 +126,15 @@ export class RegistrationService { ); } const invitationInput: CreateInvitationInput = { - invitedUser: user.id, + invitedContributor: user.id, communityID: community.id, createdBy: externalInvitation.createdBy, invitedToParent: externalInvitation.invitedToParent, }; - let invitation = await this.communityService.createInvitationExistingUser( - invitationInput - ); + let invitation = + await this.communityService.createInvitationExistingContributor( + invitationInput + ); invitation.invitedToParent = externalInvitation.invitedToParent; invitation = await this.invitationAuthorizationService.applyAuthorizationPolicy( @@ -155,9 +156,8 @@ export class RegistrationService { ): Promise { const userID = deleteData.ID; - const invitations = await this.invitationService.findInvitationsForUser( - userID - ); + const invitations = + await this.invitationService.findInvitationsForContributor(userID); for (const invitation of invitations) { await this.invitationService.deleteInvitation({ ID: invitation.id }); } diff --git a/src/services/api/roles/dto/roles.dto.result.application.ts b/src/services/api/roles/dto/roles.dto.result.community.application.ts similarity index 96% rename from src/services/api/roles/dto/roles.dto.result.application.ts rename to src/services/api/roles/dto/roles.dto.result.community.application.ts index 314b6cb1c1..d8492c6e20 100644 --- a/src/services/api/roles/dto/roles.dto.result.application.ts +++ b/src/services/api/roles/dto/roles.dto.result.community.application.ts @@ -3,7 +3,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { SpaceLevel } from '@common/enums/space.level'; @ObjectType() -export class ApplicationForRoleResult { +export class CommunityApplicationForRoleResult { @Field(() => UUID, { description: 'ID for the application', }) diff --git a/src/services/api/roles/dto/roles.dto.result.invitation.ts b/src/services/api/roles/dto/roles.dto.result.community.invitation.ts similarity index 77% rename from src/services/api/roles/dto/roles.dto.result.invitation.ts rename to src/services/api/roles/dto/roles.dto.result.community.invitation.ts index 9971c02c4f..74723f2ff2 100644 --- a/src/services/api/roles/dto/roles.dto.result.invitation.ts +++ b/src/services/api/roles/dto/roles.dto.result.community.invitation.ts @@ -1,14 +1,26 @@ import { UUID } from '@domain/common/scalars'; import { Field, ObjectType } from '@nestjs/graphql'; import { SpaceLevel } from '@common/enums/space.level'; +import { CommunityContributorType } from '@common/enums/community.contributor.type'; @ObjectType() -export class InvitationForRoleResult { +export class CommunityInvitationForRoleResult { @Field(() => UUID, { - description: 'ID for the application', + description: 'ID for the Invitation', }) id: string; + @Field(() => UUID, { + description: 'ID for Contrbutor that is being invited to a community', + }) + contributorID!: string; + + @Field(() => CommunityContributorType, { + description: + 'The Type of the Contrbutor that is being invited to a community', + }) + contributorType!: string; + @Field(() => UUID, { description: 'ID for the community', }) diff --git a/src/services/api/roles/roles.module.ts b/src/services/api/roles/roles.module.ts index a61a884d3a..df1ceed963 100644 --- a/src/services/api/roles/roles.module.ts +++ b/src/services/api/roles/roles.module.ts @@ -14,6 +14,7 @@ import { SpaceFilterModule } from '@services/infrastructure/space-filter/space.f import { InvitationModule } from '@domain/community/invitation/invitation.module'; import { EntityResolverModule } from '@services/infrastructure/entity-resolver/entity.resolver.module'; import { RolesResolverFields } from './roles.resolver.fields'; +import { UserLookupModule } from '@services/infrastructure/user-lookup/user.lookup.module'; @Module({ imports: [ @@ -29,6 +30,7 @@ import { RolesResolverFields } from './roles.resolver.fields'; PlatformAuthorizationPolicyModule, SpaceFilterModule, EntityResolverModule, + UserLookupModule, ], providers: [RolesService, RolesResolverQueries, RolesResolverFields], exports: [RolesService], diff --git a/src/services/api/roles/roles.resolver.fields.ts b/src/services/api/roles/roles.resolver.fields.ts index df23222d13..feed7ae065 100644 --- a/src/services/api/roles/roles.resolver.fields.ts +++ b/src/services/api/roles/roles.resolver.fields.ts @@ -4,10 +4,10 @@ import { UseGuards } from '@nestjs/common'; import { CurrentUser } from '@src/common/decorators'; import { Args, ResolveField } from '@nestjs/graphql'; import { AgentInfo } from '@core/authentication.agent.info/agent.info'; -import { InvitationForRoleResult } from './dto/roles.dto.result.invitation'; +import { CommunityInvitationForRoleResult } from './dto/roles.dto.result.community.invitation'; import { RolesService } from './roles.service'; import { ContributorRoles } from './dto/roles.dto.result.contributor'; -import { ApplicationForRoleResult } from './dto/roles.dto.result.application'; +import { CommunityApplicationForRoleResult } from './dto/roles.dto.result.community.application'; import { AuthorizationService } from '@core/authorization/authorization.service'; import { PlatformAuthorizationPolicyService } from '@platform/authorization/platform.authorization.policy.service'; import { AuthorizationPrivilege } from '@common/enums'; @@ -48,7 +48,7 @@ export class RolesResolverFields { } @UseGuards(GraphqlGuard) - @ResolveField('invitations', () => [InvitationForRoleResult], { + @ResolveField('invitations', () => [CommunityInvitationForRoleResult], { description: 'The invitations for the specified user; only accessible for platform admins', }) @@ -62,18 +62,21 @@ export class RolesResolverFields { description: 'The state names you want to filter on', }) states: string[] - ): Promise { + ): Promise { await this.authorizationService.grantAccessOrFail( agentInfo, await this.platformAuthorizationService.getPlatformAuthorizationPolicy(), AuthorizationPrivilege.PLATFORM_ADMIN, `roles user query: ${agentInfo.email}` ); - return await this.rolesService.getUserInvitations(roles.id, states); + return await this.rolesService.getCommunityInvitationsForUser( + roles.id, + states + ); } @UseGuards(GraphqlGuard) - @ResolveField('applications', () => [ApplicationForRoleResult], { + @ResolveField('applications', () => [CommunityApplicationForRoleResult], { description: 'The applications for the specified user; only accessible for platform admins', }) @@ -87,13 +90,16 @@ export class RolesResolverFields { description: 'The state names you want to filter on', }) states: string[] - ): Promise { + ): Promise { await this.authorizationService.grantAccessOrFail( agentInfo, await this.platformAuthorizationService.getPlatformAuthorizationPolicy(), AuthorizationPrivilege.PLATFORM_ADMIN, `roles user query: ${agentInfo.email}` ); - return await this.rolesService.getUserApplications(roles.id, states); + return await this.rolesService.getCommunityApplicationsForUser( + roles.id, + states + ); } } diff --git a/src/services/api/roles/roles.service.spec.ts b/src/services/api/roles/roles.service.spec.ts index fb90e5a4fe..6bc5c7c192 100644 --- a/src/services/api/roles/roles.service.spec.ts +++ b/src/services/api/roles/roles.service.spec.ts @@ -32,6 +32,7 @@ import { SpaceLevel } from '@common/enums/space.level'; import { Space } from '@domain/space/space/space.entity'; import { License } from '@domain/license/license/license.entity'; import { RolesResultCommunity } from './dto/roles.dto.result.community'; +import { MockUserLookupService } from '@test/mocks/user.lookup.service.mock'; describe('RolesService', () => { let rolesService: RolesService; @@ -56,6 +57,7 @@ describe('RolesService', () => { MockWinstonProvider, MockEntityManagerProvider, MockSpaceService, + MockUserLookupService, RolesService, ], }).compile(); @@ -164,7 +166,9 @@ describe('RolesService', () => { }); it.skip('Should get user applications', async () => { - const res = await rolesService.getUserApplications(testData.user.id); + const res = await rolesService.getCommunityApplicationsForUser( + testData.user.id + ); expect(res).toEqual( expect.arrayContaining([ @@ -182,7 +186,7 @@ describe('RolesService', () => { .mockResolvedValueOnce(false); await asyncToThrow( - rolesService.getUserApplications(testData.user.id), + rolesService.getCommunityApplicationsForUser(testData.user.id), RelationshipNotFoundException ); }); @@ -255,7 +259,6 @@ const getSpaceRoleResultMock = ({ license: { id: `license-${id}`, visibility: SpaceVisibility.ACTIVE, - featureFlags: [], ...getEntityMock(), }, }, diff --git a/src/services/api/roles/roles.service.ts b/src/services/api/roles/roles.service.ts index 90bffad890..d25ff3e1a4 100644 --- a/src/services/api/roles/roles.service.ts +++ b/src/services/api/roles/roles.service.ts @@ -10,10 +10,10 @@ import { IApplication } from '@domain/community/application'; import { SpaceFilterService } from '@services/infrastructure/space-filter/space.filter.service'; import { RolesUserInput } from './dto/roles.dto.input.user'; import { ContributorRoles } from './dto/roles.dto.result.contributor'; -import { ApplicationForRoleResult } from './dto/roles.dto.result.application'; +import { CommunityApplicationForRoleResult } from './dto/roles.dto.result.community.application'; import { RolesOrganizationInput } from './dto/roles.dto.input.organization'; import { mapSpaceCredentialsToRoles } from './util/map.space.credentials.to.roles'; -import { InvitationForRoleResult } from './dto/roles.dto.result.invitation'; +import { CommunityInvitationForRoleResult } from './dto/roles.dto.result.community.invitation'; import { InvitationService } from '@domain/community/invitation/invitation.service'; import { IInvitation } from '@domain/community/invitation'; import { CommunityResolverService } from '@services/infrastructure/entity-resolver/community.resolver.service'; @@ -23,6 +23,7 @@ import { RolesResultSpace } from './dto/roles.dto.result.space'; import { AgentInfo } from '@core/authentication.agent.info/agent.info'; import { AuthorizationService } from '@core/authorization/authorization.service'; import { SpaceService } from '@domain/space/space/space.service'; +import { UserLookupService } from '@services/infrastructure/user-lookup/user.lookup.service'; export class RolesService { constructor( @@ -35,6 +36,7 @@ export class RolesService { private spaceService: SpaceService, private authorizationService: AuthorizationService, private organizationService: OrganizationService, + private userLookupService: UserLookupService, @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService ) {} @@ -93,11 +95,11 @@ export class RolesService { ); } - public async getUserApplications( + public async getCommunityApplicationsForUser( userID: string, states?: string[] - ): Promise { - const applicationResults: ApplicationForRoleResult[] = []; + ): Promise { + const applicationResults: CommunityApplicationForRoleResult[] = []; const applications = await this.applicationService.findApplicationsForUser( userID, states @@ -125,7 +127,7 @@ export class RolesService { community: ICommunity, state: string, application: IApplication - ): Promise { + ): Promise { const communityDisplayName = await this.communityResolverService.getDisplayNameForCommunityOrFail( community.id @@ -135,7 +137,7 @@ export class RolesService { community.id ); - const applicationResult = new ApplicationForRoleResult( + const applicationResult = new CommunityApplicationForRoleResult( community.id, communityDisplayName, state, @@ -149,15 +151,26 @@ export class RolesService { return applicationResult; } - public async getUserInvitations( + public async getCommunityInvitationsForUser( userID: string, states?: string[] - ): Promise { - const invitationResults: InvitationForRoleResult[] = []; - const invitations = await this.invitationService.findInvitationsForUser( - userID, - states - ); + ): Promise { + const invitationResults: CommunityInvitationForRoleResult[] = []; + + // What contributors are managed by this user? + const contributorsManagedByUser = + await this.userLookupService.getContributorsManagedByUser(userID); + const invitations: IInvitation[] = []; + for (const contributor of contributorsManagedByUser) { + const contributorInvitations = + await this.invitationService.findInvitationsForContributor( + contributor.id, + states + ); + if (contributorInvitations) { + invitations.push(...contributorInvitations); + } + } if (!invitations) return []; @@ -184,7 +197,7 @@ export class RolesService { community: ICommunity, state: string, invitation: IInvitation - ): Promise { + ): Promise { const communityDisplayName = await this.communityResolverService.getDisplayNameForCommunityOrFail( community.id @@ -194,7 +207,7 @@ export class RolesService { community.id ); - const invitationResult = new InvitationForRoleResult( + const invitationResult = new CommunityInvitationForRoleResult( community.id, communityDisplayName, state, @@ -204,6 +217,8 @@ export class RolesService { invitation.createdDate, invitation.updatedDate ); + invitationResult.contributorID = invitation.invitedContributor; + invitationResult.contributorType = invitation.contributorType; invitationResult.createdBy = invitation.createdBy; invitationResult.welcomeMessage = invitation.welcomeMessage; diff --git a/src/services/api/roles/util/get.space.roles.for.contributor.entity.data.ts b/src/services/api/roles/util/get.space.roles.for.contributor.entity.data.ts index cc91a2440a..d1081ceed1 100644 --- a/src/services/api/roles/util/get.space.roles.for.contributor.entity.data.ts +++ b/src/services/api/roles/util/get.space.roles.for.contributor.entity.data.ts @@ -37,9 +37,7 @@ export const getSpaceRolesForContributorEntityData = async ( relations = { profile: true, account: { - license: { - featureFlags: true, - }, + license: true, }, }; } diff --git a/src/services/api/search/v2/result/search.result.service.ts b/src/services/api/search/v2/result/search.result.service.ts index 5501f58218..febecb83f9 100644 --- a/src/services/api/search/v2/result/search.result.service.ts +++ b/src/services/api/search/v2/result/search.result.service.ts @@ -184,8 +184,8 @@ export class SearchResultService { relations: { parentSpace: true }, select: { id: true, - type: true, - parentSpace: { id: true, type: true }, + level: true, + parentSpace: { id: true, level: true }, }, }); diff --git a/src/services/file-integration/file.integration.controller.ts b/src/services/file-integration/file.integration.controller.ts index 7b7be52eb1..e01734f212 100644 --- a/src/services/file-integration/file.integration.controller.ts +++ b/src/services/file-integration/file.integration.controller.ts @@ -9,7 +9,7 @@ import { import { FileIntegrationService } from './file.integration.service'; import { FileMessagePatternEnum } from './types/message.pattern'; import { FileInfoInputData } from './inputs'; -import { FileInfoOutputData } from './outputs'; +import { FileInfoOutputData, HealthCheckOutputData } from './outputs'; import { ack } from '../util'; @Controller() @@ -24,4 +24,12 @@ export class FileIntegrationController { ack(context); return this.integrationService.fileInfo(data); } + + @MessagePattern(FileMessagePatternEnum.HEALTH_CHECK, Transport.RMQ) + public health(@Ctx() context: RmqContext): HealthCheckOutputData { + ack(context); + // can be tight to more complex health check in the future + // for now just return true + return new HealthCheckOutputData(true); + } } diff --git a/src/services/file-integration/outputs/health.check.output.data.ts b/src/services/file-integration/outputs/health.check.output.data.ts new file mode 100644 index 0000000000..6f0be150e0 --- /dev/null +++ b/src/services/file-integration/outputs/health.check.output.data.ts @@ -0,0 +1,7 @@ +import { BaseOutputData } from './base.output.data'; + +export class HealthCheckOutputData extends BaseOutputData { + constructor(public healthy: boolean) { + super('health-check-output'); + } +} diff --git a/src/services/file-integration/outputs/index.ts b/src/services/file-integration/outputs/index.ts index 3f6153aaaa..e94ed283e8 100644 --- a/src/services/file-integration/outputs/index.ts +++ b/src/services/file-integration/outputs/index.ts @@ -1 +1,2 @@ export * from './file.info.output.data'; +export * from './health.check.output.data'; diff --git a/src/services/file-integration/types/message.pattern.ts b/src/services/file-integration/types/message.pattern.ts index 65a7b1b5b8..9884d9f54f 100644 --- a/src/services/file-integration/types/message.pattern.ts +++ b/src/services/file-integration/types/message.pattern.ts @@ -1,3 +1,4 @@ export enum FileMessagePatternEnum { FILE_INFO = 'file-info', + HEALTH_CHECK = 'health-check', } diff --git a/src/services/infrastructure/entity-resolver/community.resolver.service.ts b/src/services/infrastructure/entity-resolver/community.resolver.service.ts index f34286b6e4..e5a469febe 100644 --- a/src/services/infrastructure/entity-resolver/community.resolver.service.ts +++ b/src/services/infrastructure/entity-resolver/community.resolver.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm'; import { EntityManager, Repository } from 'typeorm'; -import { Discussion } from '@domain/communication/discussion/discussion.entity'; import { Community, ICommunity } from '@domain/community/community'; import { EntityNotFoundException } from '@common/exceptions'; import { LogContext } from '@common/enums'; @@ -9,16 +8,14 @@ import { Communication } from '@domain/communication/communication/communication import { Space } from '@domain/space/space/space.entity'; import { ISpace } from '@domain/space/space/space.interface'; import { RoomType } from '@common/enums/room.type'; -import { ILicense } from '@domain/license/license/license.interface'; import { VirtualContributor } from '@domain/community/virtual-contributor'; +import { IAgent } from '@domain/agent'; @Injectable() export class CommunityResolverService { constructor( @InjectRepository(Community) private communityRepository: Repository, - @InjectRepository(Discussion) - private discussionRepository: Repository, @InjectRepository(Communication) private communicationRepository: Repository, @InjectEntityManager('default') @@ -112,9 +109,9 @@ export class CommunityResolverService { ); } - public async getLicenseFromCommunityOrFail( + public async getAccountAgentFromCommunityOrFail( community: ICommunity - ): Promise { + ): Promise { const space = await this.entityManager.findOne(Space, { where: { community: { @@ -123,53 +120,22 @@ export class CommunityResolverService { }, relations: { account: { - license: { - featureFlags: true, + agent: { + credentials: true, }, }, }, }); - if ( - space && - space.account && - space.account.license && - space.account.license.featureFlags - ) { - return space.account.license; + if (space && space.account && space.account.agent) { + return space.account.agent; } throw new EntityNotFoundException( - `Unable to find License feature flags for given community id: ${community.id}`, + `Unable to find Agent for account for given community id: ${community.id}`, LogContext.COLLABORATION ); } - public async getCommunityFromDiscussionOrFail( - discussionID: string - ): Promise { - const discussion = await this.discussionRepository - .createQueryBuilder('discussion') - .leftJoinAndSelect('discussion.communication', 'communication') - .where('discussion.id = :id') - .setParameters({ id: `${discussionID}` }) - .getOne(); - - const community = await this.communityRepository - .createQueryBuilder('community') - .where('communicationId = :id') - .setParameters({ id: `${discussion?.communication?.id}` }) - .getOne(); - - if (!community) { - throw new EntityNotFoundException( - `Unable to find Community for Discussion: ${discussionID}`, - LogContext.COMMUNITY - ); - } - - return community; - } - public async getCommunityFromUpdatesOrFail( updatesID: string ): Promise { diff --git a/src/services/infrastructure/entity-resolver/entity.resolver.module.ts b/src/services/infrastructure/entity-resolver/entity.resolver.module.ts index 7f0895bd70..bc24d25824 100644 --- a/src/services/infrastructure/entity-resolver/entity.resolver.module.ts +++ b/src/services/infrastructure/entity-resolver/entity.resolver.module.ts @@ -1,7 +1,6 @@ import { User } from '@domain/community/user/user.entity'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Discussion } from '@domain/communication/discussion/discussion.entity'; import { IdentityResolverService } from './identity.resolver.service'; import { CommunityResolverService } from './community.resolver.service'; import { Community } from '@domain/community/community/community.entity'; @@ -14,7 +13,6 @@ import { VirtualContributor } from '@domain/community/virtual-contributor'; imports: [ TypeOrmModule.forFeature([User]), TypeOrmModule.forFeature([VirtualContributor]), - TypeOrmModule.forFeature([Discussion]), TypeOrmModule.forFeature([Community]), TypeOrmModule.forFeature([Communication]), ], diff --git a/src/services/infrastructure/event-bus/commands/ingest.space.command.ts b/src/services/infrastructure/event-bus/commands/ingest.space.command.ts index c036a6799b..9db690f16c 100644 --- a/src/services/infrastructure/event-bus/commands/ingest.space.command.ts +++ b/src/services/infrastructure/event-bus/commands/ingest.space.command.ts @@ -1,10 +1,13 @@ import { IEvent } from '@nestjs/cqrs'; +import { registerEnumType } from '@nestjs/graphql'; export enum SpaceIngestionPurpose { - Knowledge = 'knowledge', - Context = 'context', + KNOWLEDGE = 'knowledge', + CONTEXT = 'context', } +registerEnumType(SpaceIngestionPurpose, { name: 'SpaceIngestionPurpose' }); + export class IngestSpace implements IEvent { constructor( public readonly spaceId: string, diff --git a/src/services/infrastructure/naming/naming.module.ts b/src/services/infrastructure/naming/naming.module.ts index f6014e9892..8afe890c69 100644 --- a/src/services/infrastructure/naming/naming.module.ts +++ b/src/services/infrastructure/naming/naming.module.ts @@ -3,8 +3,8 @@ import { NamingService } from './naming.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Callout } from '@domain/collaboration/callout/callout.entity'; import { CalendarEvent } from '@domain/timeline/event'; -import { Discussion } from '@domain/communication/discussion/discussion.entity'; import { InnovationHub } from '@domain/innovation-hub/innovation.hub.entity'; +import { Discussion } from '@platform/forum-discussion/discussion.entity'; @Module({ imports: [ diff --git a/src/services/infrastructure/naming/naming.service.ts b/src/services/infrastructure/naming/naming.service.ts index cdf8a4114a..680f3c3a01 100644 --- a/src/services/infrastructure/naming/naming.service.ts +++ b/src/services/infrastructure/naming/naming.service.ts @@ -12,9 +12,7 @@ import { IPost } from '@domain/collaboration/post/post.interface'; import { ICommunityPolicy } from '@domain/community/community-policy/community.policy.interface'; import { CalendarEvent, ICalendarEvent } from '@domain/timeline/event'; import { Inject, LoggerService } from '@nestjs/common'; -import { Discussion } from '@domain/communication/discussion/discussion.entity'; import { InnovationHub } from '@domain/innovation-hub/innovation.hub.entity'; -import { IDiscussion } from '@domain/communication/discussion/discussion.interface'; import { ICallout } from '@domain/collaboration/callout'; import { NAMEID_LENGTH } from '@common/constants'; import { Space } from '@domain/space/space/space.entity'; @@ -24,6 +22,8 @@ import { User } from '@domain/community/user/user.entity'; import { InnovationPack } from '@library/innovation-pack/innovation.pack.entity'; import { VirtualContributor } from '@domain/community/virtual-contributor'; import { Organization } from '@domain/community/organization'; +import { Discussion } from '@platform/forum-discussion/discussion.entity'; +import { IDiscussion } from '@platform/forum-discussion/discussion.interface'; export class NamingService { replaceSpecialCharacters = require('replace-special-characters'); @@ -58,13 +58,11 @@ export class NamingService { return nameIDs; } - public async getReservedNameIDsInCommunication( - communicationID: string - ): Promise { + public async getReservedNameIDsInForum(forumID: string): Promise { const discussions = await this.entityManager.find(Discussion, { where: { - communication: { - id: communicationID, + forum: { + id: forumID, }, }, select: { @@ -217,18 +215,18 @@ export class NamingService { return true; } - async isDiscussionDisplayNameAvailableInCommunication( + async isDiscussionDisplayNameAvailableInForum( displayName: string, - communicationID: string + forumID: string ): Promise { const query = this.discussionRepository .createQueryBuilder('discussion') - .leftJoinAndSelect('discussion.communication', 'communication') + .leftJoinAndSelect('discussion.forum', 'forum') .leftJoinAndSelect('discussion.profile', 'profile') - .where('communication.id = :id') + .where('forum.id = :id') .andWhere('profile.displayName = :displayName') .setParameters({ - id: `${communicationID}`, + id: `${forumID}`, displayName: `${displayName}`, }); const discussionsWithDisplayName = await query.getOne(); diff --git a/src/services/infrastructure/storage-aggregator-resolver/storage.aggregator.resolver.service.ts b/src/services/infrastructure/storage-aggregator-resolver/storage.aggregator.resolver.service.ts index 5f7109bced..2fced468b5 100644 --- a/src/services/infrastructure/storage-aggregator-resolver/storage.aggregator.resolver.service.ts +++ b/src/services/infrastructure/storage-aggregator-resolver/storage.aggregator.resolver.service.ts @@ -14,7 +14,7 @@ import { TimelineResolverService } from '../entity-resolver/timeline.resolver.se import { StorageAggregatorNotFoundException } from '@common/exceptions/storage.aggregator.not.found.exception'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { Space } from '@domain/space/space/space.entity'; -import { SpaceType } from '@common/enums/space.type'; +import { SpaceLevel } from '@common/enums/space.level'; @Injectable() export class StorageAggregatorResolverService { @@ -74,7 +74,7 @@ export class StorageAggregatorResolverService { ): Promise<{ id: string; displayName: string; - type: SpaceType; + level: SpaceLevel; nameID: string; }> { const space = await this.entityManager.findOne(Space, { @@ -98,7 +98,7 @@ export class StorageAggregatorResolverService { id: space.id, displayName: space.profile.displayName, nameID: space.nameID, - type: space.type, + level: space.level, }; } @@ -185,12 +185,8 @@ export class StorageAggregatorResolverService { return await this.getStorageAggregatorIdForCollaboration(collaborationId); } - public async getStorageAggregatorForCommunication( - communicationID: string - ): Promise { - const storageAggregatorId = - await this.getStorageAggregatorIdForCommunication(communicationID); - return await this.getStorageAggregatorOrFail(storageAggregatorId); + public async getStorageAggregatorForForum(): Promise { + return await this.getPlatformStorageAggregator(); } public async getStorageAggregatorForCommunity( @@ -224,37 +220,6 @@ export class StorageAggregatorResolverService { return space.storageAggregator.id; } - private async getStorageAggregatorIdForCommunication( - communicationID: string - ): Promise { - const query = `SELECT \`id\` FROM \`community\` - WHERE \`community\`.\`communicationId\`='${communicationID}'`; - const [communityQueryResult]: { - id: string; - }[] = await this.entityManager.connection.query(query); - - if (!communityQueryResult) { - const query = `SELECT \`id\` FROM \`platform\` - WHERE \`platform\`.\`communicationId\`='${communicationID}'`; - const [platformQueryResult]: { - id: string; - }[] = await this.entityManager.connection.query(query); - if (!platformQueryResult) { - this.logger.error( - `lookup for communication ${communicationID} - community / platform not found`, - undefined, - LogContext.STORAGE_BUCKET - ); - } - const platformStorageAggregator = - await this.getPlatformStorageAggregator(); - return platformStorageAggregator.id; - } - return await this.getStorageAggregatorIdForCommunity( - communityQueryResult.id - ); - } - public async getStorageAggregatorForCallout( calloutID: string ): Promise { diff --git a/src/services/infrastructure/url-generator/url.generator.service.ts b/src/services/infrastructure/url-generator/url.generator.service.ts index 9fecc1c78f..21b0a04f71 100644 --- a/src/services/infrastructure/url-generator/url.generator.service.ts +++ b/src/services/infrastructure/url-generator/url.generator.service.ts @@ -1,6 +1,9 @@ import { LogContext, ProfileType } from '@common/enums'; import { ConfigurationTypes } from '@common/enums/configuration.type'; -import { EntityNotFoundException } from '@common/exceptions'; +import { + EntityNotFoundException, + RelationshipNotFoundException, +} from '@common/exceptions'; import { IProfile } from '@domain/common/profile/profile.interface'; import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; @@ -14,6 +17,11 @@ import { Callout } from '@domain/collaboration/callout/callout.entity'; import { CalloutTemplate } from '@domain/template/callout-template/callout.template.entity'; import { ISpace } from '@domain/space/space/space.interface'; import { SpaceLevel } from '@common/enums/space.level'; +import { IContributor } from '@domain/community/contributor/contributor.interface'; +import { CommunityContributorType } from '@common/enums/community.contributor.type'; +import { VirtualContributor } from '@domain/community/virtual-contributor/virtual.contributor.entity'; +import { User } from '@domain/community/user/user.entity'; +import { Organization } from '@domain/community/organization/organization.entity'; @Injectable() export class UrlGeneratorService { @@ -288,14 +296,39 @@ export class UrlGeneratorService { return ''; } - public createUrlForUserNameID(userNameID: string): string { - return `${this.endpoint_cluster}/${this.PATH_USER}/${userNameID}`; + public createUrlForContributor(contributor: IContributor): string { + const type = this.getContributorType(contributor); + let path = this.PATH_VIRTUAL_CONTRIBUTOR; + switch (type) { + case CommunityContributorType.USER: + path = this.PATH_USER; + break; + case CommunityContributorType.ORGANIZATION: + path = this.PATH_ORGANIZATION; + break; + case CommunityContributorType.VIRTUAL: + path = this.PATH_VIRTUAL_CONTRIBUTOR; + break; + } + return `${this.endpoint_cluster}/${path}/${contributor.nameID}`; } public createUrlForOrganizationNameID(organizationNameID: string): string { return `${this.endpoint_cluster}/${this.PATH_ORGANIZATION}/${organizationNameID}`; } + private getContributorType(contributor: IContributor) { + if (contributor instanceof User) return CommunityContributorType.USER; + if (contributor instanceof Organization) + return CommunityContributorType.ORGANIZATION; + if (contributor instanceof VirtualContributor) + return CommunityContributorType.VIRTUAL; + throw new RelationshipNotFoundException( + `Unable to determine contributor type for ${contributor.id}`, + LogContext.COMMUNITY + ); + } + public async getNameableEntityInfoOrFail( entityTableName: string, fieldName: string, diff --git a/src/services/infrastructure/user-lookup/user.lookup.module.ts b/src/services/infrastructure/user-lookup/user.lookup.module.ts index 20d6dd8916..d81cb10b74 100644 --- a/src/services/infrastructure/user-lookup/user.lookup.module.ts +++ b/src/services/infrastructure/user-lookup/user.lookup.module.ts @@ -1,10 +1,8 @@ import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { User } from '@domain/community/user/user.entity'; import { UserLookupService } from './user.lookup.service'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [], providers: [UserLookupService], exports: [UserLookupService], }) diff --git a/src/services/infrastructure/user-lookup/user.lookup.service.ts b/src/services/infrastructure/user-lookup/user.lookup.service.ts index 4f37b0554a..93017c3a25 100644 --- a/src/services/infrastructure/user-lookup/user.lookup.service.ts +++ b/src/services/infrastructure/user-lookup/user.lookup.service.ts @@ -1,16 +1,23 @@ -import { FindOneOptions, Repository } from 'typeorm'; -import { InjectRepository } from '@nestjs/typeorm'; +import { EntityManager, FindOneOptions, In } from 'typeorm'; +import { InjectEntityManager } from '@nestjs/typeorm'; import { Inject, LoggerService } from '@nestjs/common'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { User } from '@domain/community/user/user.entity'; import { IUser } from '@domain/community/user/user.interface'; import { UUID_LENGTH } from '@common/constants/entity.field.length.constants'; +import { IContributor } from '@domain/community/contributor/contributor.interface'; +import { EntityNotFoundException } from '@common/exceptions'; +import { AuthorizationCredential, LogContext } from '@common/enums'; +import { Credential, ICredential } from '@domain/agent'; +import { VirtualContributor } from '@domain/community/virtual-contributor'; +import { Organization } from '@domain/community/organization'; export class UserLookupService { constructor( - @InjectRepository(User) - private userRepository: Repository, - @Inject(WINSTON_MODULE_NEST_PROVIDER) private readonly logger: LoggerService + @InjectEntityManager('default') + private entityManager: EntityManager, + @Inject(WINSTON_MODULE_NEST_PROVIDER) + private readonly logger: LoggerService ) {} public async getUserByUUID( @@ -21,7 +28,7 @@ export class UserLookupService { if (userID.length === UUID_LENGTH) { { - user = await this.userRepository.findOne({ + user = await this.entityManager.findOne(User, { where: { id: userID, }, @@ -32,4 +39,106 @@ export class UserLookupService { return user; } + + public async getUserByUuidOrFail( + userID: string, + options?: FindOneOptions | undefined + ): Promise { + const user = await this.getUserByUUID(userID, options); + if (!user) { + throw new EntityNotFoundException( + `User with id ${userID} not found`, + LogContext.COMMUNITY + ); + } + return user; + } + + // Note: this logic should be reworked when the Account relationship to User / Organization is resolved + public async getContributorsManagedByUser( + userID: string + ): Promise { + const contributorsManagedByUser: IContributor[] = []; + const user = await this.getUserByUuidOrFail(userID, { + relations: { + agent: true, + }, + }); + if (!user.agent) { + throw new EntityNotFoundException( + `User with id ${userID} could not load the agent`, + LogContext.COMMUNITY + ); + } + + // Obviously this user managed itself :) + contributorsManagedByUser.push(user); + + // Get all the VCs hosted on accounts from the User + const accountHostCredentials = await this.getCredentialsByTypeHeldByAgent( + user.agent.id, + AuthorizationCredential.ACCOUNT_HOST + ); + const accountIDs = accountHostCredentials.map( + credential => credential.resourceID + ); + + for (const accountID of accountIDs) { + const virtualContributors = + await this.getVirtualContributorsManagedByAccount(accountID); + contributorsManagedByUser.push(...virtualContributors); + } + + // Get all the organizations managed by the User + const organiationOwnerCredentials = + await this.getCredentialsByTypeHeldByAgent( + user.agent.id, + AuthorizationCredential.ACCOUNT_HOST + ); + const organizationsIDs = organiationOwnerCredentials.map( + credential => credential.resourceID + ); + const organizations = await this.entityManager.find(Organization, { + where: { + id: In(organizationsIDs), + }, + }); + if (organizations.length > 0) { + contributorsManagedByUser.push(...organizations); + } + + return contributorsManagedByUser; + } + + private async getVirtualContributorsManagedByAccount( + accountID: string + ): Promise { + const virtualContributors = await this.entityManager.find( + VirtualContributor, + { + where: { + account: { + id: accountID, + }, + }, + } + ); + return virtualContributors; + } + + private async getCredentialsByTypeHeldByAgent( + agentID: string, + credentialType: AuthorizationCredential + ): Promise { + const hostedAccountCredentials = await this.entityManager.find(Credential, { + where: { + type: credentialType, + agent: { + id: agentID, + }, + }, + }); + + return hostedAccountCredentials; + } } diff --git a/src/services/subscriptions/subscription-service/dto/index.ts b/src/services/subscriptions/subscription-service/dto/index.ts index 583d35866a..d99e019147 100644 --- a/src/services/subscriptions/subscription-service/dto/index.ts +++ b/src/services/subscriptions/subscription-service/dto/index.ts @@ -1,2 +1,3 @@ export * from './activity.created.subscription.payload'; export * from './room.event.subscription.payload'; +export * from './whiteboard.saved.subscription.payload'; diff --git a/src/services/subscriptions/subscription-service/dto/whiteboard.saved.subscription.payload.ts b/src/services/subscriptions/subscription-service/dto/whiteboard.saved.subscription.payload.ts new file mode 100644 index 0000000000..10b5f9e41e --- /dev/null +++ b/src/services/subscriptions/subscription-service/dto/whiteboard.saved.subscription.payload.ts @@ -0,0 +1,7 @@ +import { BaseSubscriptionPayload } from '@interfaces/index'; + +export interface WhiteboardSavedSubscriptionPayload + extends BaseSubscriptionPayload { + whiteboardID: string; + updatedDate: Date; +} diff --git a/src/services/subscriptions/subscription-service/subscription.publish.service.ts b/src/services/subscriptions/subscription-service/subscription.publish.service.ts index e870f2b048..d897fd9a88 100644 --- a/src/services/subscriptions/subscription-service/subscription.publish.service.ts +++ b/src/services/subscriptions/subscription-service/subscription.publish.service.ts @@ -3,6 +3,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { SUBSCRIPTION_ACTIVITY_CREATED, SUBSCRIPTION_ROOM_EVENT, + SUBSCRIPTION_WHITEBOARD_SAVED, } from '@src/common/constants'; import { SubscriptionType } from '@common/enums/subscription.type'; import { IActivity } from '@platform/activity'; @@ -12,6 +13,7 @@ import { IMessageReaction } from '@domain/communication/message.reaction/message import { ActivityCreatedSubscriptionPayload, RoomEventSubscriptionPayload, + WhiteboardSavedSubscriptionPayload, } from './dto'; @Injectable() @@ -20,7 +22,9 @@ export class SubscriptionPublishService { @Inject(SUBSCRIPTION_ACTIVITY_CREATED) private activityCreatedSubscription: PubSubEngine, @Inject(SUBSCRIPTION_ROOM_EVENT) - private roomEventsSubscription: PubSubEngine + private roomEventsSubscription: PubSubEngine, + @Inject(SUBSCRIPTION_WHITEBOARD_SAVED) + private whiteboardSavedSubscription: PubSubEngine ) {} public publishActivity( @@ -68,6 +72,22 @@ export class SubscriptionPublishService { payload ); } + + public publishWhiteboardSaved( + whiteboardId: string, + updatedDate: Date + ): Promise { + const payload: WhiteboardSavedSubscriptionPayload = { + eventID: `whiteboard-saved-${randomInt()}`, + whiteboardID: whiteboardId, + updatedDate, + }; + + return this.whiteboardSavedSubscription.publish( + SubscriptionType.WHITEBOARD_SAVED, + payload + ); + } } const randomInt = () => Math.round(Math.random() * 1000); diff --git a/src/services/subscriptions/subscription-service/subscription.read.service.ts b/src/services/subscriptions/subscription-service/subscription.read.service.ts index 4d33471234..1bcb73f955 100644 --- a/src/services/subscriptions/subscription-service/subscription.read.service.ts +++ b/src/services/subscriptions/subscription-service/subscription.read.service.ts @@ -3,6 +3,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { SUBSCRIPTION_ACTIVITY_CREATED, SUBSCRIPTION_ROOM_EVENT, + SUBSCRIPTION_WHITEBOARD_SAVED, } from '@src/common/constants'; import { SubscriptionType } from '@common/enums/subscription.type'; @@ -12,7 +13,9 @@ export class SubscriptionReadService { @Inject(SUBSCRIPTION_ACTIVITY_CREATED) private activityCreatedSubscription: PubSubEngine, @Inject(SUBSCRIPTION_ROOM_EVENT) - private roomEventsSubscription: PubSubEngine + private roomEventsSubscription: PubSubEngine, + @Inject(SUBSCRIPTION_WHITEBOARD_SAVED) + private whiteboardSavedSubscription: PubSubEngine ) {} public subscribeToActivities() { @@ -26,4 +29,10 @@ export class SubscriptionReadService { SubscriptionType.ROOM_EVENTS ); } + + public subscribeToWhiteboardSavedEvents() { + return this.whiteboardSavedSubscription.asyncIterator( + SubscriptionType.WHITEBOARD_SAVED + ); + } } diff --git a/src/services/whiteboard-integration/outputs/base.output.data.ts b/src/services/whiteboard-integration/outputs/base.output.data.ts new file mode 100644 index 0000000000..6b2b1f500b --- /dev/null +++ b/src/services/whiteboard-integration/outputs/base.output.data.ts @@ -0,0 +1,3 @@ +export class BaseOutputData { + constructor(public event: string) {} +} diff --git a/src/services/whiteboard-integration/outputs/health.check.output.data.ts b/src/services/whiteboard-integration/outputs/health.check.output.data.ts new file mode 100644 index 0000000000..6f0be150e0 --- /dev/null +++ b/src/services/whiteboard-integration/outputs/health.check.output.data.ts @@ -0,0 +1,7 @@ +import { BaseOutputData } from './base.output.data'; + +export class HealthCheckOutputData extends BaseOutputData { + constructor(public healthy: boolean) { + super('health-check-output'); + } +} diff --git a/src/services/whiteboard-integration/outputs/index.ts b/src/services/whiteboard-integration/outputs/index.ts index 037283415e..3377ce2d94 100644 --- a/src/services/whiteboard-integration/outputs/index.ts +++ b/src/services/whiteboard-integration/outputs/index.ts @@ -1 +1,2 @@ export * from './info.output.data'; +export * from './health.check.output.data'; diff --git a/src/services/whiteboard-integration/types/event.pattern.ts b/src/services/whiteboard-integration/types/event.pattern.ts index 40d3797631..2cfbfc2b70 100644 --- a/src/services/whiteboard-integration/types/event.pattern.ts +++ b/src/services/whiteboard-integration/types/event.pattern.ts @@ -1,4 +1,5 @@ export enum WhiteboardIntegrationEventPattern { CONTRIBUTION = 'contribution', CONTENT_MODIFIED = 'contentModified', + HEALTH_CHECK = 'health-check', } diff --git a/src/services/whiteboard-integration/whiteboard.integration.controller.ts b/src/services/whiteboard-integration/whiteboard.integration.controller.ts index 485cbc8e9b..3e6cbc6ed1 100644 --- a/src/services/whiteboard-integration/whiteboard.integration.controller.ts +++ b/src/services/whiteboard-integration/whiteboard.integration.controller.ts @@ -7,6 +7,7 @@ import { RmqContext, Transport, } from '@nestjs/microservices'; +import { ack } from '../util'; import { UserInfo, WhiteboardIntegrationMessagePattern } from './types'; import { WhiteboardIntegrationService } from './whiteboard.integration.service'; import { WhiteboardIntegrationEventPattern } from './types/event.pattern'; @@ -16,8 +17,7 @@ import { InfoInputData, WhoInputData, } from './inputs'; -import { InfoOutputData } from './outputs/info.output.data'; -import { ack } from '../util'; +import { InfoOutputData, HealthCheckOutputData } from './outputs'; /** * Controller exposing the Whiteboard Integration service via message queue. @@ -69,4 +69,12 @@ export class WhiteboardIntegrationController { ack(context); this.integrationService.contentModified(data); } + + @MessagePattern(WhiteboardIntegrationEventPattern.HEALTH_CHECK, Transport.RMQ) + public health(@Ctx() context: RmqContext): HealthCheckOutputData { + ack(context); + // can be tight to more complex health check in the future + // for now just return true + return new HealthCheckOutputData(true); + } } diff --git a/src/services/whiteboard-integration/whiteboard.integration.service.ts b/src/services/whiteboard-integration/whiteboard.integration.service.ts index 106cd5f20e..a65efc2e4c 100644 --- a/src/services/whiteboard-integration/whiteboard.integration.service.ts +++ b/src/services/whiteboard-integration/whiteboard.integration.service.ts @@ -45,29 +45,6 @@ export class WhiteboardIntegrationService { )?.whiteboards?.max_collaborators_in_room; } - private async buildAgentInfo(userId: string): Promise { - const user = await this.userService.getUserOrFail(userId, { - relations: { agent: true }, - }); - - if (!user.agent) { - throw new EntityNotInitializedException( - `Agent not loaded for User: ${user.id}`, - LogContext.AUTH, - { userId } - ); - } - - // const verifiedCredentials = - // await this.agentService.getVerifiedCredentials(user.agent); - const verifiedCredentials = [] as IVerifiedCredential[]; - // construct the agent info object needed for isAccessGranted - return { - credentials: user.agent.credentials ?? [], - verifiedCredentials, - } as AgentInfo; - } - public async accessGranted(data: AccessGrantedInputData): Promise { try { const whiteboard = await this.whiteboardService.getWhiteboardOrFail( @@ -160,4 +137,27 @@ export class WhiteboardIntegrationService { this.logger.error(err?.message, err?.stack, LogContext.ACTIVITY); }); } + + private async buildAgentInfo(userId: string): Promise { + const user = await this.userService.getUserOrFail(userId, { + relations: { agent: true }, + }); + + if (!user.agent) { + throw new EntityNotInitializedException( + `Agent not loaded for User: ${user.id}`, + LogContext.AUTH, + { userId } + ); + } + + // const verifiedCredentials = + // await this.agentService.getVerifiedCredentials(user.agent); + const verifiedCredentials = [] as IVerifiedCredential[]; + // construct the agent info object needed for isAccessGranted + return { + credentials: user.agent.credentials ?? [], + verifiedCredentials, + } as AgentInfo; + } } diff --git a/test/mocks/invitation.service.mock.ts b/test/mocks/invitation.service.mock.ts index 12293d43bc..2f8969d3d1 100644 --- a/test/mocks/invitation.service.mock.ts +++ b/test/mocks/invitation.service.mock.ts @@ -7,7 +7,7 @@ export const MockInvitationService: ValueProvider< > = { provide: InvitationService, useValue: { - findInvitationsForUser: jest.fn(), + findInvitationsForContributor: jest.fn(), isFinalizedInvitation: jest.fn(), getInvitationState: jest.fn(), }, diff --git a/test/mocks/user.lookup.service.mock.ts b/test/mocks/user.lookup.service.mock.ts new file mode 100644 index 0000000000..0e1ff746df --- /dev/null +++ b/test/mocks/user.lookup.service.mock.ts @@ -0,0 +1,14 @@ +import { ValueProvider } from '@nestjs/common'; +import { PublicPart } from '../utils/public-part'; +import { UserLookupService } from '@services/infrastructure/user-lookup/user.lookup.service'; + +export const MockUserLookupService: ValueProvider< + PublicPart +> = { + provide: UserLookupService, + useValue: { + getContributorsManagedByUser: jest.fn(), + getUserByUUID: jest.fn(), + getUserByUuidOrFail: jest.fn(), + }, +};