diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 0b176cf382..d3a8dea80b 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -310,7 +310,7 @@ services: - "4004:4004" notification: - image: alkemio/notifications:v0.37.0 + image: alkemio/notifications:v0.39.0 platform: linux/amd64 extra_hosts: - "host.docker.internal:host-gateway" diff --git a/alkemio.yml b/alkemio.yml index 62ba1d9050..776c4c2ff7 100644 --- a/alkemio.yml +++ b/alkemio.yml @@ -534,6 +534,12 @@ notifications: enabled: ${CALLOUT_REACTION_NOTIFICATIONS_ENABLED}:true email_suppression_window_seconds: ${CALLOUT_REACTION_EMAIL_SUPPRESSION_WINDOW_SECONDS}:300 + # Organization space-invitation notifications. NOT declared on any + # deployment manifest this release — the in-code default below governs + # everywhere; changing the support escalation address requires a deploy. + organization_invitations: + support_email: ${NOTIFICATIONS_ORGANIZATION_INVITATION_SUPPORT_EMAIL}:support@alkem.io + # Chat/conversation message notifications (034-messaging-notifications). # NOT declared on any deployment manifest this release (Operator Ruling 3b) — # the in-code defaults below govern everywhere; flipping `enabled` in diff --git a/quickstart-services.yml b/quickstart-services.yml index 289d1b4c4a..f97cc6f463 100644 --- a/quickstart-services.yml +++ b/quickstart-services.yml @@ -433,7 +433,7 @@ services: - 'host.docker.internal:host-gateway' container_name: alkemio_dev_notifications hostname: notifications - image: alkemio/notifications:v0.38.0 + image: alkemio/notifications:v0.39.0 depends_on: rabbitmq: condition: service_healthy diff --git a/schema.graphql b/schema.graphql index e5678c760c..474665e510 100644 --- a/schema.graphql +++ b/schema.graphql @@ -581,6 +581,8 @@ enum MutationType { enum NotificationEvent { ORGANIZATION_ADMIN_MENTIONED ORGANIZATION_ADMIN_MESSAGE + ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION + ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED ORGANIZATION_MESSAGE_SENDER PLATFORM_ADMIN_GLOBAL_ROLE_CHANGED PLATFORM_ADMIN_SPACE_CREATED @@ -591,6 +593,10 @@ enum NotificationEvent { SPACE_ADMIN_COLLABORATION_CALLOUT_CONTRIBUTION SPACE_ADMIN_COMMUNITY_APPLICATION SPACE_ADMIN_COMMUNITY_NEW_MEMBER + SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED + SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED + SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED + SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED SPACE_ADMIN_VIRTUAL_COMMUNITY_INVITATION_DECLINED SPACE_COLLABORATION_CALLOUT_COMMENT SPACE_COLLABORATION_CALLOUT_CONTRIBUTION @@ -798,6 +804,10 @@ enum RoleName { REGISTERED } +enum RoleSetInvitationResultNotice { + ORGANIZATION_HAS_NO_ADMINISTRATORS +} + enum RoleSetInvitationResultType { ALREADY_HAS_OPEN_APPLICATION ALREADY_INVITED_TO_PLATFORM_AND_ROLE_SET @@ -806,6 +816,8 @@ enum RoleSetInvitationResultType { INVITATION_TO_PARENT_NOT_AUTHORIZED INVITED_TO_PLATFORM_AND_ROLE_SET INVITED_TO_ROLE_SET + ORGANIZATION_LEAD_ROLE_LIMIT_REACHED + ORGANIZATION_NOT_ACCEPTING_INVITATIONS } enum RoleSetRoleImplicit { @@ -3365,6 +3377,14 @@ type InAppNotificationPayloadSpaceCommunityCalendarEventComment implements InApp } type InAppNotificationPayloadSpaceCommunityInvitation implements InAppNotificationPayload { + """ + The underlying invitation — role(s) offered, whether the parent Space is also joined, and the Spaces that will be joined on acceptance. + """ + invitation: Invitation + """ + The organization the invitation is for, when the invitee is an organization. + """ + organization: Organization """The Space that the invitation is for.""" space: Space! """The payload type.""" @@ -3565,6 +3585,10 @@ type Invitation { lifecycle: Lifecycle! """The next events of this Lifecycle.""" nextEvents: [String!]! + """ + The Spaces that will be joined if this invitation is accepted, root Space first; null when the caller may not answer this invitation on the invited Actor's behalf. + """ + spacesToJoinOnAccept: [SpaceJoinPreview!] """The current state of this Lifecycle.""" state: String! """ @@ -5030,6 +5054,8 @@ type OrganizationSettings { } type OrganizationSettingsMembership { + """Allow Spaces to invite this Organization to join them.""" + allowSpaceInvitations: Boolean! """ Allow Users with email addresses matching the domain of this Organization to join. """ @@ -6166,6 +6192,18 @@ type RoleSetInvitationResult { """ application: Application invitation: Invitation + """ + The id of the invited actor this result belongs to, when the invitee was an actor or an email that resolved to an existing user. + """ + invitedActorID: UUID + """ + The email address this result belongs to, when the invitee was submitted as an email address. + """ + invitedEmail: String + """ + An informational addendum to the result, set only alongside a successful invite outcome. + """ + notice: RoleSetInvitationResultNotice platformInvitation: PlatformInvitation type: RoleSetInvitationResultType! } @@ -6612,6 +6650,15 @@ type SpaceAboutMembership { roleSetID: UUID! } +type SpaceJoinPreview { + """The display name of the Space that will be joined.""" + displayName: String! + """The ID of the Space that will be joined.""" + id: UUID! + """The URL of the Space that will be joined.""" + url: String! +} + type SpacePendingMembershipInfo { """About the Space""" about: SpaceAbout! @@ -7369,6 +7416,10 @@ type UserSettingsNotificationOrganization { Receive notification when the organization you are admin of is messaged """ adminMessageReceived: UserSettingsNotificationChannels! + """ + Receive a notification when an organization you administer is invited to a Space + """ + adminSpaceCommunityInvitation: UserSettingsNotificationChannels! } type UserSettingsNotificationPlatform { @@ -7444,6 +7495,10 @@ type UserSettingsNotificationSpaceAdmin { communicationMessageReceived: UserSettingsNotificationChannels! """Receive a notification when an application is received""" communityApplicationReceived: UserSettingsNotificationChannels! + """ + Receive a notification when someone responds to an invitation you sent (admin) + """ + communityInvitationResponse: UserSettingsNotificationChannels! """Receive a notification when a new member joins the community (admin)""" communityNewMember: UserSettingsNotificationChannels! """ @@ -10134,10 +10189,12 @@ input UpdateOrganizationSettingsInput { } input UpdateOrganizationSettingsMembershipInput { + """Allow Spaces to invite this Organization to join them.""" + allowSpaceInvitations: Boolean """ Allow Users with email addresses matching the domain of this Organization to join. """ - allowUsersMatchingDomainToJoin: Boolean! + allowUsersMatchingDomainToJoin: Boolean } input UpdateOrganizationSettingsPrivacyInput { @@ -10507,6 +10564,10 @@ input UpdateUserSettingsNotificationOrganizationInput { Receive notification when the organization you are admin of is messaged """ adminMessageReceived: NotificationSettingInput + """ + Receive a notification when an organization you administer is invited to a Space + """ + adminSpaceCommunityInvitation: NotificationSettingInput } input UpdateUserSettingsNotificationPlatformAdminInput { @@ -10553,6 +10614,10 @@ input UpdateUserSettingsNotificationSpaceAdminInput { communicationMessageReceived: NotificationSettingInput """Receive a notification when an application is received""" communityApplicationReceived: NotificationSettingInput + """ + Receive a notification when someone responds to an invitation you sent (admin) + """ + communityInvitationResponse: NotificationSettingInput """Receive a notification when a new member joins the community (admin)""" communityNewMember: NotificationSettingInput """ diff --git a/src/common/constants/authorization/index.ts b/src/common/constants/authorization/index.ts index 8bfa9ab193..abc202e2bd 100644 --- a/src/common/constants/authorization/index.ts +++ b/src/common/constants/authorization/index.ts @@ -1,4 +1,5 @@ export * from './credential.rule.constants'; export * from './credential.rule.types.constants'; export * from './global.policy.constants'; +export * from './organization.manager.credentials'; export * from './policy.rule.constants'; diff --git a/src/common/constants/authorization/organization.manager.credentials.ts b/src/common/constants/authorization/organization.manager.credentials.ts new file mode 100644 index 0000000000..6ba15981b9 --- /dev/null +++ b/src/common/constants/authorization/organization.manager.credentials.ts @@ -0,0 +1,21 @@ +import { AuthorizationCredential } from '@common/enums/authorization.credential'; + +// The credential types that make a user a manager of an organization — +// able to act on its behalf (accept/decline invitations, edit settings) +// regardless of whether they also hold associate membership. Shared by +// every lookup that needs "who manages this organization" rather than +// "who is a member of this organization". +export const ORGANIZATION_MANAGER_CREDENTIAL_TYPES: readonly AuthorizationCredential[] = + [ + AuthorizationCredential.ORGANIZATION_OWNER, + AuthorizationCredential.ORGANIZATION_ADMIN, + ]; + +// The credential types that receive an organization's notifications. +// Deliberately NARROWER than ORGANIZATION_MANAGER_CREDENTIAL_TYPES above: +// product asked for "all organization admins" only (server#4100 AC, +// notifications#356 AC and the product email thread all say admins, never +// owners), so an owner who is not also an admin manages the organization and +// may accept on its behalf, but is not notified. +export const ORGANIZATION_NOTIFICATION_CREDENTIAL_TYPES: readonly AuthorizationCredential[] = + [AuthorizationCredential.ORGANIZATION_ADMIN]; diff --git a/src/common/constants/entity.field.length.constants.ts b/src/common/constants/entity.field.length.constants.ts index 8f183c095e..f8e9092202 100644 --- a/src/common/constants/entity.field.length.constants.ts +++ b/src/common/constants/entity.field.length.constants.ts @@ -16,6 +16,15 @@ export const NAMEID_MAX_LENGTH = 25; export const NAMEID_MIN_LENGTH = 5; // polls export const POLL_OPTIONS_MAX_COUNT = 10; +// role set invitations: caps EACH invitee field independently +// (`invitedActorIDs` and `invitedUserEmails` each carry their own +// `@ArrayMaxSize`), so a single mutation's worst-case fan-out is 2x this +// value. The bound exists to stop an unbounded number of per-invitee guard +// checks; it is not a per-operation total. +export const ROLE_SET_INVITE_BATCH_MAX = 100; +// Upper bound on extraRoles per invite request: there are only a handful of +// RoleName values, so anything larger is a malformed or hostile payload. +export const ROLE_SET_INVITE_EXTRA_ROLES_MAX = 10; // others export const ENUM_LENGTH = 128; // https://www.rfc-editor.org/rfc/rfc1034#section-3.1 diff --git a/src/common/enums/community.membership.origin.ts b/src/common/enums/community.membership.origin.ts new file mode 100644 index 0000000000..02de44c38c --- /dev/null +++ b/src/common/enums/community.membership.origin.ts @@ -0,0 +1,30 @@ +/** + * How an actor came to hold a role in a community. Used to suppress the + * generic "a new member joined" admin notification when the membership was + * the outcome of a step the admins were already notified about, so one event + * never produces two notifications. + * + * The rule is: **suppress only where a replacement notification exists.** An + * accepted INVITATION has one -- "X accepted the invitation" reaches every + * admin of the invited Space (FR-020) -- so the generic notification would be + * the second of a pair, which is what the product brief rules out. + * + * An approved APPLICATION deliberately has NO member here. There is no + * application-approved event to take the suppressed notification's place -- + * `SPACE_ADMIN_COMMUNITY_APPLICATION` fires at submission, not at approval -- + * so suppressing it leaves the approving admin's co-admins told nothing at + * all. Zero is not one, and that flow is outside what server#4100 changes. + * The member is omitted rather than left unused so it cannot be quietly + * reintroduced (ruling R40, restoring R31; R35 had briefly added it). + * + * Adding the missing application-approved event is tracked as + * alkem-io/server#6476. If it lands, add the member back here and thread it in + * `RoleSetService.ensureMemberOfRoleSetAndAncestors` -- at that point the + * replacement exists and the suppression becomes correct. + */ +export enum CommunityMembershipOrigin { + /** Direct assignment by an admin, a direct join, bootstrap, conversion. */ + DIRECT = 'DIRECT', + /** The actor accepted an invitation to the community. */ + INVITATION = 'INVITATION', +} diff --git a/src/common/enums/notification.event.ts b/src/common/enums/notification.event.ts index 8814da1547..5c287e4ac6 100644 --- a/src/common/enums/notification.event.ts +++ b/src/common/enums/notification.event.ts @@ -14,11 +14,17 @@ export enum NotificationEvent { ORGANIZATION_ADMIN_MESSAGE = 'ORGANIZATION_ADMIN_MESSAGE', ORGANIZATION_ADMIN_MENTIONED = 'ORGANIZATION_ADMIN_MENTIONED', ORGANIZATION_MESSAGE_SENDER = 'ORGANIZATION_MESSAGE_SENDER', + ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION = 'ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION', + ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED = 'ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED', // space admin SPACE_ADMIN_COMMUNITY_APPLICATION = 'SPACE_ADMIN_COMMUNITY_APPLICATION', SPACE_ADMIN_COLLABORATION_CALLOUT_CONTRIBUTION = 'SPACE_ADMIN_COLLABORATION_CALLOUT_CONTRIBUTION', SPACE_ADMIN_COMMUNITY_NEW_MEMBER = 'SPACE_ADMIN_COMMUNITY_NEW_MEMBER', SPACE_ADMIN_VIRTUAL_COMMUNITY_INVITATION_DECLINED = 'SPACE_ADMIN_VIRTUAL_COMMUNITY_INVITATION_DECLINED', + SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED = 'SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED', + SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED = 'SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED', + SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED = 'SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED', + SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED = 'SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED', // space lead SPACE_LEAD_COMMUNICATION_MESSAGE = 'SPACE_LEAD_COMMUNICATION_MESSAGE', // space community diff --git a/src/common/enums/role.set.invitation.result.notice.ts b/src/common/enums/role.set.invitation.result.notice.ts new file mode 100644 index 0000000000..e058776575 --- /dev/null +++ b/src/common/enums/role.set.invitation.result.notice.ts @@ -0,0 +1,12 @@ +import { registerEnumType } from '@nestjs/graphql'; + +// Optional, informational addendum to a RoleSetInvitationResult that +// stayed a success (e.g. INVITED_TO_ROLE_SET) but has something the +// caller should know without treating the invite as failed. +export enum RoleSetInvitationResultNotice { + ORGANIZATION_HAS_NO_ADMINISTRATORS = 'organization-has-no-administrators', +} + +registerEnumType(RoleSetInvitationResultNotice, { + name: 'RoleSetInvitationResultNotice', +}); diff --git a/src/common/enums/role.set.invitation.result.type.ts b/src/common/enums/role.set.invitation.result.type.ts index 7ce44f3b95..70a0905f2f 100644 --- a/src/common/enums/role.set.invitation.result.type.ts +++ b/src/common/enums/role.set.invitation.result.type.ts @@ -8,6 +8,8 @@ export enum RoleSetInvitationResultType { INVITATION_TO_PARENT_NOT_AUTHORIZED = 'invitation-to-parent-not-authorized', ALREADY_HAS_OPEN_APPLICATION = 'already-has-open-application', ALREADY_MEMBER_OF_ROLE_SET = 'already-member-of-role-set', + ORGANIZATION_NOT_ACCEPTING_INVITATIONS = 'organization-not-accepting-invitations', + ORGANIZATION_LEAD_ROLE_LIMIT_REACHED = 'organization-lead-role-limit-reached', } registerEnumType(RoleSetInvitationResultType, { diff --git a/src/config/organization-invitation-notifications.config.spec.ts b/src/config/organization-invitation-notifications.config.spec.ts new file mode 100644 index 0000000000..f5141138dc --- /dev/null +++ b/src/config/organization-invitation-notifications.config.spec.ts @@ -0,0 +1,54 @@ +/** + * Asserts the in-code default for the organization-invitation support + * escalation address resolves correctly when the corresponding env var is + * absent, and that it is env-overridable. + */ + +const ORGANIZATION_INVITATION_ENV_VARS = [ + 'NOTIFICATIONS_ORGANIZATION_INVITATION_SUPPORT_EMAIL', +] as const; + +describe('organization-invitation notifications configuration defaults', () => { + const origEnv: Record = {}; + + beforeEach(() => { + vi.restoreAllMocks(); + for (const key of ORGANIZATION_INVITATION_ENV_VARS) { + origEnv[key] = process.env[key]; + delete process.env[key]; + } + origEnv.ALKEMIO_CONFIG_PATH = process.env.ALKEMIO_CONFIG_PATH; + delete process.env.ALKEMIO_CONFIG_PATH; + }); + + afterEach(() => { + vi.restoreAllMocks(); + for (const [key, val] of Object.entries(origEnv)) { + if (val === undefined) delete process.env[key]; + else process.env[key] = val; + } + }); + + async function loadConfiguration() { + const mod = await import('./configuration'); + return mod.default; + } + + it('defaults support_email to support@alkem.io when the env var is absent', async () => { + const factory = await loadConfiguration(); + const result = factory(); + expect(result.notifications.organization_invitations.support_email).toBe( + 'support@alkem.io' + ); + }); + + it('support_email is env-overridable', async () => { + process.env.NOTIFICATIONS_ORGANIZATION_INVITATION_SUPPORT_EMAIL = + 'escalations@example.com'; + const factory = await loadConfiguration(); + const result = factory(); + expect(result.notifications.organization_invitations.support_email).toBe( + 'escalations@example.com' + ); + }); +}); diff --git a/src/core/dataloader/creators/loader.creators/in-app-notification/invitation.loader.creator.ts b/src/core/dataloader/creators/loader.creators/in-app-notification/invitation.loader.creator.ts new file mode 100644 index 0000000000..8b5ee61e21 --- /dev/null +++ b/src/core/dataloader/creators/loader.creators/in-app-notification/invitation.loader.creator.ts @@ -0,0 +1,39 @@ +import { EntityNotFoundException } from '@common/exceptions'; +import { + DataLoaderCreator, + DataLoaderCreatorBaseOptions, +} from '@core/dataloader/creators/base'; +import { ILoader } from '@core/dataloader/loader.interface'; +import { createBatchLoader } from '@core/dataloader/utils'; +import { Invitation } from '@domain/access/invitation/invitation.entity'; +import { IInvitation } from '@domain/access/invitation/invitation.interface'; +import { Injectable } from '@nestjs/common'; +import { InjectEntityManager } from '@nestjs/typeorm'; +import { EntityManager, In } from 'typeorm'; + +@Injectable() +export class InvitationLoaderCreator implements DataLoaderCreator { + constructor(@InjectEntityManager() private manager: EntityManager) {} + + public create( + options?: DataLoaderCreatorBaseOptions + ): ILoader { + return createBatchLoader(this.invitationInBatch, { + name: this.constructor.name, + loadedTypeName: Invitation.name, + resolveToNull: options?.resolveToNull, + }); + } + + private invitationInBatch = ( + keys: ReadonlyArray + ): Promise => { + // Loads the roleSet relation eagerly so downstream field resolvers + // (e.g. spacesToJoinOnAccept) never pay a per-row reload just to + // discover the roleSet the dataloader could have batched already. + return this.manager.find(Invitation, { + where: { id: In(keys) }, + relations: { roleSet: true }, + }); + }; +} diff --git a/src/core/dataloader/creators/loader.creators/index.ts b/src/core/dataloader/creators/loader.creators/index.ts index 46df4345c5..1e7ea4f9c7 100644 --- a/src/core/dataloader/creators/loader.creators/index.ts +++ b/src/core/dataloader/creators/loader.creators/index.ts @@ -18,6 +18,7 @@ export * from './community/community.roleset.loader.creator'; export * from './conversation/conversation.memberships.loader.creator'; export * from './in-app-notification/actor.loader.creator'; export * from './in-app-notification/callout.loader.creator'; +export * from './in-app-notification/invitation.loader.creator'; export * from './in-app-notification/poll.loader.creator'; export * from './in-app-notification/space.loader.creator'; export * from './license.loader.creator'; diff --git a/src/domain/access/invitation/dto/invitation.dto.space.join.preview.ts b/src/domain/access/invitation/dto/invitation.dto.space.join.preview.ts new file mode 100644 index 0000000000..1234f66449 --- /dev/null +++ b/src/domain/access/invitation/dto/invitation.dto.space.join.preview.ts @@ -0,0 +1,42 @@ +import { UUID } from '@domain/common/scalars'; +import { Field, ObjectType } from '@nestjs/graphql'; + +/** + * The informed-consent preview of ONE Space that accepting an invitation + * joins (FR-013). + * + * Deliberately NOT `ISpaceAbout`. The `spacesToJoinOnAccept` field is gated + * on ROLESET_ENTRY_ROLE_INVITE_ACCEPT — granted to account admins of the + * INVITED actor — precisely so an organization's admins can preview the + * chain without holding READ on it. Returning `ISpaceAbout` therefore handed + * those admins the full About content (`why`, `who`, `profile.description`, + * `references`, `tagsets`, `guidelines`, `classifications`) of private + * ancestor Spaces, none of which carry a field-level authorization + * decorator: the Space-level gate that normally fronts SpaceAbout was + * bypassed by resolving it from the invitation instead. + * + * This type is the enumeration FR-013 actually needs, and matches exactly + * what the equivalent email path already discloses — a display name and a + * link. Any widening of it re-opens that leak, so add fields only with the + * per-Space authorization filter this field intentionally does not apply. + */ +@ObjectType('SpaceJoinPreview') +export abstract class ISpaceJoinPreview { + @Field(() => UUID, { + nullable: false, + description: 'The ID of the Space that will be joined.', + }) + id!: string; + + @Field(() => String, { + nullable: false, + description: 'The display name of the Space that will be joined.', + }) + displayName!: string; + + @Field(() => String, { + nullable: false, + description: 'The URL of the Space that will be joined.', + }) + url!: string; +} diff --git a/src/domain/access/invitation/index.ts b/src/domain/access/invitation/index.ts index c749ad5e9b..5cc0f886de 100644 --- a/src/domain/access/invitation/index.ts +++ b/src/domain/access/invitation/index.ts @@ -1,5 +1,6 @@ export * from './dto/invitation.dto.create'; export * from './dto/invitation.dto.delete'; export * from './dto/invitation.dto.event'; +export * from './dto/invitation.dto.space.join.preview'; export * from './invitation.entity'; export * from './invitation.interface'; diff --git a/src/domain/access/invitation/invitation.module.ts b/src/domain/access/invitation/invitation.module.ts index 5532599bdb..d9a746d4bd 100644 --- a/src/domain/access/invitation/invitation.module.ts +++ b/src/domain/access/invitation/invitation.module.ts @@ -8,8 +8,10 @@ import { LifecycleModule } from '@domain/common/lifecycle/lifecycle.module'; import { UserLookupModule } from '@domain/community/user-lookup/user.lookup.module'; import { VirtualActorLookupModule } from '@domain/community/virtual-contributor-lookup/virtual.contributor.lookup.module'; import { AccountLookupModule } from '@domain/space/account.lookup/account.lookup.module'; -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { UrlGeneratorModule } from '@services/infrastructure/url-generator'; +import { RoleSetModule } from '../role-set/role.set.module'; import { RoleSetCacheModule } from '../role-set/role.set.service.cache.module'; import { InvitationResolverFields } from './invitation.resolver.fields'; import { InvitationLifecycleResolverFields } from './invitation.resolver.fields.lifecycle'; @@ -29,6 +31,8 @@ import { InvitationLifecycleService } from './invitation.service.lifecycle'; AccountLookupModule, TypeOrmModule.forFeature([Invitation]), RoleSetCacheModule, + UrlGeneratorModule, + forwardRef(() => RoleSetModule), ], providers: [ InvitationService, diff --git a/src/domain/access/invitation/invitation.resolver.fields.spec.ts b/src/domain/access/invitation/invitation.resolver.fields.spec.ts index 58755389db..d6e6ae07f7 100644 --- a/src/domain/access/invitation/invitation.resolver.fields.spec.ts +++ b/src/domain/access/invitation/invitation.resolver.fields.spec.ts @@ -1,14 +1,21 @@ +import { AuthorizationService } from '@core/authorization/authorization.service'; import { Test, TestingModule } from '@nestjs/testing'; +import { UrlGeneratorService } from '@services/infrastructure/url-generator'; import { MockCacheManager } from '@test/mocks/cache-manager.mock'; import { MockWinstonProvider } from '@test/mocks/winston.provider.mock'; import { defaultMockerFactory } from '@test/utils/default.mocker.factory'; import { type Mock } from 'vitest'; +import { RoleSetService } from '../role-set/role.set.service'; import { InvitationResolverFields } from './invitation.resolver.fields'; import { InvitationService } from './invitation.service'; describe('InvitationResolverFields', () => { let resolver: InvitationResolverFields; let invitationService: InvitationService; + let roleSetService: RoleSetService; + let authorizationService: AuthorizationService; + let urlGeneratorService: UrlGeneratorService; + const actorContext = { actorID: 'user-1' } as any; beforeEach(async () => { vi.restoreAllMocks(); @@ -25,6 +32,17 @@ describe('InvitationResolverFields', () => { resolver = module.get(InvitationResolverFields); invitationService = module.get(InvitationService); + roleSetService = module.get(RoleSetService); + authorizationService = + module.get(AuthorizationService); + urlGeneratorService = module.get(UrlGeneratorService); + (authorizationService.isAccessGranted as Mock).mockReturnValue(true); + // Derived from the profile it is handed, NOT a single constant: a + // constant would make passing the WRONG profile on every iteration + // indistinguishable from passing the right one. + (urlGeneratorService.generateUrlForProfile as Mock).mockImplementation( + async (profile: { id: string }) => `/space/${profile.id}` + ); }); it('should be defined', () => { @@ -70,4 +88,271 @@ describe('InvitationResolverFields', () => { expect(result).toBeNull(); }); }); + + describe('spacesToJoinOnAccept', () => { + // A Space as `getSpacesToJoinOnAccept` actually returns it: `about` is a + // full ISpaceAbout carrying the ungated private content the projection + // exists to withhold. + const mockSpace = (id: string, displayName: string) => + ({ + id, + authorization: { id: `auth-${id}` }, + about: { + id: `about-${id}`, + why: `SECRET why for ${id}`, + who: `SECRET who for ${id}`, + guidelines: { id: `guidelines-${id}` }, + profile: { + id: `profile-${id}`, + displayName, + description: `SECRET description for ${id}`, + references: [{ uri: 'https://secret.example' }], + tagsets: [{ tags: ['secret'] }], + }, + }, + }) as any; + + const preview = (id: string, displayName: string) => ({ + id, + displayName, + url: `/space/profile-${id}`, + }); + + it('resolves via the roleSet already loaded on the invitation, target last', async () => { + const mockRoleSet = { id: 'rs-1' } as any; + const mockInvitation = { + id: 'inv-1', + invitedActorID: 'org-1', + invitedToParent: true, + roleSet: mockRoleSet, + // Eager on the entity; the resolver returns null without it (see the + // missing-policy case below), so every fixture here must carry one. + authorization: { id: 'auth-inv-1' }, + } as any; + (roleSetService.getSpacesToJoinOnAccept as Mock).mockResolvedValue([ + mockSpace('root', 'Root Space'), + mockSpace('target', 'Target Space'), + ]); + + const result = await resolver.spacesToJoinOnAccept( + mockInvitation, + actorContext + ); + + expect(roleSetService.getSpacesToJoinOnAccept).toHaveBeenCalledWith( + mockRoleSet, + 'org-1', + true + ); + expect(invitationService.getInvitationOrFail).not.toHaveBeenCalled(); + expect(result).toEqual([ + preview('root', 'Root Space'), + preview('target', 'Target Space'), + ]); + }); + + it('reloads the invitation with its roleSet relation when absent on the parent', async () => { + const mockRoleSet = { id: 'rs-1' } as any; + const mockInvitation = { + id: 'inv-1', + invitedActorID: 'org-1', + invitedToParent: false, + authorization: { id: 'auth-inv-1' }, + // roleSet absent + } as any; + (invitationService.getInvitationOrFail as Mock).mockResolvedValue({ + ...mockInvitation, + roleSet: mockRoleSet, + }); + (roleSetService.getSpacesToJoinOnAccept as Mock).mockResolvedValue([ + mockSpace('target', 'Target Space'), + ]); + + const result = await resolver.spacesToJoinOnAccept( + mockInvitation, + actorContext + ); + + expect(invitationService.getInvitationOrFail).toHaveBeenCalledWith( + 'inv-1', + { relations: { roleSet: true } } + ); + expect(roleSetService.getSpacesToJoinOnAccept).toHaveBeenCalledWith( + mockRoleSet, + 'org-1', + false + ); + expect(result).toEqual([preview('target', 'Target Space')]); + }); + + it('enumerates every Space getSpacesToJoinOnAccept returns, including a private ancestor the reviewing admin holds no personal READ_ABOUT on', async () => { + // The field gate already confines this resolver to the invited + // actor's own account admins, who are consenting on the + // organization's behalf rather than their own — so the list must + // never shrink based on the reviewing admin's personal Space + // credentials. + const mockRoleSet = { id: 'rs-1' } as any; + const mockInvitation = { + id: 'inv-1', + invitedActorID: 'org-1', + invitedToParent: true, + roleSet: mockRoleSet, + authorization: { id: 'auth-inv-1' }, + } as any; + (roleSetService.getSpacesToJoinOnAccept as Mock).mockResolvedValue([ + mockSpace('root-private', 'Private Root'), + mockSpace('target', 'Target Space'), + ]); + + const result = await resolver.spacesToJoinOnAccept( + mockInvitation, + actorContext + ); + + expect(result).toEqual([ + preview('root-private', 'Private Root'), + preview('target', 'Target Space'), + ]); + }); + + it('discloses ONLY id/displayName/url — never the ancestor Space About content', async () => { + // The field gate is ROLESET_ENTRY_ROLE_INVITE_ACCEPT, granted to the + // INVITED actor's account admins, who by design hold no READ on the + // Spaces being previewed — and this resolver deliberately applies no + // per-Space filter. Returning ISpaceAbout therefore handed those + // admins `why`, `who`, `profile.description`, `references`, `tagsets`, + // `guidelines` and `classifications` of every private ancestor, none + // of which carry a field-level authorization decorator of their own. + // The projection is the entire boundary; this test is what holds it. + const mockInvitation = { + id: 'inv-1', + invitedActorID: 'org-1', + invitedToParent: true, + roleSet: { id: 'rs-1' }, + authorization: { id: 'auth-inv-1' }, + } as any; + (roleSetService.getSpacesToJoinOnAccept as Mock).mockResolvedValue([ + mockSpace('root-private', 'Private Root'), + ]); + + const result = await resolver.spacesToJoinOnAccept( + mockInvitation, + actorContext + ); + + expect(result).toHaveLength(1); + expect(Object.keys(result![0]).sort()).toEqual([ + 'displayName', + 'id', + 'url', + ]); + expect(JSON.stringify(result)).not.toContain('SECRET'); + // The URL must be generated from THIS Space's own profile, not + // inherited from a sibling iteration. + expect(urlGeneratorService.generateUrlForProfile).toHaveBeenCalledWith( + expect.objectContaining({ id: 'profile-root-private' }) + ); + }); + + it('returns null — never throws — when the caller may not answer the invitation', async () => { + // The field is spread by the shared InvitationData fragment that the + // top-bar dialog and the in-app notifications panel select for every + // invitation the viewer can read. Throwing would attach a GraphQL + // error to those fetches and null out the non-null + // CommunityInvitationResult.invitation. + (authorizationService.isAccessGranted as Mock).mockReturnValue(false); + const mockInvitation = { + id: 'inv-1', + invitedActorID: 'org-1', + invitedToParent: true, + roleSet: { id: 'rs-1' }, + authorization: { id: 'auth-inv-1' }, + } as any; + + const result = await resolver.spacesToJoinOnAccept( + mockInvitation, + actorContext + ); + + expect(result).toBeNull(); + expect(roleSetService.getSpacesToJoinOnAccept).not.toHaveBeenCalled(); + }); + + it('returns null — never throws — when the invitation has no authorization policy', async () => { + // `Invitation.authorization` is eager but `onDelete: 'SET NULL'`, so a + // policy row removed by orphan cleanup or a partially-projected parent + // leaves it undefined. `isAccessGranted` THROWS on an undefined policy + // rather than returning false, which would reintroduce exactly the + // whole-`me`-query failure this nullable field exists to prevent. + (authorizationService.isAccessGranted as Mock).mockImplementation(() => { + throw new Error('isAccessGranted must not be reached without a policy'); + }); + const mockInvitation = { + id: 'inv-1', + invitedActorID: 'org-1', + invitedToParent: true, + roleSet: { id: 'rs-1' }, + // authorization absent + } as any; + + const result = await resolver.spacesToJoinOnAccept( + mockInvitation, + actorContext + ); + + expect(result).toBeNull(); + expect(authorizationService.isAccessGranted).not.toHaveBeenCalled(); + expect(roleSetService.getSpacesToJoinOnAccept).not.toHaveBeenCalled(); + }); + + it('returns null — never throws — when the invitation row disappears mid-flight', async () => { + // Live race: a Space admin clicks Revoke on this invitation while an + // organization admin's dashboard `me` query is resolving. The reload + // then throws EntityNotFound, and an uncaught throw here nulls out the + // whole `me` payload — the exact failure this nullable field exists to + // prevent, reintroduced one call later. + (authorizationService.isAccessGranted as Mock).mockReturnValue(true); + (invitationService.getInvitationOrFail as Mock).mockRejectedValue( + new Error('Invitation not found') + ); + const mockInvitation = { + id: 'inv-1', + invitedActorID: 'org-1', + invitedToParent: true, + // roleSet absent -> forces the reload that now throws + authorization: { id: 'auth-inv-1' }, + } as any; + + const result = await resolver.spacesToJoinOnAccept( + mockInvitation, + actorContext + ); + + expect(result).toBeNull(); + }); + + it('returns null — never throws — when the ancestor walk fails', async () => { + // getSpacesToJoinOnAccept fans out to getParentRoleSet / isMember / + // getSpaceForRoleSetOrFail, all of which throw on a role set or Space + // removed underneath the caller. + (authorizationService.isAccessGranted as Mock).mockReturnValue(true); + (roleSetService.getSpacesToJoinOnAccept as Mock).mockRejectedValue( + new Error('RoleSet not found') + ); + const mockInvitation = { + id: 'inv-1', + invitedActorID: 'org-1', + invitedToParent: true, + roleSet: { id: 'rs-1' }, + authorization: { id: 'auth-inv-1' }, + } as any; + + const result = await resolver.spacesToJoinOnAccept( + mockInvitation, + actorContext + ); + + expect(result).toBeNull(); + }); + }); }); diff --git a/src/domain/access/invitation/invitation.resolver.fields.ts b/src/domain/access/invitation/invitation.resolver.fields.ts index 8aa43ed2f7..8152642920 100644 --- a/src/domain/access/invitation/invitation.resolver.fields.ts +++ b/src/domain/access/invitation/invitation.resolver.fields.ts @@ -1,19 +1,30 @@ import { AuthorizationPrivilege } from '@common/enums'; +import { ActorContext } from '@core/actor-context/actor.context'; import { GraphqlGuard } from '@core/authorization'; -import { IInvitation } from '@domain/access/invitation'; +import { AuthorizationService } from '@core/authorization/authorization.service'; +import { IInvitation, ISpaceJoinPreview } from '@domain/access/invitation'; import { IActor } from '@domain/actor/actor/actor.interface'; import { IUser } from '@domain/community/user/user.interface'; -import { UseGuards } from '@nestjs/common'; +import { forwardRef, Inject, UseGuards } from '@nestjs/common'; import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { UrlGeneratorService } from '@services/infrastructure/url-generator'; import { AuthorizationActorHasPrivilege, + CurrentActor, Profiling, } from '@src/common/decorators'; +import { RoleSetService } from '../role-set/role.set.service'; import { InvitationService } from './invitation.service'; @Resolver(() => IInvitation) export class InvitationResolverFields { - constructor(private invitationService: InvitationService) {} + constructor( + private invitationService: InvitationService, + private authorizationService: AuthorizationService, + @Inject(forwardRef(() => RoleSetService)) + private roleSetService: RoleSetService, + private urlGeneratorService: UrlGeneratorService + ) {} @AuthorizationActorHasPrivilege(AuthorizationPrivilege.READ) @UseGuards(GraphqlGuard) @@ -40,4 +51,109 @@ export class InvitationResolverFields { return null; } } + + // Gated on ROLESET_ENTRY_ROLE_INVITE_ACCEPT rather than the broader READ: + // that privilege is granted only to account admins of the invited actor + // (invitation.service.authorization.ts), the intended informed-consent + // audience for previewing what accepting joins. Every other actor with + // READ on the invitation (e.g. an inviter with visibility limited to an + // immediate subspace) is excluded from this field, even though it can + // read other invitation fields. + // + // The check is made INLINE and the field is NULLABLE rather than using + // @AuthorizationActorHasPrivilege, which throws. This field is selected + // from the shared `InvitationData` fragment that the top-bar pending + // memberships dialog and the in-app notifications panel spread for every + // invitation, including ones the viewer may read but not answer (an org + // admin demoted to associate keeps the in-app row until it is cleaned + // up). Throwing there would attach a GraphQL error to every notifications + // fetch, and — under the non-null `CommunityInvitationResult.invitation` + // — null out the whole `me` query. Not being allowed to preview the list + // is an absence, not an error. + @UseGuards(GraphqlGuard) + @ResolveField('spacesToJoinOnAccept', () => [ISpaceJoinPreview], { + nullable: true, + description: + "The Spaces that will be joined if this invitation is accepted, root Space first; null when the caller may not answer this invitation on the invited Actor's behalf.", + }) + @Profiling.api + async spacesToJoinOnAccept( + @Parent() invitation: IInvitation, + @CurrentActor() actorContext: ActorContext + ): Promise { + // `isAccessGranted` delegates to `isAccessGratedForCredentials`, which + // THROWS `EntityNotInitializedException` on an undefined policy rather + // than returning false. The relation is eager but `onDelete: 'SET NULL'`, + // so an invitation whose policy row was removed would otherwise throw — + // reintroducing exactly the whole-`me`-query failure this nullable, + // never-throwing design exists to prevent. No policy means no grant. + if (!invitation.authorization) { + return null; + } + if ( + !this.authorizationService.isAccessGranted( + actorContext, + invitation.authorization, + AuthorizationPrivilege.ROLESET_ENTRY_ROLE_INVITE_ACCEPT + ) + ) { + return null; + } + // EVERYTHING below is best-effort. `getInvitationOrFail`, + // `getParentRoleSet`, `isMember` and `getSpaceForRoleSetOrFail` all throw + // on a row that has moved underneath the caller — a Space admin revoking + // this very invitation while an org admin's dashboard `me` query is in + // flight is a live race, and an uncaught throw here nulls out the whole + // `me` payload (see the comment above). A preview that cannot be computed + // is an absence, not an error, exactly as the authorization denial above + // is. This mirrors the `createdBy` resolver in this same file. + try { + const roleSet = + invitation.roleSet ?? + ( + await this.invitationService.getInvitationOrFail(invitation.id, { + relations: { roleSet: true }, + }) + ).roleSet; + if (!roleSet) { + return []; + } + const spaces = await this.roleSetService.getSpacesToJoinOnAccept( + roleSet, + invitation.invitedActorID, + invitation.invitedToParent + ); + // No per-Space READ_ABOUT filter here: the field-level gate above + // already confines this resolver to the invited actor's own account + // admins, and every Space returned by getSpacesToJoinOnAccept is one + // that accepting this invitation actually joins. Filtering by the + // current human admin's own READ_ABOUT would silently drop Spaces the + // consenting organization is about to join whenever an ancestor is + // private (the organization holds the membership, not the admin + // reviewing on its behalf), producing exactly the empty-list / + // cross-artifact mismatch this field exists to prevent — the same + // audience already receives the identical Space list unfiltered via + // email and `me.communityInvitations`. + // + // Because that filter is deliberately absent, this projection is the + // disclosure boundary: display name and URL ONLY — byte for byte what + // the email path already sends (notification.external.adapter.ts + // `spacesToJoinPayload`) — instead of the whole `ISpaceAbout`, whose + // `why` / `who` / `profile` / `guidelines` / `classifications` carry + // no field-level gate of their own and would otherwise hand these + // admins the private About content of every ancestor Space. See + // ISpaceJoinPreview before adding a field here. + return await Promise.all( + spaces.map(async space => ({ + id: space.id, + displayName: space.about.profile.displayName, + url: await this.urlGeneratorService.generateUrlForProfile( + space.about.profile + ), + })) + ); + } catch { + return null; + } + } } diff --git a/src/domain/access/invitation/invitation.service.lifecycle.spec.ts b/src/domain/access/invitation/invitation.service.lifecycle.spec.ts index adb7a01185..b764c6ccf6 100644 --- a/src/domain/access/invitation/invitation.service.lifecycle.spec.ts +++ b/src/domain/access/invitation/invitation.service.lifecycle.spec.ts @@ -4,7 +4,10 @@ import { MockCacheManager } from '@test/mocks/cache-manager.mock'; import { MockWinstonProvider } from '@test/mocks/winston.provider.mock'; import { defaultMockerFactory } from '@test/utils/default.mocker.factory'; import { type Mock } from 'vitest'; -import { InvitationLifecycleService } from './invitation.service.lifecycle'; +import { + InvitationLifecycleService, + invitationLifecycleMachine, +} from './invitation.service.lifecycle'; describe('InvitationLifecycleService', () => { let service: InvitationLifecycleService; @@ -62,6 +65,53 @@ describe('InvitationLifecycleService', () => { }); }); + describe('the real machine: a declined invitation cannot be resurrected', () => { + // Regression (FR-004/R2). `rejected` used to carry + // `REINVITE -> invited` guarded on `hasUpdatePrivilege` — a privilege the + // INVITING Space admin holds through the RoleSet's inherited + // authorization. `eventOnInvitation` re-runs neither + // `guardOrganizationInvitation` (the organization's + // `allowSpaceInvitations` opt-out and the Lead-slot limit) nor the + // invitation notification, so that transition let the very party the + // opt-out protects against loop a declining organization back to + // `invited`, silently and indefinitely. Re-inviting now goes through + // ARCHIVE (final) + a fresh `inviteForEntryRoleOnRoleSet`, where every + // one of those checks runs. + const realService = () => + new InvitationLifecycleService( + new LifecycleService({} as any, MockWinstonProvider.useValue as any) + ); + + const rejectedLifecycle = { + id: 'lc-rejected', + machineState: JSON.stringify({ + status: 'active', + value: 'rejected', + historyValue: {}, + context: {}, + children: {}, + }), + } as any; + + it('offers ARCHIVE and nothing else from `rejected`', () => { + expect(realService().getNextEvents(rejectedLifecycle)).toEqual([ + 'ARCHIVE', + ]); + }); + + it('declares no transition out of `rejected` that returns to `invited`', () => { + // Asserted on the definition as well as the runtime, because the + // states-only machine and the primary event-handling machine are kept + // in sync by hand (see the comment in the service). + const rejectedTransitions = + (invitationLifecycleMachine.states as any).rejected.on ?? {}; + expect(Object.keys(rejectedTransitions)).toEqual(['ARCHIVE']); + expect( + Object.values(rejectedTransitions).map((t: any) => t.target) + ).not.toContain('invited'); + }); + }); + describe('isFinalState', () => { it('should return true when lifecycle is in final state', () => { const mockLifecycle = { id: 'lc-1', machineState: 'accepted' } as any; diff --git a/src/domain/access/invitation/invitation.service.lifecycle.ts b/src/domain/access/invitation/invitation.service.lifecycle.ts index 92d0db07e7..778384732a 100644 --- a/src/domain/access/invitation/invitation.service.lifecycle.ts +++ b/src/domain/access/invitation/invitation.service.lifecycle.ts @@ -83,10 +83,26 @@ export const invitationLifecycleMachine: ILifecycleDefinition = { }, rejected: { on: { - REINVITE: { - guard: 'hasUpdatePrivilege', - target: InvitationLifecycleState.INVITED, - }, + // There is deliberately NO transition back to `invited` here. + // + // A REINVITE guarded on `hasUpdatePrivilege` was reachable by the + // INVITING Space admin (they hold UPDATE through the RoleSet's + // inherited authorization) and bypassed every check that makes an + // invitation legitimate: `eventOnInvitation` re-runs neither + // `guardOrganizationInvitation` — the organization's + // `allowSpaceInvitations` opt-out (FR-004/R2) and the Lead-slot + // limit — nor the invitation notification, so a declining + // organization could be returned to `invited` on a loop, silently, + // by the exact party the opt-out exists to protect against. + // + // Re-inviting after a decline is not lost, only routed through its + // single guarded owner: ARCHIVE the declined invitation (the Space + // admin's existing "remove pending" action — + // `useCommunityTabData.pendingDelete` already sends exactly this + // event for a non-`invited` invitation), which IS final, and then + // invite again through `inviteForEntryRoleOnRoleSet`, where the + // opt-out, the Lead-slot check and the org-admin notification all + // run as they do for any other invitation. ARCHIVE: { guard: 'hasUpdatePrivilege', target: InvitationLifecycleState.ARCHIVED, diff --git a/src/domain/access/invitation/invitation.service.spec.ts b/src/domain/access/invitation/invitation.service.spec.ts index 76c207d6a8..d8252a172f 100644 --- a/src/domain/access/invitation/invitation.service.spec.ts +++ b/src/domain/access/invitation/invitation.service.spec.ts @@ -1,4 +1,6 @@ +import { ActorType } from '@common/enums/actor.type'; import { LogContext } from '@common/enums/logging.context'; +import { RoleName } from '@common/enums/role.name'; import { EntityNotFoundException, RelationshipNotFoundException, @@ -506,7 +508,6 @@ describe('InvitationService', () => { mockInvitation ); (invitationLifecycleService.getNextEvents as Mock).mockReturnValue([ - 'REINVITE', 'ARCHIVE', ]); @@ -531,4 +532,168 @@ describe('InvitationService', () => { expect(result).toBe(false); }); }); + + describe('countOpenInvitationsForRoleSet', () => { + const invitationRow = ( + id: string, + extraRoles: RoleName[], + machineState: string + ) => + ({ + id, + extraRoles, + lifecycle: { id: `lifecycle-${id}`, machineState }, + }) as any; + + const mockQueryBuilder = (rows: any[]) => { + const qb: any = { + innerJoin: vi.fn(() => qb), + where: vi.fn(() => qb), + andWhere: vi.fn(() => qb), + select: vi.fn(() => qb), + getMany: vi.fn().mockResolvedValue(rows), + }; + vi.spyOn(invitationRepository, 'createQueryBuilder').mockReturnValue(qb); + return qb; + }; + + it('joins on the invited actor and filters to the requested actor type at the SQL level', async () => { + const qb = mockQueryBuilder([]); + + await service.countOpenInvitationsForRoleSet('rs-1', { + extraRole: RoleName.LEAD, + actorType: ActorType.ORGANIZATION, + }); + + expect(qb.where).toHaveBeenCalledWith( + 'invitation.roleSetId = :roleSetID', + { + roleSetID: 'rs-1', + } + ); + expect(qb.andWhere).toHaveBeenCalledWith( + 'invitedActor.type = :actorType', + { actorType: ActorType.ORGANIZATION } + ); + }); + + it('counts only pending (invited/accepting) invitations carrying the role', async () => { + mockQueryBuilder([ + invitationRow('inv-1', [RoleName.LEAD], 'invited'), + invitationRow('inv-2', [RoleName.LEAD], 'accepting'), + invitationRow('inv-3', [RoleName.MEMBER], 'invited'), + ]); + (invitationLifecycleService.getState as Mock).mockImplementation( + (lifecycle: any) => lifecycle.machineState + ); + + const result = await service.countOpenInvitationsForRoleSet('rs-1', { + extraRole: RoleName.LEAD, + actorType: ActorType.ORGANIZATION, + }); + + expect(result).toBe(2); + }); + + it('excludes accepted and archived (finalized) invitations', async () => { + mockQueryBuilder([ + invitationRow('inv-1', [RoleName.LEAD], 'accepted'), + invitationRow('inv-2', [RoleName.LEAD], 'archived'), + ]); + (invitationLifecycleService.getState as Mock).mockImplementation( + (lifecycle: any) => lifecycle.machineState + ); + + const result = await service.countOpenInvitationsForRoleSet('rs-1', { + extraRole: RoleName.LEAD, + actorType: ActorType.ORGANIZATION, + }); + + expect(result).toBe(0); + }); + + it('excludes a rejected (declined) invitation, even though its row is never deleted or archived', async () => { + // Regression: a declined Lead invitation must not keep consuming the + // Space's Lead-organization slot forever. 'rejected' is an active, + // non-final xstate state (it still has an ARCHIVE transition), so it + // must be excluded explicitly rather than via "not final". + mockQueryBuilder([ + invitationRow('inv-1', [RoleName.LEAD], 'invited'), + invitationRow('inv-2', [RoleName.LEAD], 'rejected'), + ]); + (invitationLifecycleService.getState as Mock).mockImplementation( + (lifecycle: any) => lifecycle.machineState + ); + + const result = await service.countOpenInvitationsForRoleSet('rs-1', { + extraRole: RoleName.LEAD, + actorType: ActorType.ORGANIZATION, + }); + + expect(result).toBe(1); + }); + + it('excludes a rejected invitation against the real persisted xstate snapshot', async () => { + // Exercises the actual xstate machine end to end (real LifecycleService + // + real InvitationLifecycleService) against the snapshot shape + // actor.getPersistedSnapshot() produces after a REJECT event, rather + // than a stubbed getState/isFinalState — a stub previously masked + // this defect (a rejected row read back as "not final" == "open"). + const realLifecycleService = new LifecycleService( + {} as any, + MockWinstonProvider.useValue as any + ); + const realInvitationLifecycleService = new InvitationLifecycleService( + realLifecycleService + ); + (service as any).invitationLifecycleService = + realInvitationLifecycleService; + + // Real actor.getPersistedSnapshot() shapes for the 'invited' initial + // state and after a REJECT event, captured from this machine's own + // xstate build (createActor(...).start() / .send({type:'REJECT'})). + mockQueryBuilder([ + invitationRow( + 'inv-1', + [RoleName.LEAD], + JSON.stringify({ + status: 'active', + value: 'invited', + historyValue: {}, + context: {}, + children: {}, + }) + ), + invitationRow( + 'inv-2', + [RoleName.LEAD], + JSON.stringify({ + status: 'active', + value: 'rejected', + historyValue: {}, + context: {}, + children: {}, + }) + ), + ]); + + const result = await service.countOpenInvitationsForRoleSet('rs-1', { + extraRole: RoleName.LEAD, + actorType: ActorType.ORGANIZATION, + }); + + expect(result).toBe(1); + }); + + it('returns 0 when nothing carries the role', async () => { + mockQueryBuilder([invitationRow('inv-1', [RoleName.MEMBER], 'invited')]); + + const result = await service.countOpenInvitationsForRoleSet('rs-1', { + extraRole: RoleName.LEAD, + actorType: ActorType.ORGANIZATION, + }); + + expect(result).toBe(0); + }); + }); }); diff --git a/src/domain/access/invitation/invitation.service.ts b/src/domain/access/invitation/invitation.service.ts index 0e3861475e..4e3ed6db00 100644 --- a/src/domain/access/invitation/invitation.service.ts +++ b/src/domain/access/invitation/invitation.service.ts @@ -1,5 +1,7 @@ +import { ActorType } from '@common/enums/actor.type'; import { AuthorizationPolicyType } from '@common/enums/authorization.policy.type'; import { LogContext } from '@common/enums/logging.context'; +import { RoleName } from '@common/enums/role.name'; import { EntityNotFoundException, RelationshipNotFoundException, @@ -30,7 +32,10 @@ import { Repository, } from 'typeorm'; import { RoleSetCacheService } from '../role-set/role.set.service.cache'; -import { InvitationLifecycleService } from './invitation.service.lifecycle'; +import { + InvitationLifecycleService, + InvitationLifecycleState, +} from './invitation.service.lifecycle'; @Injectable() export class InvitationService { @@ -260,4 +265,51 @@ export class InvitationService { .getNextEvents(invitation.lifecycle) .includes('ACCEPT'); } + + /** + * Counts the still-pending (invited or accepting) invitations on a + * RoleSet that carry a given extra role and target a given actor type. + * Used by the advisory Lead-slot check: a still-pending, never-acted-on + * invitation holds its slot until it is accepted, revoked, or + * rejected/archived — a rejected or archived invitation is finalized and + * must not keep counting against the slot. + * + * The actor-type filter is applied as a SQL join and only the columns the + * predicate needs are selected, so neither the invitation's authorization + * policy (eager on the entity) nor unrelated invitations for other actor + * types are ever loaded into memory. + */ + async countOpenInvitationsForRoleSet( + roleSetID: string, + filter: { extraRole: RoleName; actorType: ActorType } + ): Promise { + const invitations = await this.invitationRepository + .createQueryBuilder('invitation') + .innerJoin('invitation.lifecycle', 'lifecycle') + .innerJoin('invitation.invitedActor', 'invitedActor') + .where('invitation.roleSetId = :roleSetID', { roleSetID }) + .andWhere('invitedActor.type = :actorType', { + actorType: filter.actorType, + }) + .select([ + 'invitation.id', + 'invitation.extraRoles', + 'lifecycle.id', + 'lifecycle.machineState', + ]) + .getMany(); + + return invitations.filter(invitation => { + if (!invitation.extraRoles?.includes(filter.extraRole)) { + return false; + } + const state = this.invitationLifecycleService.getState( + invitation.lifecycle + ); + return ( + state === InvitationLifecycleState.INVITED || + state === InvitationLifecycleState.ACCEPTING + ); + }).length; + } } diff --git a/src/domain/access/role-set/dto/role.set.dto.entry.role.invite.spec.ts b/src/domain/access/role-set/dto/role.set.dto.entry.role.invite.spec.ts new file mode 100644 index 0000000000..0c1647a1c4 --- /dev/null +++ b/src/domain/access/role-set/dto/role.set.dto.entry.role.invite.spec.ts @@ -0,0 +1,112 @@ +import { + ROLE_SET_INVITE_BATCH_MAX, + ROLE_SET_INVITE_EXTRA_ROLES_MAX, +} from '@common/constants'; +import { validate } from 'class-validator'; +import { InviteForEntryRoleOnRoleSetInput } from './role.set.dto.entry.role.invite'; + +const validInput = (): InviteForEntryRoleOnRoleSetInput => { + const input = new InviteForEntryRoleOnRoleSetInput(); + input.roleSetID = '12345678-1234-1234-1234-123456789012'; + input.invitedActorIDs = []; + input.invitedUserEmails = []; + input.extraRoles = []; + return input; +}; + +describe('InviteForEntryRoleOnRoleSetInput', () => { + describe(`invitedActorIDs @ArrayMaxSize(${ROLE_SET_INVITE_BATCH_MAX})`, () => { + it(`accepts exactly ${ROLE_SET_INVITE_BATCH_MAX} entries`, async () => { + const input = validInput(); + input.invitedActorIDs = Array.from( + { length: ROLE_SET_INVITE_BATCH_MAX }, + (_v, i) => `actor-${i}` + ); + + const errors = await validate(input); + + expect(errors.some(error => error.property === 'invitedActorIDs')).toBe( + false + ); + }); + + it(`rejects ${ROLE_SET_INVITE_BATCH_MAX + 1} entries`, async () => { + const input = validInput(); + input.invitedActorIDs = Array.from( + { length: ROLE_SET_INVITE_BATCH_MAX + 1 }, + (_v, i) => `actor-${i}` + ); + + const errors = await validate(input); + + expect( + errors.some( + error => + error.property === 'invitedActorIDs' && + !!error.constraints?.arrayMaxSize + ) + ).toBe(true); + }); + }); + + describe(`invitedUserEmails @ArrayMaxSize(${ROLE_SET_INVITE_BATCH_MAX})`, () => { + const email = (i: number) => `invitee-${i}@example.com`; + + it(`accepts exactly ${ROLE_SET_INVITE_BATCH_MAX} entries`, async () => { + const input = validInput(); + input.invitedUserEmails = Array.from( + { length: ROLE_SET_INVITE_BATCH_MAX }, + (_v, i) => email(i) + ); + + const errors = await validate(input); + + expect(errors.some(error => error.property === 'invitedUserEmails')).toBe( + false + ); + }); + + it(`rejects ${ROLE_SET_INVITE_BATCH_MAX + 1} entries`, async () => { + const input = validInput(); + input.invitedUserEmails = Array.from( + { length: ROLE_SET_INVITE_BATCH_MAX + 1 }, + (_v, i) => email(i) + ); + + const errors = await validate(input); + + expect( + errors.some( + error => + error.property === 'invitedUserEmails' && + !!error.constraints?.arrayMaxSize + ) + ).toBe(true); + }); + }); + describe(`extraRoles @ArrayMaxSize(${ROLE_SET_INVITE_EXTRA_ROLES_MAX})`, () => { + it(`accepts exactly ${ROLE_SET_INVITE_EXTRA_ROLES_MAX} entries`, async () => { + const input = validInput(); + input.extraRoles = Array.from( + { length: ROLE_SET_INVITE_EXTRA_ROLES_MAX }, + () => 'lead' as any + ); + + const errors = await validate(input); + + expect(errors.some(error => error.property === 'extraRoles')).toBe(false); + }); + + it(`rejects ${ROLE_SET_INVITE_EXTRA_ROLES_MAX + 1} entries`, async () => { + const input = validInput(); + input.extraRoles = Array.from( + { length: ROLE_SET_INVITE_EXTRA_ROLES_MAX + 1 }, + () => 'lead' as any + ); + + const errors = await validate(input); + + expect(errors.some(error => error.property === 'extraRoles')).toBe(true); + }); + }); +}); diff --git a/src/domain/access/role-set/dto/role.set.dto.entry.role.invite.ts b/src/domain/access/role-set/dto/role.set.dto.entry.role.invite.ts index b69446c5ea..52bae5741f 100644 --- a/src/domain/access/role-set/dto/role.set.dto.entry.role.invite.ts +++ b/src/domain/access/role-set/dto/role.set.dto.entry.role.invite.ts @@ -1,13 +1,21 @@ import { LONGER_TEXT_LENGTH, MID_TEXT_LENGTH, + ROLE_SET_INVITE_BATCH_MAX, + ROLE_SET_INVITE_EXTRA_ROLES_MAX, UUID_LENGTH, } from '@common/constants'; import { SUPPORTED_INTERFACE_LANGUAGES } from '@common/constants/supported.languages'; import { RoleName } from '@common/enums/role.name'; import { UUID } from '@domain/common/scalars'; import { Field, InputType } from '@nestjs/graphql'; -import { IsEmail, IsIn, IsOptional, MaxLength } from 'class-validator'; +import { + ArrayMaxSize, + IsEmail, + IsIn, + IsOptional, + MaxLength, +} from 'class-validator'; @InputType() export class InviteForEntryRoleOnRoleSetInput { @@ -19,11 +27,13 @@ export class InviteForEntryRoleOnRoleSetInput { nullable: false, description: 'The identifiers for the actors being invited.', }) + @ArrayMaxSize(ROLE_SET_INVITE_BATCH_MAX) invitedActorIDs!: string[]; @Field(() => [String], { nullable: false, }) + @ArrayMaxSize(ROLE_SET_INVITE_BATCH_MAX) @IsEmail({}, { each: true }) @MaxLength(MID_TEXT_LENGTH, { each: true }) invitedUserEmails!: string[]; @@ -37,6 +47,7 @@ export class InviteForEntryRoleOnRoleSetInput { nullable: false, description: 'Additional roles to assign in addition to the entry Role.', }) + @ArrayMaxSize(ROLE_SET_INVITE_EXTRA_ROLES_MAX) extraRoles!: RoleName[]; @Field(() => String, { diff --git a/src/domain/access/role-set/dto/role.set.invitation.result.ts b/src/domain/access/role-set/dto/role.set.invitation.result.ts index e0e04b3562..7fe894b792 100644 --- a/src/domain/access/role-set/dto/role.set.invitation.result.ts +++ b/src/domain/access/role-set/dto/role.set.invitation.result.ts @@ -1,7 +1,9 @@ +import { RoleSetInvitationResultNotice } from '@common/enums/role.set.invitation.result.notice'; import { RoleSetInvitationResultType } from '@common/enums/role.set.invitation.result.type'; import { IApplication } from '@domain/access/application'; import { IInvitation } from '@domain/access/invitation'; import { IPlatformInvitation } from '@domain/access/invitation.platform/platform.invitation.interface'; +import { UUID } from '@domain/common/scalars'; import { Field, ObjectType } from '@nestjs/graphql'; @ObjectType() @@ -27,4 +29,31 @@ export class RoleSetInvitationResult { 'The existing open application that blocks this invitation, when the result type is ALREADY_HAS_OPEN_APPLICATION.', }) application?: IApplication; + + // Identity of the invitee this result belongs to. Typed failures create + // neither an invitation nor a platformInvitation, so without these the + // client had to fall back to matching results positionally — which + // mis-attributes as soon as an invited email turns out to be an existing + // user, because the server moves that invitee from the email group into + // the actor group and the result order stops matching the input order. + @Field(() => UUID, { + nullable: true, + description: + 'The id of the invited actor this result belongs to, when the invitee was an actor or an email that resolved to an existing user.', + }) + invitedActorID?: string; + + @Field(() => String, { + nullable: true, + description: + 'The email address this result belongs to, when the invitee was submitted as an email address.', + }) + invitedEmail?: string; + + @Field(() => RoleSetInvitationResultNotice, { + nullable: true, + description: + 'An informational addendum to the result, set only alongside a successful invite outcome.', + }) + notice?: RoleSetInvitationResultNotice; } diff --git a/src/domain/access/role-set/role.set.module.ts b/src/domain/access/role-set/role.set.module.ts index e2d47f1f4d..919df4cdf3 100644 --- a/src/domain/access/role-set/role.set.module.ts +++ b/src/domain/access/role-set/role.set.module.ts @@ -15,7 +15,7 @@ import { UserLookupModule } from '@domain/community/user-lookup/user.lookup.modu import { VirtualActorLookupModule } from '@domain/community/virtual-contributor-lookup/virtual.contributor.lookup.module'; import { AccountLookupModule } from '@domain/space/account.lookup/account.lookup.module'; import { SpaceLookupModule } from '@domain/space/space.lookup/space.lookup.module'; -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { InAppNotificationModule } from '@platform/in-app-notification/in.app.notification.module'; import { ActivityAdapterModule } from '@services/adapters/activity-adapter/activity.adapter.module'; @@ -54,13 +54,13 @@ import { RoleSetServiceLifecycleInvitation } from './role.set.service.lifecycle. VirtualActorLookupModule, ActorLookupModule, RoleModule, - InvitationModule, + forwardRef(() => InvitationModule), EntityResolverModule, ApplicationModule, PlatformInvitationModule, AccountLookupModule, AiServerAdapterModule, - NotificationAdapterModule, + forwardRef(() => NotificationAdapterModule), ContributionReporterModule, ActivityAdapterModule, LifecycleModule, diff --git a/src/domain/access/role-set/role.set.resolver.mutations.membership.spec.ts b/src/domain/access/role-set/role.set.resolver.mutations.membership.spec.ts index 144beffc8c..25211445dc 100644 --- a/src/domain/access/role-set/role.set.resolver.mutations.membership.spec.ts +++ b/src/domain/access/role-set/role.set.resolver.mutations.membership.spec.ts @@ -1,8 +1,10 @@ -import { LogContext } from '@common/enums'; +import { AuthorizationPrivilege, LogContext } from '@common/enums'; +import { ActorType } from '@common/enums/actor.type'; import { CommunityMembershipStatus } from '@common/enums/community.membership.status'; import { RoleSetInvitationResultType } from '@common/enums/role.set.invitation.result.type'; import { RoleSetType } from '@common/enums/role.set.type'; import { ValidationException } from '@common/exceptions'; +import { ForbiddenAuthorizationPolicyException } from '@common/exceptions/forbidden.authorization.policy.exception'; import { RoleSetInvitationException } from '@common/exceptions/role.set.invitation.exception'; import { RoleSetMembershipException } from '@common/exceptions/role.set.membership.exception'; import { AuthorizationService } from '@core/authorization/authorization.service'; @@ -11,8 +13,11 @@ import { InvitationService } from '@domain/access/invitation/invitation.service' import { ActorLookupService } from '@domain/actor/actor-lookup/actor.lookup.service'; import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; import { LifecycleService } from '@domain/common/lifecycle/lifecycle.service'; +import { OrganizationLookupService } from '@domain/community/organization-lookup/organization.lookup.service'; import { UserLookupService } from '@domain/community/user-lookup/user.lookup.service'; import { Test, TestingModule } from '@nestjs/testing'; +import { NotificationOrganizationAdapter } from '@services/adapters/notification-adapter/notification.organization.adapter'; +import { NotificationSpaceAdapter } from '@services/adapters/notification-adapter/notification.space.adapter'; import { CommunityResolverService } from '@services/infrastructure/entity-resolver/community.resolver.service'; import { MockCacheManager } from '@test/mocks/cache-manager.mock'; import { MockWinstonProvider } from '@test/mocks/winston.provider.mock'; @@ -35,9 +40,12 @@ describe('RoleSetResolverMutationsMembership', () => { let roleSetAuthorizationService: RoleSetAuthorizationService; let actorLookupService: ActorLookupService; let userLookupService: UserLookupService; + let organizationLookupService: OrganizationLookupService; let communityResolverService: CommunityResolverService; let authorizationPolicyService: AuthorizationPolicyService; let eligibleLanguageGuard: RoleSetEligibleLanguageGuard; + let notificationOrganizationAdapter: NotificationOrganizationAdapter; + let notificationAdapterSpace: NotificationSpaceAdapter; beforeEach(async () => { vi.restoreAllMocks(); @@ -56,6 +64,18 @@ describe('RoleSetResolverMutationsMembership', () => { RoleSetResolverMutationsMembership ); roleSetService = module.get(RoleSetService); + // validateInviteesAndRolesOrFail loads the requested definitions in one + // call; derive them from the per-role mock so existing cases keep driving + // policy through getRoleDefinition. + (roleSetService.getRoleDefinitions as Mock).mockImplementation( + async (roleSet: any, roles: any[] = []) => + Promise.all( + roles.map(async name => ({ + ...(await roleSetService.getRoleDefinition(roleSet, name)), + name, + })) + ) + ); authorizationService = module.get(AuthorizationService); applicationService = module.get(ApplicationService); @@ -67,6 +87,16 @@ describe('RoleSetResolverMutationsMembership', () => { ); actorLookupService = module.get(ActorLookupService); userLookupService = module.get(UserLookupService); + organizationLookupService = module.get( + OrganizationLookupService + ); + notificationOrganizationAdapter = + module.get( + NotificationOrganizationAdapter + ); + notificationAdapterSpace = module.get( + NotificationSpaceAdapter + ); communityResolverService = module.get( CommunityResolverService ); @@ -513,6 +543,644 @@ describe('RoleSetResolverMutationsMembership', () => { }); }); + describe('inviteForEntryRoleOnRoleSet - invitee/role validation', () => { + const baseRoleSet = { + id: 'rs-1', + type: RoleSetType.SPACE, + authorization: { id: 'auth-1' }, + parentRoleSet: undefined, + } as any; + + beforeEach(() => { + (roleSetService.getRoleSetOrFail as Mock).mockResolvedValue(baseRoleSet); + (authorizationService.grantAccessOrFail as Mock).mockReturnValue( + undefined + ); + }); + + it('rejects an invitee actor type that is not a contributor (e.g. a Space)', async () => { + (actorLookupService.validateActorsAndGetTypes as Mock).mockResolvedValue( + new Map([['space-1', 'space']]) + ); + + await expect( + resolver.inviteForEntryRoleOnRoleSet(actorContext(), { + roleSetID: 'rs-1', + invitedActorIDs: ['space-1'], + invitedUserEmails: [], + extraRoles: [], + } as any) + ).rejects.toThrow(ValidationException); + + expect( + roleSetService.createInvitationExistingActor + ).not.toHaveBeenCalled(); + }); + + it('rejects an organization invited with the ADMIN role (server#4602)', async () => { + (actorLookupService.validateActorsAndGetTypes as Mock).mockResolvedValue( + new Map([['org-1', 'organization']]) + ); + (roleSetService.getRoleDefinition as Mock).mockResolvedValue({ + organizationPolicy: { minimum: 0, maximum: 0 }, + }); + + await expect( + resolver.inviteForEntryRoleOnRoleSet(actorContext(), { + roleSetID: 'rs-1', + invitedActorIDs: ['org-1'], + invitedUserEmails: [], + extraRoles: ['admin'], + } as any) + ).rejects.toThrow(ValidationException); + + expect( + roleSetService.createInvitationExistingActor + ).not.toHaveBeenCalled(); + }); + + it('allows an organization invited with the LEAD role', async () => { + const mockInvitation = { id: 'inv-1', invitedActorID: 'org-1' } as any; + (actorLookupService.validateActorsAndGetTypes as Mock).mockResolvedValue( + new Map([['org-1', 'organization']]) + ); + (roleSetService.getRoleDefinition as Mock).mockResolvedValue({ + organizationPolicy: { minimum: 0, maximum: 2 }, + }); + ( + organizationLookupService.getOrganizationByIdOrFail as Mock + ).mockResolvedValue({ + settings: { membership: { allowSpaceInvitations: true } }, + }); + (roleSetService.countActorsWithRole as Mock).mockResolvedValue(0); + ( + invitationService.countOpenInvitationsForRoleSet as Mock + ).mockResolvedValue(0); + (roleSetService.findOpenInvitation as Mock).mockResolvedValue(undefined); + (roleSetService.findOpenApplication as Mock).mockResolvedValue(undefined); + (roleSetService.isMember as Mock).mockResolvedValue(false); + (roleSetService.createInvitationExistingActor as Mock).mockResolvedValue( + mockInvitation + ); + (invitationService.getInvitationsOrFail as Mock).mockResolvedValue([ + mockInvitation, + ]); + ( + roleSetAuthorizationService.applyAuthorizationPolicyOnInvitationsApplications as Mock + ).mockResolvedValue([]); + (authorizationPolicyService.saveAll as Mock).mockResolvedValue(undefined); + ( + communityResolverService.getCommunityForRoleSet as Mock + ).mockResolvedValue({ id: 'comm-1' }); + + const result = await resolver.inviteForEntryRoleOnRoleSet( + actorContext(), + { + roleSetID: 'rs-1', + invitedActorIDs: ['org-1'], + invitedUserEmails: [], + extraRoles: ['lead'], + } as any + ); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe( + RoleSetInvitationResultType.INVITED_TO_ROLE_SET + ); + }); + + it('allows a user invited with the ADMIN role (unchanged)', async () => { + const mockInvitation = { id: 'inv-1', invitedActorID: 'user-1' } as any; + (actorLookupService.validateActorsAndGetTypes as Mock).mockResolvedValue( + new Map([['user-1', 'user']]) + ); + // ADMIN is forbidden to organizations (maximum 0) but allowed to users. + (roleSetService.getRoleDefinition as Mock).mockResolvedValue({ + userPolicy: { minimum: 0, maximum: -1 }, + organizationPolicy: { minimum: 0, maximum: 0 }, + virtualContributorPolicy: { minimum: 0, maximum: 0 }, + }); + (roleSetService.findOpenInvitation as Mock).mockResolvedValue(undefined); + (roleSetService.findOpenApplication as Mock).mockResolvedValue(undefined); + (roleSetService.isMember as Mock).mockResolvedValue(false); + (roleSetService.createInvitationExistingActor as Mock).mockResolvedValue( + mockInvitation + ); + (invitationService.getInvitationsOrFail as Mock).mockResolvedValue([ + mockInvitation, + ]); + ( + roleSetAuthorizationService.applyAuthorizationPolicyOnInvitationsApplications as Mock + ).mockResolvedValue([]); + (authorizationPolicyService.saveAll as Mock).mockResolvedValue(undefined); + ( + communityResolverService.getCommunityForRoleSet as Mock + ).mockResolvedValue({ id: 'comm-1' }); + + const result = await resolver.inviteForEntryRoleOnRoleSet( + actorContext(), + { + roleSetID: 'rs-1', + invitedActorIDs: ['user-1'], + invitedUserEmails: [], + extraRoles: ['admin'], + } as any + ); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe( + RoleSetInvitationResultType.INVITED_TO_ROLE_SET + ); + // The role guard now runs for every invitee type — ADMIN is forbidden + // to organizations and Virtual Contributors (maximum 0) but allowed to + // users, so the definition IS loaded and the invitation still succeeds. + expect(roleSetService.getRoleDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ id: 'rs-1' }), + ['admin'] + ); + }); + + it('returns ORGANIZATION_NOT_ACCEPTING_INVITATIONS and creates nothing when the organization opted out', async () => { + (actorLookupService.validateActorsAndGetTypes as Mock).mockResolvedValue( + new Map([['org-1', 'organization']]) + ); + ( + organizationLookupService.getOrganizationByIdOrFail as Mock + ).mockResolvedValue({ + settings: { membership: { allowSpaceInvitations: false } }, + }); + (roleSetService.findOpenInvitation as Mock).mockResolvedValue(undefined); + (roleSetService.findOpenApplication as Mock).mockResolvedValue(undefined); + (roleSetService.isMember as Mock).mockResolvedValue(false); + (invitationService.getInvitationsOrFail as Mock).mockResolvedValue([]); + ( + roleSetAuthorizationService.applyAuthorizationPolicyOnInvitationsApplications as Mock + ).mockResolvedValue([]); + (authorizationPolicyService.saveAll as Mock).mockResolvedValue(undefined); + ( + communityResolverService.getCommunityForRoleSet as Mock + ).mockResolvedValue({ id: 'comm-1' }); + + const result = await resolver.inviteForEntryRoleOnRoleSet( + actorContext(), + { + roleSetID: 'rs-1', + invitedActorIDs: ['org-1'], + invitedUserEmails: [], + extraRoles: [], + } as any + ); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe( + RoleSetInvitationResultType.ORGANIZATION_NOT_ACCEPTING_INVITATIONS + ); + expect( + roleSetService.createInvitationExistingActor + ).not.toHaveBeenCalled(); + // A Lead-limit check that never runs must never be reached. + expect(roleSetService.countActorsWithRole).not.toHaveBeenCalled(); + }); + + it.each([ + ['settings.membership is absent entirely', { settings: {} }], + ['settings itself is absent', {}], + ])('treats an organization as accepting invitations, without throwing, when %s', async (_label, organization) => { + // `Organization.applyMembershipSettingsDefaults` (@AfterLoad) + // early-returns on `!this.settings?.membership`, and + // organization.entity.spec.ts asserts the key stays undefined in that + // case — so a row written before migration 1788400000000 ran, or by an + // old pod mid rolling-deploy, reaches this guard with no `membership` + // OBJECT. An unguarded deref throws inside the invitee loop and aborts + // the whole batch, including invitations already created for other + // invitees. The documented default is "accepting". + (actorLookupService.validateActorsAndGetTypes as Mock).mockResolvedValue( + new Map([['org-1', 'organization']]) + ); + ( + organizationLookupService.getOrganizationByIdOrFail as Mock + ).mockResolvedValue(organization); + (roleSetService.findOpenInvitation as Mock).mockResolvedValue(undefined); + (roleSetService.findOpenApplication as Mock).mockResolvedValue(undefined); + (roleSetService.isMember as Mock).mockResolvedValue(false); + (roleSetService.createInvitationExistingActor as Mock).mockResolvedValue({ + id: 'inv-1', + invitedActorID: 'org-1', + }); + (invitationService.getInvitationsOrFail as Mock).mockResolvedValue([ + { id: 'inv-1', invitedActorID: 'org-1' }, + ]); + ( + roleSetAuthorizationService.applyAuthorizationPolicyOnInvitationsApplications as Mock + ).mockResolvedValue([]); + (authorizationPolicyService.saveAll as Mock).mockResolvedValue(undefined); + ( + communityResolverService.getCommunityForRoleSet as Mock + ).mockResolvedValue({ id: 'comm-1' }); + (actorLookupService.getActorTypeByIdOrFail as Mock).mockResolvedValue( + 'organization' + ); + + const result = await resolver.inviteForEntryRoleOnRoleSet( + actorContext(), + { + roleSetID: 'rs-1', + invitedActorIDs: ['org-1'], + invitedUserEmails: [], + extraRoles: [], + } as any + ); + + expect(result).toHaveLength(1); + expect(result[0].type).not.toBe( + RoleSetInvitationResultType.ORGANIZATION_NOT_ACCEPTING_INVITATIONS + ); + expect(roleSetService.createInvitationExistingActor).toHaveBeenCalled(); + }); + + describe('Lead-slot capacity (granted + pending, advisory)', () => { + const setUpOrganizationLeadInvite = () => { + ( + actorLookupService.validateActorsAndGetTypes as Mock + ).mockResolvedValue(new Map([['org-1', 'organization']])); + ( + organizationLookupService.getOrganizationByIdOrFail as Mock + ).mockResolvedValue({ + settings: { membership: { allowSpaceInvitations: true } }, + }); + (roleSetService.findOpenInvitation as Mock).mockResolvedValue( + undefined + ); + (roleSetService.findOpenApplication as Mock).mockResolvedValue( + undefined + ); + (roleSetService.isMember as Mock).mockResolvedValue(false); + (invitationService.getInvitationsOrFail as Mock).mockResolvedValue([]); + ( + roleSetAuthorizationService.applyAuthorizationPolicyOnInvitationsApplications as Mock + ).mockResolvedValue([]); + (authorizationPolicyService.saveAll as Mock).mockResolvedValue( + undefined + ); + ( + communityResolverService.getCommunityForRoleSet as Mock + ).mockResolvedValue({ id: 'comm-1' }); + }; + + it('returns ORGANIZATION_LEAD_ROLE_LIMIT_REACHED when granted Leads already fill the two slots', async () => { + setUpOrganizationLeadInvite(); + (roleSetService.getRoleDefinition as Mock).mockResolvedValue({ + organizationPolicy: { minimum: 0, maximum: 2 }, + }); + (roleSetService.countActorsWithRole as Mock).mockResolvedValue(2); + ( + invitationService.countOpenInvitationsForRoleSet as Mock + ).mockResolvedValue(0); + + const result = await resolver.inviteForEntryRoleOnRoleSet( + actorContext(), + { + roleSetID: 'rs-1', + invitedActorIDs: ['org-1'], + invitedUserEmails: [], + extraRoles: ['lead'], + } as any + ); + + expect(result[0].type).toBe( + RoleSetInvitationResultType.ORGANIZATION_LEAD_ROLE_LIMIT_REACHED + ); + expect( + roleSetService.createInvitationExistingActor + ).not.toHaveBeenCalled(); + }); + + it('returns ORGANIZATION_LEAD_ROLE_LIMIT_REACHED when granted + pending fill the two slots', async () => { + setUpOrganizationLeadInvite(); + (roleSetService.getRoleDefinition as Mock).mockResolvedValue({ + organizationPolicy: { minimum: 0, maximum: 2 }, + }); + (roleSetService.countActorsWithRole as Mock).mockResolvedValue(1); + ( + invitationService.countOpenInvitationsForRoleSet as Mock + ).mockResolvedValue(1); + + const result = await resolver.inviteForEntryRoleOnRoleSet( + actorContext(), + { + roleSetID: 'rs-1', + invitedActorIDs: ['org-1'], + invitedUserEmails: [], + extraRoles: ['lead'], + } as any + ); + + expect(result[0].type).toBe( + RoleSetInvitationResultType.ORGANIZATION_LEAD_ROLE_LIMIT_REACHED + ); + }); + + it('never triggers the limit when the role policy maximum is unlimited (-1)', async () => { + setUpOrganizationLeadInvite(); + (roleSetService.getRoleDefinition as Mock).mockResolvedValue({ + organizationPolicy: { minimum: 0, maximum: -1 }, + }); + (roleSetService.countActorsWithRole as Mock).mockResolvedValue(50); + ( + invitationService.countOpenInvitationsForRoleSet as Mock + ).mockResolvedValue(50); + const mockInvitation = { + id: 'inv-1', + invitedActorID: 'org-1', + } as any; + ( + roleSetService.createInvitationExistingActor as Mock + ).mockResolvedValue(mockInvitation); + (invitationService.getInvitationsOrFail as Mock).mockResolvedValue([ + mockInvitation, + ]); + + const result = await resolver.inviteForEntryRoleOnRoleSet( + actorContext(), + { + roleSetID: 'rs-1', + invitedActorIDs: ['org-1'], + invitedUserEmails: [], + extraRoles: ['lead'], + } as any + ); + + expect(result[0].type).toBe( + RoleSetInvitationResultType.INVITED_TO_ROLE_SET + ); + }); + + it('ignores the Lead limit for a Member-only invite (no extraRoles)', async () => { + setUpOrganizationLeadInvite(); + const mockInvitation = { + id: 'inv-1', + invitedActorID: 'org-1', + } as any; + ( + roleSetService.createInvitationExistingActor as Mock + ).mockResolvedValue(mockInvitation); + (invitationService.getInvitationsOrFail as Mock).mockResolvedValue([ + mockInvitation, + ]); + + const result = await resolver.inviteForEntryRoleOnRoleSet( + actorContext(), + { + roleSetID: 'rs-1', + invitedActorIDs: ['org-1'], + invitedUserEmails: [], + extraRoles: [], + } as any + ); + + expect(result[0].type).toBe( + RoleSetInvitationResultType.INVITED_TO_ROLE_SET + ); + expect(roleSetService.getRoleDefinition).not.toHaveBeenCalled(); + expect(roleSetService.countActorsWithRole).not.toHaveBeenCalled(); + }); + + it('one Lead slot free for two Lead invitees in one call: first sent, second Lead-limit-reached, in submission order', async () => { + ( + actorLookupService.validateActorsAndGetTypes as Mock + ).mockResolvedValue( + new Map([ + ['org-1', 'organization'], + ['org-2', 'organization'], + ]) + ); + ( + organizationLookupService.getOrganizationByIdOrFail as Mock + ).mockResolvedValue({ + settings: { membership: { allowSpaceInvitations: true } }, + }); + (roleSetService.findOpenInvitation as Mock).mockResolvedValue( + undefined + ); + (roleSetService.findOpenApplication as Mock).mockResolvedValue( + undefined + ); + (roleSetService.isMember as Mock).mockResolvedValue(false); + ( + roleSetAuthorizationService.applyAuthorizationPolicyOnInvitationsApplications as Mock + ).mockResolvedValue([]); + (authorizationPolicyService.saveAll as Mock).mockResolvedValue( + undefined + ); + ( + communityResolverService.getCommunityForRoleSet as Mock + ).mockResolvedValue({ id: 'comm-1' }); + (roleSetService.getRoleDefinition as Mock).mockResolvedValue({ + organizationPolicy: { minimum: 0, maximum: 2 }, + }); + // One Lead slot granted already; the request reads the pending + // count once (0) and tracks it locally, bumping it to 1 once the + // first invitee's invitation is created — so a second queued value + // is unnecessary but harmless if a caller still provides one. + (roleSetService.countActorsWithRole as Mock).mockResolvedValue(1); + ( + invitationService.countOpenInvitationsForRoleSet as Mock + ).mockResolvedValue(0); + const mockInvitation1 = { + id: 'inv-org-1', + invitedActorID: 'org-1', + } as any; + ( + roleSetService.createInvitationExistingActor as Mock + ).mockResolvedValue(mockInvitation1); + (invitationService.getInvitationsOrFail as Mock).mockResolvedValue([ + mockInvitation1, + ]); + + const result = await resolver.inviteForEntryRoleOnRoleSet( + actorContext(), + { + roleSetID: 'rs-1', + invitedActorIDs: ['org-1', 'org-2'], + invitedUserEmails: [], + extraRoles: ['lead'], + } as any + ); + + expect(result).toHaveLength(2); + expect(result[0].type).toBe( + RoleSetInvitationResultType.INVITED_TO_ROLE_SET + ); + expect(result[1].type).toBe( + RoleSetInvitationResultType.ORGANIZATION_LEAD_ROLE_LIMIT_REACHED + ); + expect( + roleSetService.createInvitationExistingActor + ).toHaveBeenCalledTimes(1); + // The pending-count read is invariant for the whole request and is + // hoisted out of the per-invitee loop: one query regardless of how + // many organization Lead invitees are in the batch. + expect( + invitationService.countOpenInvitationsForRoleSet + ).toHaveBeenCalledTimes(1); + expect(roleSetService.countActorsWithRole).toHaveBeenCalledTimes(1); + }); + }); + + function actorContext() { + return { actorID: 'user-1' } as any; + } + }); + + describe('inviteForEntryRoleOnRoleSet - organization zero-admin notice and notification dispatch (T009)', () => { + const mockRoleSet = { + id: 'rs-1', + type: RoleSetType.SPACE, + authorization: { id: 'auth-1' }, + parentRoleSet: undefined, + } as any; + const mockInvitation = { + id: 'inv-1', + invitedActorID: 'org-1', + extraRoles: [], + invitedToParent: false, + welcomeMessage: undefined, + } as any; + + const setUp = () => { + (roleSetService.getRoleSetOrFail as Mock).mockResolvedValue(mockRoleSet); + (authorizationService.grantAccessOrFail as Mock).mockReturnValue( + undefined + ); + (actorLookupService.validateActorsAndGetTypes as Mock).mockResolvedValue( + new Map([['org-1', 'organization']]) + ); + ( + organizationLookupService.getOrganizationByIdOrFail as Mock + ).mockResolvedValue({ + settings: { membership: { allowSpaceInvitations: true } }, + }); + (roleSetService.findOpenInvitation as Mock).mockResolvedValue(undefined); + (roleSetService.findOpenApplication as Mock).mockResolvedValue(undefined); + (roleSetService.isMember as Mock).mockResolvedValue(false); + (roleSetService.createInvitationExistingActor as Mock).mockResolvedValue( + mockInvitation + ); + (invitationService.getInvitationsOrFail as Mock).mockResolvedValue([ + mockInvitation, + ]); + ( + roleSetAuthorizationService.applyAuthorizationPolicyOnInvitationsApplications as Mock + ).mockResolvedValue([]); + (authorizationPolicyService.saveAll as Mock).mockResolvedValue(undefined); + ( + communityResolverService.getCommunityForRoleSet as Mock + ).mockResolvedValue({ id: 'comm-1' }); + (actorLookupService.getActorTypeByIdOrFail as Mock).mockResolvedValue( + 'organization' + ); + }; + + it('sets the zero-admin notice and passes organizationHasNoAdministrators: true to the dispatch', async () => { + setUp(); + (userLookupService.usersWithCredentials as Mock).mockResolvedValue([]); + + const result = await resolver.inviteForEntryRoleOnRoleSet( + { actorID: 'user-1' } as any, + { + roleSetID: 'rs-1', + invitedActorIDs: ['org-1'], + invitedUserEmails: [], + extraRoles: [], + } as any + ); + + expect(result[0].type).toBe( + RoleSetInvitationResultType.INVITED_TO_ROLE_SET + ); + expect(result[0].notice).toBe('organization-has-no-administrators'); + expect( + notificationOrganizationAdapter.organizationSpaceCommunityInvitationCreated + ).toHaveBeenCalledWith( + expect.objectContaining({ organizationHasNoAdministrators: true }) + ); + }); + + it('leaves the notice unset when the organization has at least one owner/admin', async () => { + setUp(); + (userLookupService.usersWithCredentials as Mock).mockResolvedValue([ + { id: 'owner-1' }, + ]); + + const result = await resolver.inviteForEntryRoleOnRoleSet( + { actorID: 'user-1' } as any, + { + roleSetID: 'rs-1', + invitedActorIDs: ['org-1'], + invitedUserEmails: [], + extraRoles: [], + } as any + ); + + expect(result[0].notice).toBeUndefined(); + expect( + notificationOrganizationAdapter.organizationSpaceCommunityInvitationCreated + ).toHaveBeenCalledWith( + expect.objectContaining({ organizationHasNoAdministrators: false }) + ); + }); + + it('never dispatches the organization adapter for a non-organization invitee', async () => { + (roleSetService.getRoleSetOrFail as Mock).mockResolvedValue(mockRoleSet); + (authorizationService.grantAccessOrFail as Mock).mockReturnValue( + undefined + ); + // Keyed by the ID the mutation is actually called with below. Keying it + // by the *caller* (`user-1`) made `actorTypes.get('user-2')` resolve to + // `undefined`, so the organization guard was skipped for the trivial + // reason that the invitee had no type at all — the assertions then held + // even if the guard were wired wrongly. + (actorLookupService.validateActorsAndGetTypes as Mock).mockResolvedValue( + new Map([['user-2', 'user']]) + ); + (roleSetService.findOpenInvitation as Mock).mockResolvedValue(undefined); + (roleSetService.findOpenApplication as Mock).mockResolvedValue(undefined); + (roleSetService.isMember as Mock).mockResolvedValue(false); + (roleSetService.createInvitationExistingActor as Mock).mockResolvedValue({ + id: 'inv-2', + invitedActorID: 'user-2', + }); + (invitationService.getInvitationsOrFail as Mock).mockResolvedValue([ + { id: 'inv-2', invitedActorID: 'user-2' }, + ]); + ( + roleSetAuthorizationService.applyAuthorizationPolicyOnInvitationsApplications as Mock + ).mockResolvedValue([]); + (authorizationPolicyService.saveAll as Mock).mockResolvedValue(undefined); + ( + communityResolverService.getCommunityForRoleSet as Mock + ).mockResolvedValue({ id: 'comm-1' }); + (actorLookupService.getActorTypeByIdOrFail as Mock).mockResolvedValue( + 'user' + ); + + await resolver.inviteForEntryRoleOnRoleSet( + { actorID: 'user-1' } as any, + { + roleSetID: 'rs-1', + invitedActorIDs: ['user-2'], + invitedUserEmails: [], + extraRoles: [], + } as any + ); + + expect( + notificationOrganizationAdapter.organizationSpaceCommunityInvitationCreated + ).not.toHaveBeenCalled(); + expect(userLookupService.usersWithCredentials).not.toHaveBeenCalled(); + }); + }); + describe('inviteForEntryRoleOnRoleSet - new email users', () => { it('should create platform invitations for new email users', async () => { const actorContext = { actorID: 'user-1' } as any; @@ -565,6 +1233,67 @@ describe('RoleSetResolverMutationsMembership', () => { expect(result[0].type).toBe('invited-to-platform-and-role-set'); }); + it('stamps the submitted email on the result when the address is an existing user', async () => { + // The server routes an email that belongs to an existing user through + // the ACTOR path, so the result carries `invitation`, never + // `platformInvitation`. Without `invitedEmail` the client cannot match + // that result back to the email chip the user typed, and falls back to + // matching by position — which hands the chip another invitee's outcome. + const actorContext = { actorID: 'user-1' } as any; + const mockRoleSet = { + id: 'rs-1', + type: RoleSetType.SPACE, + authorization: { id: 'auth-1' }, + parentRoleSet: undefined, + } as any; + + (roleSetService.getRoleSetOrFail as Mock).mockResolvedValue(mockRoleSet); + (authorizationService.grantAccessOrFail as Mock).mockReturnValue( + undefined + ); + (actorLookupService.validateActorsAndGetTypes as Mock).mockResolvedValue( + new Map([['user-existing', ActorType.USER]]) + ); + (userLookupService.getUserByEmail as Mock).mockResolvedValue({ + id: 'user-existing', + }); + (roleSetService.findOpenInvitation as Mock).mockResolvedValue(undefined); + (roleSetService.findOpenApplication as Mock).mockResolvedValue(undefined); + (roleSetService.isMember as Mock).mockResolvedValue(false); + const createdInvitation = { + id: 'inv-1', + invitedActorID: 'user-existing', + } as any; + (roleSetService.createInvitationExistingActor as Mock).mockResolvedValue( + createdInvitation + ); + (invitationService.getInvitationsOrFail as Mock).mockResolvedValue([ + createdInvitation, + ]); + (actorLookupService.getActorTypeByIdOrFail as Mock).mockResolvedValue( + ActorType.USER + ); + ( + roleSetAuthorizationService.applyAuthorizationPolicyOnInvitationsApplications as Mock + ).mockResolvedValue([]); + (authorizationPolicyService.saveAll as Mock).mockResolvedValue(undefined); + ( + communityResolverService.getCommunityForRoleSet as Mock + ).mockResolvedValue({ id: 'comm-1' }); + + const result = await resolver.inviteForEntryRoleOnRoleSet(actorContext, { + roleSetID: 'rs-1', + invitedActorIDs: [], + invitedUserEmails: ['bob@existing.com'], + extraRoles: [], + } as any); + + expect(result).toHaveLength(1); + expect(result[0].platformInvitation).toBeUndefined(); + expect(result[0].invitedActorID).toBe('user-existing'); + expect(result[0].invitedEmail).toBe('bob@existing.com'); + }); + it('should handle already-invited platform email', async () => { const actorContext = { actorID: 'user-1' } as any; const mockRoleSet = { @@ -745,6 +1474,466 @@ describe('RoleSetResolverMutationsMembership', () => { expect(result).toBe(mockInvitation); }); + + it('requires the ACCEPT-specific privilege (not just UPDATE) for an ACCEPT event, so a generic UPDATE holder cannot accept on the invited actor behalf', async () => { + const actorContext = { actorID: 'global-admin-1' } as any; + const mockInvitation = { + id: 'inv-1', + authorization: { id: 'auth-1' }, + lifecycle: { id: 'lc-1' }, + invitedActorID: 'org-1', + roleSet: { id: 'rs-1' }, + } as any; + + (invitationService.getInvitationOrFail as Mock).mockResolvedValue( + mockInvitation + ); + // Generic UPDATE is granted (e.g. inherited global admin authorization), + // but the ACCEPT-specific privilege is not. + (authorizationService.grantAccessOrFail as Mock).mockImplementation( + (_actorContext, _authorization, privilege) => { + if ( + privilege === + AuthorizationPrivilege.ROLESET_ENTRY_ROLE_INVITE_ACCEPT + ) { + throw new ForbiddenAuthorizationPolicyException( + 'not permitted to accept', + privilege, + 'auth-1', + 'global-admin-1' + ); + } + return undefined; + } + ); + + await expect( + resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'ACCEPT' } as any, + actorContext + ) + ).rejects.toThrow(ForbiddenAuthorizationPolicyException); + + expect(authorizationService.grantAccessOrFail).toHaveBeenCalledWith( + actorContext, + mockInvitation.authorization, + AuthorizationPrivilege.ROLESET_ENTRY_ROLE_INVITE_ACCEPT, + expect.any(String) + ); + // The event must never reach the lifecycle machine once the + // ACCEPT-specific check has failed. + expect(lifecycleService.event).not.toHaveBeenCalled(); + }); + + it('requires the invite-accept (consent) privilege for a REJECT event too', async () => { + const actorContext = { actorID: 'user-1' } as any; + const mockInvitation = { + id: 'inv-1', + authorization: { id: 'auth-1' }, + lifecycle: { id: 'lc-1' }, + invitedActorID: 'actor-1', + roleSet: { id: 'rs-1' }, + } as any; + + (invitationService.getInvitationOrFail as Mock).mockResolvedValue( + mockInvitation + ); + (authorizationService.grantAccessOrFail as Mock).mockReturnValue( + undefined + ); + (lifecycleService.event as Mock).mockResolvedValue(undefined); + (invitationService.getLifecycleState as Mock).mockResolvedValue( + 'invited' + ); + (lifecycleService.getState as Mock).mockReturnValue('rejected'); + ( + roleSetCacheService.deleteOpenInvitationFromCache as Mock + ).mockResolvedValue(undefined); + ( + roleSetCacheService.deleteMembershipStatusCache as Mock + ).mockResolvedValue(undefined); + (roleSetCacheService.setActorIsMemberCache as Mock).mockResolvedValue( + undefined + ); + + const actorLookupService = (resolver as any).actorLookupService; + (actorLookupService.getActorTypeById as Mock).mockResolvedValue('user'); + + await resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'REJECT' } as any, + actorContext + ); + + // FR-010: declining is the invited actor's own consent decision. A + // generic UPDATE holder (e.g. a global admin) may REVOKE the + // invitation but must not answer it for the invitee — doing so would + // additionally tell the Space admins the invitee declined. + expect(authorizationService.grantAccessOrFail).toHaveBeenCalledWith( + actorContext, + mockInvitation.authorization, + AuthorizationPrivilege.ROLESET_ENTRY_ROLE_INVITE_ACCEPT, + expect.any(String) + ); + }); + + describe('organization accept/decline outcome dispatch (T016)', () => { + const setUp = (createdBy: string | undefined) => { + const mockInvitation = { + id: 'inv-1', + authorization: { id: 'auth-1' }, + lifecycle: { id: 'lc-1' }, + invitedActorID: 'org-1', + roleSet: { id: 'rs-1' }, + createdBy, + } as any; + + (invitationService.getInvitationOrFail as Mock).mockResolvedValue( + mockInvitation + ); + (authorizationService.grantAccessOrFail as Mock).mockReturnValue( + undefined + ); + (lifecycleService.event as Mock).mockResolvedValue(undefined); + ( + roleSetCacheService.deleteOpenInvitationFromCache as Mock + ).mockResolvedValue(undefined); + ( + roleSetCacheService.deleteMembershipStatusCache as Mock + ).mockResolvedValue(undefined); + (roleSetCacheService.setActorIsMemberCache as Mock).mockResolvedValue( + undefined + ); + ( + communityResolverService.getSpaceForRoleSetOrFail as Mock + ).mockResolvedValue({ id: 'space-1' }); + + const actorLookupService = (resolver as any).actorLookupService; + (actorLookupService.getActorTypeById as Mock).mockResolvedValue( + 'organization' + ); + + return mockInvitation; + }; + + it('dispatches spaceAdminOrganizationInvitationAccepted when the invitation is accepted', async () => { + setUp('inviter-1'); + (invitationService.getLifecycleState as Mock).mockResolvedValue( + 'accepting' + ); + (roleSetService.acceptInvitationToRoleSet as Mock).mockResolvedValue( + undefined + ); + (lifecycleService.getState as Mock).mockReturnValue('accepted'); + + await resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'ACCEPT' } as any, + { actorID: 'org-admin-1' } as any + ); + + expect( + notificationAdapterSpace.spaceAdminOrganizationInvitationAccepted + ).toHaveBeenCalledWith( + expect.objectContaining({ + triggeredBy: 'org-admin-1', + invitationCreatedBy: 'inviter-1', + invitedActorID: 'org-1', + spaceID: 'space-1', + }), + expect.objectContaining({ id: 'space-1' }) + ); + expect( + notificationAdapterSpace.spaceAdminOrganizationInvitationDeclined + ).not.toHaveBeenCalled(); + }); + + it('still dispatches accepted when the inviter no longer exists (createdBy null)', async () => { + // The event goes to every Space admin, not only the inviter, so a + // deleted inviter must not silence it — with the generic "new member + // joined" suppressed, that would leave the Space told nothing. + setUp(undefined); + (invitationService.getLifecycleState as Mock).mockResolvedValue( + 'accepting' + ); + (roleSetService.acceptInvitationToRoleSet as Mock).mockResolvedValue( + undefined + ); + (lifecycleService.getState as Mock).mockReturnValue('accepted'); + + const result = await resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'ACCEPT' } as any, + { actorID: 'org-admin-1' } as any + ); + + expect(result).toBeDefined(); + expect( + notificationAdapterSpace.spaceAdminOrganizationInvitationAccepted + ).toHaveBeenCalledWith( + expect.objectContaining({ invitationCreatedBy: '' }), + expect.objectContaining({ id: 'space-1' }) + ); + }); + + it('dispatches spaceAdminOrganizationInvitationDeclined when the invitation is rejected', async () => { + setUp('inviter-1'); + (invitationService.getLifecycleState as Mock).mockResolvedValue( + 'invited' + ); + (lifecycleService.getState as Mock).mockReturnValue('rejected'); + + await resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'REJECT' } as any, + { actorID: 'org-admin-1' } as any + ); + + expect( + notificationAdapterSpace.spaceAdminOrganizationInvitationDeclined + ).toHaveBeenCalledWith( + expect.objectContaining({ + triggeredBy: 'org-admin-1', + invitationCreatedBy: 'inviter-1', + invitedActorID: 'org-1', + spaceID: 'space-1', + }), + expect.objectContaining({ id: 'space-1' }) + ); + expect( + notificationAdapterSpace.spaceAdminOrganizationInvitationAccepted + ).not.toHaveBeenCalled(); + }); + + it('still dispatches declined when the inviter no longer exists (createdBy null)', async () => { + setUp(undefined); + (invitationService.getLifecycleState as Mock).mockResolvedValue( + 'invited' + ); + (lifecycleService.getState as Mock).mockReturnValue('rejected'); + + const result = await resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'REJECT' } as any, + { actorID: 'org-admin-1' } as any + ); + + expect(result).toBeDefined(); + expect( + notificationAdapterSpace.spaceAdminOrganizationInvitationDeclined + ).toHaveBeenCalledWith( + expect.objectContaining({ invitationCreatedBy: '' }), + expect.objectContaining({ id: 'space-1' }) + ); + }); + + it('dispatches the organization "joined" welcome to the org admins on accept', async () => { + setUp('inviter-1'); + (invitationService.getLifecycleState as Mock).mockResolvedValue( + 'accepting' + ); + (roleSetService.acceptInvitationToRoleSet as Mock).mockResolvedValue( + undefined + ); + (lifecycleService.getState as Mock).mockReturnValue('accepted'); + + await resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'ACCEPT' } as any, + { actorID: 'org-admin-1' } as any + ); + + expect( + notificationOrganizationAdapter.organizationSpaceCommunityJoined + ).toHaveBeenCalledWith({ + triggeredBy: 'org-admin-1', + organizationID: 'org-1', + spaceID: 'space-1', + }); + }); + + it('does not dispatch the organization "joined" welcome on decline', async () => { + setUp('inviter-1'); + (invitationService.getLifecycleState as Mock).mockResolvedValue( + 'invited' + ); + (lifecycleService.getState as Mock).mockReturnValue('rejected'); + + await resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'REJECT' } as any, + { actorID: 'org-admin-1' } as any + ); + + expect( + notificationOrganizationAdapter.organizationSpaceCommunityJoined + ).not.toHaveBeenCalled(); + }); + + it('never dispatches the organization outcome adapters for a Virtual Contributor invitee (unchanged VC path)', async () => { + const mockInvitation = { + id: 'inv-1', + authorization: { id: 'auth-1' }, + lifecycle: { id: 'lc-1' }, + invitedActorID: 'vc-1', + roleSet: { id: 'rs-1' }, + createdBy: 'inviter-1', + } as any; + (invitationService.getInvitationOrFail as Mock).mockResolvedValue( + mockInvitation + ); + (authorizationService.grantAccessOrFail as Mock).mockReturnValue( + undefined + ); + (lifecycleService.event as Mock).mockResolvedValue(undefined); + (invitationService.getLifecycleState as Mock).mockResolvedValue( + 'invited' + ); + (lifecycleService.getState as Mock).mockReturnValue('rejected'); + ( + roleSetCacheService.deleteOpenInvitationFromCache as Mock + ).mockResolvedValue(undefined); + ( + roleSetCacheService.deleteMembershipStatusCache as Mock + ).mockResolvedValue(undefined); + (roleSetCacheService.setActorIsMemberCache as Mock).mockResolvedValue( + undefined + ); + ( + communityResolverService.getCommunityForRoleSet as Mock + ).mockResolvedValue({ id: 'comm-1' }); + ( + communityResolverService.getSpaceForCommunityOrFail as Mock + ).mockResolvedValue({ id: 'space-1' }); + const actorLookupService = (resolver as any).actorLookupService; + (actorLookupService.getActorTypeById as Mock).mockResolvedValue( + 'virtual-contributor' + ); + + await resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'REJECT' } as any, + { actorID: 'org-admin-1' } as any + ); + + expect( + notificationAdapterSpace.spaceAdminVirtualContributorInvitationDeclined + ).toHaveBeenCalled(); + expect( + notificationAdapterSpace.spaceAdminOrganizationInvitationDeclined + ).not.toHaveBeenCalled(); + expect( + notificationAdapterSpace.spaceAdminOrganizationInvitationAccepted + ).not.toHaveBeenCalled(); + }); + }); + + describe('user accept/decline outcome dispatch', () => { + const setUpUser = (createdBy: string | undefined) => { + const mockInvitation = { + id: 'inv-1', + authorization: { id: 'auth-1' }, + lifecycle: { id: 'lc-1' }, + invitedActorID: 'user-9', + roleSet: { id: 'rs-1' }, + createdBy, + } as any; + + (invitationService.getInvitationOrFail as Mock).mockResolvedValue( + mockInvitation + ); + (authorizationService.grantAccessOrFail as Mock).mockReturnValue( + undefined + ); + (lifecycleService.event as Mock).mockResolvedValue(undefined); + ( + roleSetCacheService.deleteOpenInvitationFromCache as Mock + ).mockResolvedValue(undefined); + ( + roleSetCacheService.deleteMembershipStatusCache as Mock + ).mockResolvedValue(undefined); + (roleSetCacheService.setActorIsMemberCache as Mock).mockResolvedValue( + undefined + ); + ( + communityResolverService.getSpaceForRoleSetOrFail as Mock + ).mockResolvedValue({ id: 'space-1' }); + + const actorLookupService = (resolver as any).actorLookupService; + (actorLookupService.getActorTypeById as Mock).mockResolvedValue('user'); + + return mockInvitation; + }; + + it('dispatches spaceAdminUserInvitationAccepted to the inviter on accept', async () => { + setUpUser('inviter-1'); + (invitationService.getLifecycleState as Mock).mockResolvedValue( + 'accepting' + ); + (roleSetService.acceptInvitationToRoleSet as Mock).mockResolvedValue( + undefined + ); + (lifecycleService.getState as Mock).mockReturnValue('accepted'); + + await resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'ACCEPT' } as any, + { actorID: 'user-9' } as any + ); + + expect( + notificationAdapterSpace.spaceAdminUserInvitationAccepted + ).toHaveBeenCalledWith( + expect.objectContaining({ + triggeredBy: 'user-9', + invitationCreatedBy: 'inviter-1', + invitedActorID: 'user-9', + spaceID: 'space-1', + }), + expect.objectContaining({ id: 'space-1' }) + ); + expect( + notificationAdapterSpace.spaceAdminOrganizationInvitationAccepted + ).not.toHaveBeenCalled(); + expect( + notificationOrganizationAdapter.organizationSpaceCommunityJoined + ).not.toHaveBeenCalled(); + }); + + it('dispatches spaceAdminUserInvitationDeclined to the inviter on decline', async () => { + setUpUser('inviter-1'); + (invitationService.getLifecycleState as Mock).mockResolvedValue( + 'invited' + ); + (lifecycleService.getState as Mock).mockReturnValue('rejected'); + + await resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'REJECT' } as any, + { actorID: 'user-9' } as any + ); + + expect( + notificationAdapterSpace.spaceAdminUserInvitationDeclined + ).toHaveBeenCalledWith( + expect.objectContaining({ + invitedActorID: 'user-9', + invitationCreatedBy: 'inviter-1', + }), + expect.objectContaining({ id: 'space-1' }) + ); + }); + + it('still dispatches the user outcome when the inviter no longer exists', async () => { + setUpUser(undefined); + (invitationService.getLifecycleState as Mock).mockResolvedValue( + 'invited' + ); + (lifecycleService.getState as Mock).mockReturnValue('rejected'); + + await resolver.eventOnInvitation( + { invitationID: 'inv-1', eventName: 'REJECT' } as any, + { actorID: 'user-9' } as any + ); + + expect( + notificationAdapterSpace.spaceAdminUserInvitationDeclined + ).toHaveBeenCalledWith( + expect.objectContaining({ invitationCreatedBy: '' }), + expect.objectContaining({ id: 'space-1' }) + ); + }); + }); }); describe('updateApplicationFormOnRoleSet', () => { diff --git a/src/domain/access/role-set/role.set.resolver.mutations.membership.ts b/src/domain/access/role-set/role.set.resolver.mutations.membership.ts index ddc6c14993..4ae7990bea 100644 --- a/src/domain/access/role-set/role.set.resolver.mutations.membership.ts +++ b/src/domain/access/role-set/role.set.resolver.mutations.membership.ts @@ -1,8 +1,11 @@ +import { ORGANIZATION_NOTIFICATION_CREDENTIAL_TYPES } from '@common/constants/authorization'; import { AuthorizationPrivilege, LogContext } from '@common/enums'; import { ActorType } from '@common/enums/actor.type'; import { CommunityMembershipStatus } from '@common/enums/community.membership.status'; +import { isContributorActorType } from '@common/enums/contributor.actor.types'; import { LicenseEntitlementType } from '@common/enums/license.entitlement.type'; import { RoleName } from '@common/enums/role.name'; +import { RoleSetInvitationResultNotice } from '@common/enums/role.set.invitation.result.notice'; import { RoleSetInvitationResultType } from '@common/enums/role.set.invitation.result.type'; import { RoleSetType } from '@common/enums/role.set.type'; import { @@ -27,17 +30,22 @@ import { ActorLookupService } from '@domain/actor/actor-lookup/actor.lookup.serv import { AuthorizationPolicyService } from '@domain/common/authorization-policy/authorization.policy.service'; import { LicenseService } from '@domain/common/license/license.service'; import { LifecycleService } from '@domain/common/lifecycle/lifecycle.service'; +import { OrganizationLookupService } from '@domain/community/organization-lookup/organization.lookup.service'; import { UserLookupService } from '@domain/community/user-lookup/user.lookup.service'; import { VirtualContributorLookupService } from '@domain/community/virtual-contributor-lookup/virtual.contributor.lookup.service'; import { AccountLookupService } from '@domain/space/account.lookup/account.lookup.service'; import { Inject, LoggerService } from '@nestjs/common'; import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { NotificationInputOrganizationSpaceCommunityInvitation } from '@services/adapters/notification-adapter/dto/organization/notification.dto.input.organization.space.community.invitation'; +import { NotificationInputOrganizationSpaceCommunityJoined } from '@services/adapters/notification-adapter/dto/organization/notification.dto.input.organization.space.community.joined'; import { NotificationInputCommunityApplication } from '@services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.application'; import { NotificationInputCommunityInvitation } from '@services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.invitation'; +import { NotificationInputSpaceCommunityInvitationOutcome } from '@services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.invitation.outcome'; import { NotificationInputPlatformInvitation } from '@services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.invitation.platform'; import { NotificationInputCommunityInvitationVirtualContributor } from '@services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.invitation.vc'; import { NotificationInputVirtualContributorSpaceCommunityInvitationDeclined } from '@services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.invitation.vc.declined'; import { NotificationInputUserSpaceCommunityApplicationDeclined } from '@services/adapters/notification-adapter/dto/user/notification.dto.input.user.space.community.application.declined'; +import { NotificationOrganizationAdapter } from '@services/adapters/notification-adapter/notification.organization.adapter'; import { NotificationPlatformAdapter } from '@services/adapters/notification-adapter/notification.platform.adapter'; import { NotificationSpaceAdapter } from '@services/adapters/notification-adapter/notification.space.adapter'; import { NotificationUserAdapter } from '@services/adapters/notification-adapter/notification.user.adapter'; @@ -81,8 +89,10 @@ export class RoleSetResolverMutationsMembership { private notificationUserAdapter: NotificationUserAdapter, private notificationAdapterSpace: NotificationSpaceAdapter, private notificationVirtualContributorAdapter: NotificationVirtualContributorAdapter, + private notificationOrganizationAdapter: NotificationOrganizationAdapter, private notificationPlatformAdapter: NotificationPlatformAdapter, private userLookupService: UserLookupService, + private organizationLookupService: OrganizationLookupService, private virtualContributorLookupService: VirtualContributorLookupService, private accountLookupService: AccountLookupService, private communityResolverService: CommunityResolverService, @@ -335,16 +345,46 @@ export class RoleSetResolverMutationsMembership { } } - // Collect actor IDs to invite - const actorIDsToInvite: string[] = [...invitationData.invitedActorIDs]; + // Normalize ONCE, here, so validation, persistence and the eventual role + // grant all see the same list. Previously only the policy lookup inside + // `validateInviteesAndRolesOrFail` de-duplicated, so `[LEAD, LEAD]` + // validated as one role but was persisted on the invitation — and echoed + // back to the client — as two. + const extraRoles = [...new Set(invitationData.extraRoles)]; + + // Reject an invalid invitee actor type or a role an organization's policy + // forbids before anything is created. + await this.validateInviteesAndRolesOrFail(actorTypes, extraRoles, roleSet); + + // Collect actor IDs to invite, de-duplicated for the same reason + // `extraRoles` is normalized above: one actor can arrive twice — listed + // twice in `invitedActorIDs`, or listed once there and typed again as the + // email address of that same registered user. Without this the second pass + // over the duplicate throws ALREADY_INVITED *after* the first invitation + // has been persisted, so the mutation 500s, the notification never goes + // out, and every retry hits the same wall. A person who appears as both a + // picked actor and a typed address is invited once, attributed to the + // actor they were picked as. + const actorIDsToInvite: string[] = [ + ...new Set(invitationData.invitedActorIDs), + ]; // Loop through the emails provided to see if are existing users or not const newUserEmails: string[] = []; + // An email that belongs to an existing user is invited as an actor, not as + // a platform invitation. Its result therefore carries `invitation`, never + // `platformInvitation`, and the invitee moves out of the email group into + // the actor group — so the client can only match it back to the chip the + // user typed if the originating address travels with it. + const emailByActorID = new Map(); for (const email of invitationData.invitedUserEmails) { // If the user is already registered, then just create a normal invitation const existingUser = await this.userLookupService.getUserByEmail(email); if (existingUser) { - actorIDsToInvite.push(existingUser.id); + if (!actorIDsToInvite.includes(existingUser.id)) { + actorIDsToInvite.push(existingUser.id); + emailByActorID.set(existingUser.id, email); + } } else { newUserEmails.push(email); } @@ -355,9 +395,11 @@ export class RoleSetResolverMutationsMembership { actorIDsToInvite, actorContext, authorizedToInviteToParentRoleSet, - invitationData.extraRoles, + extraRoles, invitationData.welcomeMessage, - invitationData.suggestedLanguage + invitationData.suggestedLanguage, + actorTypes, + emailByActorID ); const newUserInvitationResults = @@ -366,7 +408,7 @@ export class RoleSetResolverMutationsMembership { newUserEmails, authorizedToInviteToParentRoleSet, invitationData.welcomeMessage, - invitationData.extraRoles, + extraRoles, actorContext, invitationData.suggestedLanguage ); @@ -419,6 +461,7 @@ export class RoleSetResolverMutationsMembership { const result: RoleSetInvitationResult = { type: RoleSetInvitationResultType.ALREADY_INVITED_TO_PLATFORM_AND_ROLE_SET, platformInvitation: existingPlatformInvitation, + invitedEmail: email, }; invitationResults.push(result); continue; @@ -431,6 +474,7 @@ export class RoleSetResolverMutationsMembership { if (!authorizedToInviteToParentRoleSet) { const result: RoleSetInvitationResult = { type: RoleSetInvitationResultType.INVITATION_TO_PARENT_NOT_AUTHORIZED, + invitedEmail: email, }; invitationResults.push(result); @@ -452,6 +496,7 @@ export class RoleSetResolverMutationsMembership { const result: RoleSetInvitationResult = { type: RoleSetInvitationResultType.INVITED_TO_PLATFORM_AND_ROLE_SET, platformInvitation: newPlatformInvitation, + invitedEmail: email, }; invitationResults.push(result); } @@ -609,6 +654,23 @@ export class RoleSetResolverMutationsMembership { AuthorizationPrivilege.UPDATE, `event on invitation: ${invitation.id}` ); + // ACCEPT and REJECT are scoped tighter than the generic UPDATE privilege + // above: both are the invited actor's own consent decision, so only that + // actor's account admin (or, for a user actor, the user themself) may + // make them on the actor's behalf (FR-010). A generic UPDATE holder — + // e.g. a global admin via inherited parent authorization — can still + // REVOKE the invitation (deletion), which is the Space-side action, but + // must not answer it for the invitee: declining on their behalf would + // additionally send the Space admins a "the organisation declined your + // invitation" notification asserting a decision the invitee never made. + if (eventData.eventName === 'ACCEPT' || eventData.eventName === 'REJECT') { + this.authorizationService.grantAccessOrFail( + actorContext, + invitation.authorization, + AuthorizationPrivilege.ROLESET_ENTRY_ROLE_INVITE_ACCEPT, + `${eventData.eventName.toLowerCase()} event on invitation: ${invitation.id}` + ); + } // Send the event, translated if needed this.logger.verbose?.( @@ -676,34 +738,96 @@ export class RoleSetResolverMutationsMembership { // Send notification if invitation was declined/rejected for Virtual Contributor if (invitationState === InvitationLifecycleState.REJECTED) { if (invitedActorType === ActorType.VIRTUAL_CONTRIBUTOR) { - const community = - await this.communityResolverService.getCommunityForRoleSet( - invitation.roleSet.id - ); - const space = - await this.communityResolverService.getSpaceForCommunityOrFail( - community.id - ); + // Notification-only lookups, so they live INSIDE the swallowing + // wrapper (see dispatchInvitationOutcomeNotification). + const roleSetID = invitation.roleSet.id; + const invitationCreatedBy = invitation.createdBy ?? ''; + this.dispatchNotification( + (async () => { + const community = + await this.communityResolverService.getCommunityForRoleSet( + roleSetID + ); + const space = + await this.communityResolverService.getSpaceForCommunityOrFail( + community.id + ); - const notificationInput: NotificationInputVirtualContributorSpaceCommunityInvitationDeclined = - { - triggeredBy: actorContext.actorID, // Who declined the invitation - invitationCreatedBy: invitation.createdBy ?? '', // Who sent the invitation (may be null if creator was deleted) - virtualContributorID: invitedActorID, - spaceID: space.id, - }; + const notificationInput: NotificationInputVirtualContributorSpaceCommunityInvitationDeclined = + { + triggeredBy: actorContext.actorID, // Who declined the invitation + invitationCreatedBy, // Who sent the invitation (may be '' if creator was deleted) + virtualContributorID: invitedActorID, + spaceID: space.id, + }; - this.dispatchNotification( - this.notificationAdapterSpace.spaceAdminVirtualContributorInvitationDeclined( - notificationInput, - space - ), + await this.notificationAdapterSpace.spaceAdminVirtualContributorInvitationDeclined( + notificationInput, + space + ); + })(), 'spaceAdminVirtualContributorInvitationDeclined' ); + } else if ( + invitedActorType === ActorType.ORGANIZATION || + invitedActorType === ActorType.USER + ) { + this.dispatchInvitationOutcomeNotification( + invitation, + invitedActorID, + invitedActorType, + actorContext, + 'declined' + ); } } const isMember = invitationState === InvitationLifecycleState.ACCEPTED; + + if ( + isMember && + (invitedActorType === ActorType.ORGANIZATION || + invitedActorType === ActorType.USER) + ) { + this.dispatchInvitationOutcomeNotification( + invitation, + invitedActorID, + invitedActorType, + actorContext, + 'accepted' + ); + } + + // "Your organization has joined" — the organization-side counterpart of + // the welcome notification a user gets when they accept their own Space + // invitation. Its point is the multi-admin case: one admin accepts, and + // the rest learn no action is needed. + if (isMember && invitedActorType === ActorType.ORGANIZATION) { + // Notification-only lookup, so it lives INSIDE the swallowing + // wrapper: a failure here must not 500 a mutation whose membership + // change has already committed, nor skip the cache invalidations + // below it. + const roleSetID = invitation.roleSet.id; + this.dispatchNotification( + (async () => { + const space = + await this.communityResolverService.getSpaceForRoleSetOrFail( + roleSetID + ); + const joinedInput: NotificationInputOrganizationSpaceCommunityJoined = + { + triggeredBy: actorContext.actorID, // Who accepted the invitation + organizationID: invitedActorID, + spaceID: space.id, + }; + await this.notificationOrganizationAdapter.organizationSpaceCommunityJoined( + joinedInput + ); + })(), + 'organizationSpaceCommunityJoined' + ); + } + await this.roleSetCacheService.deleteOpenInvitationFromCache( invitedActorID, invitation.roleSet.id @@ -747,6 +871,193 @@ export class RoleSetResolverMutationsMembership { ); } + /** + * "Someone responded to an invitation" — one dispatch for both accepted and + * declined, and for both organization and user invitees. It goes to EVERY + * admin of the Space (product email: "Space admin(s) gets notification that + * the organization has accepted or rejected their invitation"), not only + * `invitation.createdBy`: because it fires, the generic "a new member + * joined" notification is suppressed for the same membership change (see + * `CommunityMembershipOrigin`), so scoping it to the inviter would leave + * every co-admin uninformed — and inform nobody at all once the inviter is + * deleted or demoted. + * + * Whole body is best-effort: the notification-only Space lookup is inside + * the swallowing wrapper, so a failure here can never 500 a mutation whose + * membership change has already committed, nor skip the cache + * invalidations that follow it. + */ + private dispatchInvitationOutcomeNotification( + invitation: IInvitation, + invitedActorID: string, + invitedActorType: ActorType, + actorContext: ActorContext, + outcome: 'accepted' | 'declined' + ): void { + const roleSetID = invitation.roleSet?.id; + if (!roleSetID) { + return; + } + const isOrganization = invitedActorType === ActorType.ORGANIZATION; + const handler = isOrganization + ? outcome === 'accepted' + ? 'spaceAdminOrganizationInvitationAccepted' + : 'spaceAdminOrganizationInvitationDeclined' + : outcome === 'accepted' + ? 'spaceAdminUserInvitationAccepted' + : 'spaceAdminUserInvitationDeclined'; + + this.dispatchNotification( + (async () => { + const space = + await this.communityResolverService.getSpaceForRoleSetOrFail( + roleSetID + ); + const notificationInput: NotificationInputSpaceCommunityInvitationOutcome = + { + triggeredBy: actorContext.actorID, // Who answered the invitation + // Who sent the invitation; '' when that account is gone. Kept for + // the event payload only — it no longer scopes the recipients. + invitationCreatedBy: invitation.createdBy ?? '', + invitedActorID, + spaceID: space.id, + }; + await this.notificationAdapterSpace[handler](notificationInput, space); + })(), + handler + ); + } + + /** + * Rejects an invite request before anything is created when either: an + * invited actor is not a valid community-contributor type (user, + * organization or virtual contributor), or a requested extra role is one + * this RoleSet does not define at all, or one that an invited actor + * type's policy forbids (maximum 0) in this RoleSet. + */ + private async validateInviteesAndRolesOrFail( + actorTypes: Map, + extraRoles: RoleName[], + roleSet: IRoleSet + ): Promise { + for (const [actorID, actorType] of actorTypes) { + if (!isContributorActorType(actorType)) { + throw new ValidationException( + 'Invitees must be a user, organization or virtual contributor', + LogContext.COMMUNITY, + { actorID, actorType } + ); + } + } + + // One RoleSet+roles load for the whole list: the DTO caps extraRoles, but + // a per-element round trip would still scale with input. The caller has + // already de-duplicated. + const requestedRoles = extraRoles; + if (requestedRoles.length === 0) { + return; + } + const invitedActorTypes = new Set(actorTypes.values()); + const roleDefinitions = await this.roleSetService.getRoleDefinitions( + roleSet, + requestedRoles + ); + for (const role of requestedRoles) { + const roleDefinition = roleDefinitions.find( + definition => definition.name === role + ); + // `getRoleDefinitions` FILTERS to the roles this RoleSet declares + // rather than failing, so an undefined definition means the role does + // not exist here at all (RoleName is a 20-member enum while a Space + // RoleSet declares only MEMBER/LEAD/ADMIN). Accepting it would persist + // an invitation offering a role that can never be granted — the + // server#4602 shape. + if (!roleDefinition) { + throw new ValidationException( + 'Invitees cannot be invited with a role this RoleSet does not define', + LogContext.COMMUNITY, + { role } + ); + } + // Per invited actor type, reject a role that type's policy forbids + // (maximum 0) in this RoleSet. Checked for every contributor type, not + // only organizations: a Virtual Contributor's ADMIN policy is 0 too + // (fixes server#4602 for both). + for (const actorType of invitedActorTypes) { + const policy = + actorType === ActorType.ORGANIZATION + ? roleDefinition.organizationPolicy + : actorType === ActorType.VIRTUAL_CONTRIBUTOR + ? roleDefinition.virtualContributorPolicy + : roleDefinition.userPolicy; + if (policy?.maximum === 0) { + throw new ValidationException( + 'An invitee cannot be invited with a role its policy forbids', + LogContext.COMMUNITY, + { role, actorType } + ); + } + } + } + } + + /** + * Advisory, organization-only guards run just before an invitation row is + * created: an organization that opted out of Space invitations, and a + * Lead invitation that would exceed the Space's Lead-organization + * capacity (granted Leads plus every still-pending Lead invitation on the + * Space, including ones this same request already created). Returns a + * typed, no-op result to record instead of creating anything, or + * `undefined` when the invitee may proceed. Never throws — both checks + * are advisory, not authorization. + * + * The granted/pending Lead counts are invariant for the whole request + * (creating an invitation never grants the role), so the caller computes + * them once and passes the running pending count in rather than this + * method re-querying the RoleSet's entire invitation history once per + * invitee. + */ + private async guardOrganizationInvitation( + roleSet: IRoleSet, + actorID: string, + actorType: ActorType | undefined, + extraRoles: RoleName[], + leadSlots?: { granted: number; pending: number; maximum: number } + ): Promise { + if (actorType !== ActorType.ORGANIZATION) { + return undefined; + } + + const organization = + await this.organizationLookupService.getOrganizationByIdOrFail(actorID); + // `Organization.applyMembershipSettingsDefaults` (@AfterLoad) early-returns + // when the `settings` jsonb has no `membership` object at all, so the + // object — not just the key — can legitimately be absent on a row written + // before migration 1788400000000 ran, or by an old pod mid rolling-deploy. + // An unguarded deref would throw inside the invitee loop and abort the + // whole batch, including invitations already created for other invitees. + const allowsSpaceInvitations = + organization.settings?.membership?.allowSpaceInvitations ?? true; + if (!allowsSpaceInvitations) { + return { + type: RoleSetInvitationResultType.ORGANIZATION_NOT_ACCEPTING_INVITATIONS, + }; + } + + if (!extraRoles.includes(RoleName.LEAD) || !leadSlots) { + return undefined; + } + + const { granted, pending, maximum } = leadSlots; + if (maximum >= 0 && granted + pending >= maximum) { + return { + type: RoleSetInvitationResultType.ORGANIZATION_LEAD_ROLE_LIMIT_REACHED, + }; + } + + return undefined; + } + private async inviteActorsToEntryRole( roleSet: IRoleSet, actorIDs: string[], @@ -754,9 +1065,57 @@ export class RoleSetResolverMutationsMembership { authorizedToInviteToParentRoleSet: boolean, extraRoles: RoleName[], welcomeMessage: string | undefined, - suggestedLanguage?: string + suggestedLanguage?: string, + actorTypes: Map = new Map(), + emailByActorID: Map = new Map() ): Promise { const invitationResults: RoleSetInvitationResult[] = []; + + // Every result produced in the loop below belongs to the invitee being + // processed, so the identity is stamped in one place rather than at each + // of the six `push` sites. `emailByActorID` carries the address the + // client actually submitted for an invitee that reached this loop as an + // email that resolved to an existing user — without it that result would + // be unmatchable against the chip the user typed. + const pushResultForActor = ( + actorID: string, + result: RoleSetInvitationResult + ): void => { + const invitedEmail = emailByActorID.get(actorID); + invitationResults.push({ + ...result, + invitedActorID: actorID, + ...(invitedEmail ? { invitedEmail } : {}), + }); + }; + + // The Lead-organization slot counts are invariant for the whole + // request (creating an invitation never grants the role), so they are + // read once here rather than once per organization invitee below. The + // pending count is then tracked locally and bumped after each org Lead + // invitation this same request creates, so a batch of invitees still + // can't jointly exceed the Space's Lead-organization capacity. + let leadSlots: + | { granted: number; pending: number; maximum: number } + | undefined; + if (extraRoles.includes(RoleName.LEAD)) { + const [granted, pending, leadRoleDefinition] = await Promise.all([ + this.roleSetService.countActorsWithRole(roleSet, RoleName.LEAD, [ + ActorType.ORGANIZATION, + ]), + this.invitationService.countOpenInvitationsForRoleSet(roleSet.id, { + extraRole: RoleName.LEAD, + actorType: ActorType.ORGANIZATION, + }), + this.roleSetService.getRoleDefinition(roleSet, RoleName.LEAD), + ]); + leadSlots = { + granted, + pending, + maximum: leadRoleDefinition.organizationPolicy.maximum, + }; + } + for (const actorID of actorIDs) { let invitedToParent = false; // Logic is that the ability to invite to a subspace requires the ability to invite to the @@ -770,7 +1129,7 @@ export class RoleSetResolverMutationsMembership { const result: RoleSetInvitationResult = { type: RoleSetInvitationResultType.INVITATION_TO_PARENT_NOT_AUTHORIZED, }; - invitationResults.push(result); + pushResultForActor(actorID, result); continue; } invitedToParent = true; @@ -795,7 +1154,7 @@ export class RoleSetResolverMutationsMembership { type: RoleSetInvitationResultType.ALREADY_INVITED_TO_ROLE_SET, invitation: openInvitation, }; - invitationResults.push(result); + pushResultForActor(actorID, result); continue; } @@ -811,7 +1170,7 @@ export class RoleSetResolverMutationsMembership { type: RoleSetInvitationResultType.ALREADY_HAS_OPEN_APPLICATION, application: openApplication, }; - invitationResults.push(result); + pushResultForActor(actorID, result); continue; } @@ -825,18 +1184,52 @@ export class RoleSetResolverMutationsMembership { const result: RoleSetInvitationResult = { type: RoleSetInvitationResultType.ALREADY_MEMBER_OF_ROLE_SET, }; - invitationResults.push(result); + pushResultForActor(actorID, result); + continue; + } + + const invitedActorType = actorTypes.get(actorID); + const organizationGuardResult = await this.guardOrganizationInvitation( + roleSet, + actorID, + invitedActorType, + extraRoles, + leadSlots + ); + if (organizationGuardResult) { + pushResultForActor(actorID, organizationGuardResult); continue; } const invitation = await this.roleSetService.createInvitationExistingActor(input); + if (invitedActorType === ActorType.ORGANIZATION && leadSlots) { + leadSlots.pending += 1; + } + const invitationResult: RoleSetInvitationResult = { type: RoleSetInvitationResultType.INVITED_TO_ROLE_SET, invitation, }; - invitationResults.push(invitationResult); + if (invitedActorType === ActorType.ORGANIZATION) { + // Counted on the ADMIN set — the same set the invitation notification + // is addressed to. An organization with owners but no admins therefore + // escalates to platform support rather than notifying nobody, which is + // exactly the story AC ("if there are no organization admins at all, + // invitation should be sent to support@alkem.io"). + const admins = await this.userLookupService.usersWithCredentials( + ORGANIZATION_NOTIFICATION_CREDENTIAL_TYPES.map(type => ({ + type, + resourceID: actorID, + })) + ); + if (admins.length === 0) { + invitationResult.notice = + RoleSetInvitationResultNotice.ORGANIZATION_HAS_NO_ADMINISTRATORS; + } + } + pushResultForActor(actorID, invitationResult); } return invitationResults; } @@ -972,7 +1365,26 @@ export class RoleSetResolverMutationsMembership { break; } case ActorType.ORGANIZATION: { - // No notifications supported at the moment + const notificationInput: NotificationInputOrganizationSpaceCommunityInvitation = + { + triggeredBy: actorContext.actorID, + community, + invitationID: invitation.id, + invitedContributorID: invitation.invitedActorID, + welcomeMessage: invitation.welcomeMessage, + extraRoles: invitation.extraRoles, + invitedToParent: invitation.invitedToParent, + organizationHasNoAdministrators: + invitationResult.notice === + RoleSetInvitationResultNotice.ORGANIZATION_HAS_NO_ADMINISTRATORS, + }; + + this.dispatchNotification( + this.notificationOrganizationAdapter.organizationSpaceCommunityInvitationCreated( + notificationInput + ), + 'organizationSpaceCommunityInvitationCreated' + ); break; } } @@ -980,10 +1392,24 @@ export class RoleSetResolverMutationsMembership { } case RoleSetInvitationResultType.ALREADY_INVITED_TO_PLATFORM_AND_ROLE_SET: case RoleSetInvitationResultType.ALREADY_INVITED_TO_ROLE_SET: - case RoleSetInvitationResultType.INVITATION_TO_PARENT_NOT_AUTHORIZED: { + case RoleSetInvitationResultType.INVITATION_TO_PARENT_NOT_AUTHORIZED: + case RoleSetInvitationResultType.ALREADY_HAS_OPEN_APPLICATION: + case RoleSetInvitationResultType.ALREADY_MEMBER_OF_ROLE_SET: + case RoleSetInvitationResultType.ORGANIZATION_NOT_ACCEPTING_INVITATIONS: + case RoleSetInvitationResultType.ORGANIZATION_LEAD_ROLE_LIMIT_REACHED: { // No notifications to be triggered break; } + default: { + // Compile-time exhaustiveness guard: a new RoleSetInvitationResultType + // value that reaches this branch fails to build rather than silently + // dropping every invitation's notification. + const _exhaustiveCheck: never = invitationResult.type; + this.logger.warn?.( + `Unhandled RoleSetInvitationResultType in notification dispatch: ${_exhaustiveCheck}`, + LogContext.NOTIFICATIONS + ); + } } } } diff --git a/src/domain/access/role-set/role.set.resolver.mutations.spec.ts b/src/domain/access/role-set/role.set.resolver.mutations.spec.ts index d0a7630113..fa183da9b2 100644 --- a/src/domain/access/role-set/role.set.resolver.mutations.spec.ts +++ b/src/domain/access/role-set/role.set.resolver.mutations.spec.ts @@ -163,16 +163,17 @@ describe('RoleSetResolverMutations', () => { }); describe('assignRoleToOrganization', () => { - it('should assign role to organization on SPACE roleSet', async () => { - const actorContext = { actorID: 'admin-1' } as any; - const mockRoleSet = { - id: 'rs-1', - type: RoleSetType.SPACE, - authorization: { id: 'auth-1' }, - } as any; + const spaceRoleSet = { + id: 'rs-1', + type: RoleSetType.SPACE, + entryRoleName: RoleName.MEMBER, + authorization: { id: 'auth-1' }, + } as any; + + const arrangeAssign = (alreadyInRoleSet: boolean) => { const mockOrg = { id: 'org-1' } as any; - - (roleSetService.getRoleSetOrFail as Mock).mockResolvedValue(mockRoleSet); + (roleSetService.getRoleSetOrFail as Mock).mockResolvedValue(spaceRoleSet); + (roleSetService.isInRole as Mock).mockResolvedValue(alreadyInRoleSet); (authorizationService.grantAccessOrFail as Mock).mockReturnValue( undefined ); @@ -180,16 +181,85 @@ describe('RoleSetResolverMutations', () => { ( organizationLookupService.getOrganizationByIdOrFail as Mock ).mockResolvedValue(mockOrg); + return mockOrg; + }; - const result = await resolver.assignRoleToOrganization(actorContext, { - roleSetID: 'rs-1', - actorID: 'org-1', - role: RoleName.MEMBER, - } as any); + const privilegesChecked = () => + (authorizationService.grantAccessOrFail as Mock).mock.calls.map( + call => call[2] + ); + + it('requires the assign-organization privilege to bring in a NEW organization', async () => { + const mockOrg = arrangeAssign(false); + + const result = await resolver.assignRoleToOrganization( + { actorID: 'admin-1' } as any, + { + roleSetID: 'rs-1', + actorID: 'org-1', + role: RoleName.MEMBER, + } as any + ); + + expect(result).toBe(mockOrg); + expect(privilegesChecked()).toEqual([ + AuthorizationPrivilege.ROLESET_ENTRY_ROLE_ASSIGN_ORGANIZATION, + AuthorizationPrivilege.GRANT, + ]); + }); + + it('requires GRANT alone to change the role of an organization already in the roleSet', async () => { + // R32: consent is about entering the Space, not about which role the + // organization holds once it is in. A Space admin holds GRANT but never + // holds ROLESET_ENTRY_ROLE_ASSIGN_ORGANIZATION, so requiring both here + // left every organization that accepted an invitation unmanageable. + const mockOrg = arrangeAssign(true); + + const result = await resolver.assignRoleToOrganization( + { actorID: 'space-admin-1' } as any, + { + roleSetID: 'rs-1', + actorID: 'org-1', + role: RoleName.LEAD, + } as any + ); expect(result).toBe(mockOrg); - // Should check both ROLESET_ENTRY_ROLE_ASSIGN_ORGANIZATION and GRANT - expect(authorizationService.grantAccessOrFail).toHaveBeenCalledTimes(2); + expect(privilegesChecked()).toEqual([AuthorizationPrivilege.GRANT]); + expect(roleSetService.isInRole).toHaveBeenCalledWith( + 'org-1', + spaceRoleSet, + RoleName.MEMBER + ); + }); + + it('refuses a non-organization actor BEFORE granting any credential', async () => { + // R32 relaxed this mutation to GRANT alone for an actor already holding + // the entry role, which puts it in reach of every Space admin. Nothing + // on this path asserts the actor type — `assignActorToRole` derives it + // from the DB and applies THAT type's policy — so aiming the mutation at + // a Virtual Contributor already in the Space granted it a Space role + // while skipping the SPACE_FLAG_VIRTUAL_CONTRIBUTOR_ACCESS entitlement + // that `assignRoleToVirtualContributor` enforces. The lookup used to run + // only AFTER the grant, so the credential persisted (there is no + // transaction) while the caller saw an error. + arrangeAssign(true); + ( + organizationLookupService.getOrganizationByIdOrFail as Mock + ).mockRejectedValue(new Error('Organization not found')); + + await expect( + resolver.assignRoleToOrganization( + { actorID: 'space-admin-1' } as any, + { + roleSetID: 'rs-1', + actorID: 'vc-1', + role: RoleName.LEAD, + } as any + ) + ).rejects.toThrow('Organization not found'); + + expect(roleSetService.assignActorToRole).not.toHaveBeenCalled(); }); }); diff --git a/src/domain/access/role-set/role.set.resolver.mutations.ts b/src/domain/access/role-set/role.set.resolver.mutations.ts index 5b97dd2068..7442d6411b 100644 --- a/src/domain/access/role-set/role.set.resolver.mutations.ts +++ b/src/domain/access/role-set/role.set.resolver.mutations.ts @@ -121,29 +121,40 @@ export class RoleSetResolverMutations { const roleSet = await this.roleSetService.getRoleSetOrFail( roleData.roleSetID ); - this.validateRoleSetTypeOrFail(roleSet, [RoleSetType.SPACE]); - // Check if has **both** grant + assign org privileges - this.authorizationService.grantAccessOrFail( + await this.authorizeAssignOrganization( actorContext, - roleSet.authorization, - AuthorizationPrivilege.ROLESET_ENTRY_ROLE_ASSIGN_ORGANIZATION, - `assign organization RoleSet role: ${roleSet.id}` - ); - this.authorizationService.grantAccessOrFail( - actorContext, - roleSet.authorization, - AuthorizationPrivilege.GRANT, - `assign organization RoleSet role: ${roleSet.id}` + roleSet, + roleData.actorID ); + + // Assert the actor really IS an organization BEFORE any write. This lookup + // used to run only after `assignActorToRole` had already granted the + // credential, and nothing else on this path checks the type: + // `assignActorToRole` derives the actor type from the DB and applies THAT + // type's policy, so a non-organization actorID was assigned first and + // rejected afterwards, leaving the credential granted while the caller saw + // an error (there is no transaction around the two). + // + // That mattered little while the mutation required + // ROLESET_ENTRY_ROLE_ASSIGN_ORGANIZATION (global admin / support / beta + // tester) for every call. R32 relaxed it to GRANT alone for an actor + // already holding the entry role, so any Space admin can now reach this + // path — and aiming it at a Virtual Contributor already in the Space + // granted that VC a Space role while skipping the + // SPACE_FLAG_VIRTUAL_CONTRIBUTOR_ACCESS entitlement that + // `assignRoleToVirtualContributor` enforces for exactly this operation. + const organization = + await this.organizationLookupService.getOrganizationByIdOrFail( + roleData.actorID + ); + await this.roleSetService.assignActorToRole( roleSet, roleData.role, roleData.actorID ); - return await this.organizationLookupService.getOrganizationByIdOrFail( - roleData.actorID - ); + return organization; } @Mutation(() => IVirtualContributor, { @@ -397,7 +408,11 @@ export class RoleSetResolverMutations { await this.authorizeAssignUser(actorContext, roleSet, roleData.role); break; case ActorType.ORGANIZATION: - await this.authorizeAssignOrganization(actorContext, roleSet); + await this.authorizeAssignOrganization( + actorContext, + roleSet, + roleData.actorID + ); break; case ActorType.VIRTUAL_CONTRIBUTOR: await this.authorizeAssignVirtualContributor( @@ -527,18 +542,45 @@ export class RoleSetResolverMutations { ); } + /** + * Bringing a NEW organization into a Space requires + * `ROLESET_ENTRY_ROLE_ASSIGN_ORGANIZATION` (GLOBAL_ADMIN / GLOBAL_SUPPORT / + * BETA_TESTER) plus GRANT. Changing the role of one that is ALREADY in the + * role set requires GRANT alone. + * + * The assign-organization privilege protects the organization's *consent*: a + * direct add puts an organization into a Space without ever asking it, which + * is why it stays global-only (R6). Consent is about entering the Space, not + * about which role the organization holds once it is in. Since + * workspace#061 an organization enters by accepting an invitation from a + * Space admin, and that admin must then be able to move it between Member and + * Lead and to remove it again — the same GRANT that + * `removeRoleFromOrganization` has always required, and the same authority + * they already hold over every user member. Without this split the invite + * flow ships a front door with no management surface behind it (R32). + */ private async authorizeAssignOrganization( actorContext: ActorContext, - roleSet: IRoleSet + roleSet: IRoleSet, + actorID: string ): Promise { this.validateRoleSetTypeOrFail(roleSet, [RoleSetType.SPACE]); - this.authorizationService.grantAccessOrFail( - actorContext, - roleSet.authorization, - AuthorizationPrivilege.ROLESET_ENTRY_ROLE_ASSIGN_ORGANIZATION, - `assign organization RoleSet role: ${roleSet.id}` + const alreadyInRoleSet = await this.roleSetService.isInRole( + actorID, + roleSet, + roleSet.entryRoleName ); + + if (!alreadyInRoleSet) { + this.authorizationService.grantAccessOrFail( + actorContext, + roleSet.authorization, + AuthorizationPrivilege.ROLESET_ENTRY_ROLE_ASSIGN_ORGANIZATION, + `assign organization RoleSet role: ${roleSet.id}` + ); + } + this.authorizationService.grantAccessOrFail( actorContext, roleSet.authorization, diff --git a/src/domain/access/role-set/role.set.service.events.ts b/src/domain/access/role-set/role.set.service.events.ts index 31657fa952..f25aa22c79 100644 --- a/src/domain/access/role-set/role.set.service.events.ts +++ b/src/domain/access/role-set/role.set.service.events.ts @@ -1,5 +1,6 @@ import { LogContext } from '@common/enums'; import { ActorType } from '@common/enums/actor.type'; +import { CommunityMembershipOrigin } from '@common/enums/community.membership.origin'; import { SpaceLevel } from '@common/enums/space.level'; import { RoleSetMembershipException } from '@common/exceptions/role.set.membership.exception'; import { ActorContext } from '@core/actor-context/actor.context'; @@ -44,7 +45,8 @@ export class RoleSetEventsService { roleSet: IRoleSet, actorContext: ActorContext, actorID: string, - actorType: ActorType + actorType: ActorType, + membershipOrigin: CommunityMembershipOrigin = CommunityMembershipOrigin.DIRECT ) { const community = await this.communityResolverService.getCommunityForRoleSet(roleSet.id); @@ -63,6 +65,7 @@ export class RoleSetEventsService { triggeredBy: actorContext.actorID, actorType, community, + membershipOrigin, }; await this.notificationAdapterSpace.spaceCommunityNewMember( notificationInput diff --git a/src/domain/access/role-set/role.set.service.spec.ts b/src/domain/access/role-set/role.set.service.spec.ts index c7fbbc4504..4d1cd82339 100644 --- a/src/domain/access/role-set/role.set.service.spec.ts +++ b/src/domain/access/role-set/role.set.service.spec.ts @@ -1,5 +1,6 @@ import { ActorType } from '@common/enums/actor.type'; import { AuthorizationCredential } from '@common/enums/authorization.credential'; +import { CommunityMembershipOrigin } from '@common/enums/community.membership.origin'; import { CommunityMembershipPolicy } from '@common/enums/community.membership.policy'; import { CommunityMembershipStatus } from '@common/enums/community.membership.status'; import { RoleName } from '@common/enums/role.name'; @@ -2663,7 +2664,12 @@ describe('RoleSetService', () => { RoleName.MEMBER, 'user-1', expect.anything(), - true + true, + // An approved application carries DIRECT, so the generic "a new + // member joined" still reaches the Space admins (R40): there is no + // application-approved event to replace it, and suppressing would + // leave the approving admin's co-admins told nothing at all. + CommunityMembershipOrigin.DIRECT ); }); @@ -2958,7 +2964,8 @@ describe('RoleSetService', () => { RoleName.MEMBER, 'user-1', expect.anything(), - true + true, + CommunityMembershipOrigin.DIRECT ); }); @@ -3003,6 +3010,116 @@ describe('RoleSetService', () => { ]); }); + it('(R26) suppresses the new-member notification ONLY on the invited role set — every ancestor stays DIRECT', async () => { + // The ancestors were never invited to and never applied to, so their + // admins receive no invitation-response notification. Marking them + // INVITATION too would leave them told nothing at all. + const root = spaceRoleSet('root'); + const mid = spaceRoleSet('mid'); + const target = spaceRoleSet('target'); + vi.spyOn(service, 'getRoleSetAncestorChain').mockResolvedValue([ + root, + mid, + target, + ]); + vi.spyOn(service, 'isMember').mockResolvedValue(false); + vi.spyOn(service as any, 'grantRoleCredential').mockResolvedValue( + undefined + ); + passthroughTransaction(); + const sideEffects = vi + .spyOn(service as any, 'applyRoleGrantSideEffects') + .mockResolvedValue(undefined); + + await service.ensureMemberOfRoleSetAndAncestors( + target, + 'user-1', + { actorID: 'user-1' } as any, + { source: 'invitation', invitedToParent: true } + ); + + expect( + sideEffects.mock.calls.map((c: any[]) => ({ + roleSetId: c[0].id, + origin: c[6], + })) + ).toEqual([ + { roleSetId: 'root', origin: CommunityMembershipOrigin.DIRECT }, + { roleSetId: 'mid', origin: CommunityMembershipOrigin.DIRECT }, + { + roleSetId: 'target', + origin: CommunityMembershipOrigin.INVITATION, + }, + ]); + }); + + it('(R40) leaves an approved application on DIRECT for every role set — nothing replaces the generic notification', async () => { + const root = spaceRoleSet('root'); + const target = spaceRoleSet('target'); + vi.spyOn(service, 'getRoleSetAncestorChain').mockResolvedValue([ + root, + target, + ]); + vi.spyOn(service, 'isMember').mockResolvedValue(false); + vi.spyOn( + service as any, + 'isCombinedApplicationGrantAuthorised' + ).mockResolvedValue(true); + vi.spyOn(service as any, 'grantRoleCredential').mockResolvedValue( + undefined + ); + passthroughTransaction(); + const sideEffects = vi + .spyOn(service as any, 'applyRoleGrantSideEffects') + .mockResolvedValue(undefined); + + await service.ensureMemberOfRoleSetAndAncestors( + target, + 'user-1', + { actorID: 'user-1' } as any, + { source: 'application' } + ); + + expect( + sideEffects.mock.calls.map((c: any[]) => ({ + roleSetId: c[0].id, + origin: c[6], + })) + ).toEqual([ + { roleSetId: 'root', origin: CommunityMembershipOrigin.DIRECT }, + { roleSetId: 'target', origin: CommunityMembershipOrigin.DIRECT }, + ]); + }); + + it('(R26) does not suppress for a Virtual Contributor — that actor type has no invitation-response notification', async () => { + (actorLookupService.getActorTypeByIdOrFail as Mock).mockResolvedValue( + ActorType.VIRTUAL_CONTRIBUTOR + ); + const target = spaceRoleSet('target'); + vi.spyOn(service, 'getRoleSetAncestorChain').mockResolvedValue([ + target, + ]); + vi.spyOn(service, 'isMember').mockResolvedValue(false); + vi.spyOn(service as any, 'grantRoleCredential').mockResolvedValue( + undefined + ); + passthroughTransaction(); + const sideEffects = vi + .spyOn(service as any, 'applyRoleGrantSideEffects') + .mockResolvedValue(undefined); + + await service.ensureMemberOfRoleSetAndAncestors( + target, + 'vc-1', + { actorID: 'user-1' } as any, + { source: 'invitation', invitedToParent: true } + ); + + expect(sideEffects.mock.calls[0][6]).toBe( + CommunityMembershipOrigin.DIRECT + ); + }); + it('does not touch open-application / open-invitation caches (a direct join has neither)', async () => { const target = spaceRoleSet('target'); vi.spyOn(service, 'getRoleSetAncestorChain').mockResolvedValue([ @@ -3043,6 +3160,94 @@ describe('RoleSetService', () => { }); }); + describe('getRoleSetsToJoinOnAccept', () => { + const spaceRoleSet = (id: string): IRoleSet => + ({ id, type: RoleSetType.SPACE }) as unknown as IRoleSet; + + it('returns only the target when invitedToParent is false', async () => { + const target = spaceRoleSet('target'); + const chainSpy = vi.spyOn(service, 'getRoleSetAncestorChain'); + + const result = await service.getRoleSetsToJoinOnAccept( + target, + 'actor-1', + false + ); + + expect(result).toEqual([target]); + expect(chainSpy).not.toHaveBeenCalled(); + }); + + it('returns the missing-only ancestor chain, root first, target last, when invitedToParent is true', async () => { + const root = spaceRoleSet('root'); + const mid = spaceRoleSet('mid'); + const target = spaceRoleSet('target'); + vi.spyOn(service, 'getRoleSetAncestorChain').mockResolvedValue([ + root, + mid, + target, + ]); + // Already a member of root; missing mid and target. + vi.spyOn(service, 'isMember').mockImplementation( + async (_actorID: string, rs: IRoleSet) => rs.id === 'root' + ); + + const result = await service.getRoleSetsToJoinOnAccept( + target, + 'actor-1', + true + ); + + expect(result).toEqual([mid, target]); + }); + + it('always includes the target — the invitee is never already a member of it', async () => { + const root = spaceRoleSet('root'); + const target = spaceRoleSet('target'); + vi.spyOn(service, 'getRoleSetAncestorChain').mockResolvedValue([ + root, + target, + ]); + vi.spyOn(service, 'isMember').mockResolvedValue(false); + + const result = await service.getRoleSetsToJoinOnAccept( + target, + 'actor-1', + true + ); + + expect(result).toEqual([root, target]); + }); + }); + + describe('getSpacesToJoinOnAccept', () => { + it('maps the RoleSets to join, in order, to their Spaces via one shared computation', async () => { + const target = { id: 'target', type: RoleSetType.SPACE } as IRoleSet; + const roleSetsSpy = vi + .spyOn(service, 'getRoleSetsToJoinOnAccept') + .mockResolvedValue([ + { id: 'root' } as IRoleSet, + { id: 'target' } as IRoleSet, + ]); + const communityResolverService = (service as any) + .communityResolverService; + ( + communityResolverService.getSpaceForRoleSetOrFail as Mock + ).mockImplementation(async (roleSetID: string) => ({ + id: `space-${roleSetID}`, + })); + + const result = await service.getSpacesToJoinOnAccept( + target, + 'actor-1', + true + ); + + expect(roleSetsSpy).toHaveBeenCalledWith(target, 'actor-1', true); + expect(result).toEqual([{ id: 'space-root' }, { id: 'space-target' }]); + }); + }); + // Feature 017 — the combined-flow authorisation predicate. Drives BOTH the // client-facing APPLY privilege exposure (T009) and the approval-time gate // (T013), so the setting (US2/FR-003/FR-014) and privacy (US3) govern both. diff --git a/src/domain/access/role-set/role.set.service.ts b/src/domain/access/role-set/role.set.service.ts index b320ac7f58..da9bd1437b 100644 --- a/src/domain/access/role-set/role.set.service.ts +++ b/src/domain/access/role-set/role.set.service.ts @@ -2,6 +2,7 @@ import { ActorType } from '@common/enums/actor.type'; import { AlkemioErrorStatus } from '@common/enums/alkemio.error.status'; import { AuthorizationCredential } from '@common/enums/authorization.credential'; import { AuthorizationPolicyType } from '@common/enums/authorization.policy.type'; +import { CommunityMembershipOrigin } from '@common/enums/community.membership.origin'; import { CommunityMembershipStatus } from '@common/enums/community.membership.status'; import { LicenseEntitlementDataType } from '@common/enums/license.entitlement.data.type'; import { LicenseEntitlementType } from '@common/enums/license.entitlement.type'; @@ -48,6 +49,7 @@ import { IUser } from '@domain/community/user/user.interface'; import { UserLookupService } from '@domain/community/user-lookup/user.lookup.service'; import { IVirtualContributor } from '@domain/community/virtual-contributor/virtual.contributor.interface'; import { VirtualContributorLookupService } from '@domain/community/virtual-contributor-lookup/virtual.contributor.lookup.service'; +import { ISpace } from '@domain/space/space/space.interface'; import { SpaceLookupService } from '@domain/space/space.lookup/space.lookup.service'; import { ISpaceSettings } from '@domain/space/space.settings/space.settings.interface'; import { Inject, Injectable, LoggerService } from '@nestjs/common'; @@ -720,7 +722,8 @@ export class RoleSetService { roleType: RoleName, actorID: string, actorContext?: ActorContext, - triggerNewMemberEvents = false + triggerNewMemberEvents = false, + membershipOrigin: CommunityMembershipOrigin = CommunityMembershipOrigin.DIRECT ): Promise { // 1. Get actor type without loading full entity const actorType = @@ -799,7 +802,8 @@ export class RoleSetService { actorID, actorType, actorContext, - triggerNewMemberEvents + triggerNewMemberEvents, + membershipOrigin ); return actorID; @@ -918,7 +922,8 @@ export class RoleSetService { roleSet: IRoleSet, role: RoleName, actorContext?: ActorContext, - triggerNewMemberEvents = false + triggerNewMemberEvents = false, + membershipOrigin: CommunityMembershipOrigin = CommunityMembershipOrigin.DIRECT ) { await this.roleSetCacheService.appendActorRoleCache( actorID, @@ -959,7 +964,8 @@ export class RoleSetService { roleSet, actorContext, actorID, - actorType + actorType, + membershipOrigin ); } } @@ -1974,6 +1980,44 @@ export class RoleSetService { const actorType = await this.actorLookupService.getActorTypeByIdOrFail(actorID); + // Which flow produced this membership, for the TARGET role set only. + // The Space-admin "a new member joined" notification is suppressed for + // invitations because a replacement notification is dispatched for the + // same event: every admin of the invited Space receives "X accepted / + // declined the invitation" (FR-020). A direct join has no such step, so + // it keeps the notification. + // + // ONE RULE, APPLIED UNIFORMLY (R40, restoring R31): suppress only where a + // replacement notification actually exists. Three cases therefore keep the + // generic notification, and they are the same rule three times, not three + // exceptions: + // - APPROVED APPLICATIONS. There is no application-approved event to + // replace the suppressed one — SPACE_ADMIN_COMMUNITY_APPLICATION fires + // at *submission* — so suppressing here tells the approving admin's + // co-admins nothing at all, which is a silent regression of a flow + // server#4100 does not otherwise touch. R35 had suppressed it on the + // literal reading of "no invitation OR APPLICATION step"; that sentence + // was the product email pruning a PROPOSED notification list for the + // user -> organization associates flow, and organizations cannot apply + // to a Space at all (FR-014/R9), so within this feature the clause has + // nothing to attach to. Adding the missing event is alkem-io/server#6476 + // — until it lands, applications notify the ordinary way; + // - only USER and ORGANIZATION invitees have invitation-response + // events (FR-020a/R28). A Virtual Contributor accepting produces no + // replacement, so its membership stays DIRECT and the admins are + // told the ordinary way; + // - only the invited role set is suppressed. Ancestor Spaces joined on + // the way in were never invited to, and their admins receive no + // response notification, so they keep the generic "a new member + // joined" (see the per-role-set origin passed in the grant loop + // below). + const originHasReplacementNotification = + actorType === ActorType.USER || actorType === ActorType.ORGANIZATION; + let membershipOrigin = CommunityMembershipOrigin.DIRECT; + if (opts.source === 'invitation' && originHasReplacementNotification) { + membershipOrigin = CommunityMembershipOrigin.INVITATION; + } + // Application and direct-join share the same combined-flow authorisation: // grant the ancestor chain iff every ancestor the actor would be granted // into is public + opted in (actor-relative). For application this is the @@ -2032,7 +2076,14 @@ export class RoleSetService { actorID, actorType, actorContext, - true + true, + // Only the target Space saw the invitation / application, so + // only its admins get the replacement notification. Every + // ancestor joined on the way in is a plain new membership to + // that Space's admins. + grantedRoleSet.id === targetRoleSet.id + ? membershipOrigin + : CommunityMembershipOrigin.DIRECT ); } catch (e: any) { this.logger.error( @@ -2070,7 +2121,8 @@ export class RoleSetService { RoleName.MEMBER, actorID, actorContext, - true + true, + membershipOrigin ); } @@ -2139,6 +2191,53 @@ export class RoleSetService { return chain.reverse(); } + /** + * The RoleSets a pending invitation would join on acceptance, root first + * — target always last. Read-only: mirrors, without executing, exactly + * the rule {@link ensureMemberOfRoleSetAndAncestors} applies for an + * invitation (`invitedToParent` gates ancestor granting; missing-only — + * an ancestor the actor already belongs to is skipped). Used to power + * the informed-consent artifacts (email, in-app, Invitations tab) so + * they enumerate exactly what acceptance will do. + */ + public async getRoleSetsToJoinOnAccept( + roleSet: IRoleSet, + actorID: string, + invitedToParent: boolean + ): Promise { + if (!invitedToParent) { + return [roleSet]; + } + const chain = await this.getRoleSetAncestorChain(roleSet); + const alreadyMemberFlags = await Promise.all( + chain.map(roleSetInChain => this.isMember(actorID, roleSetInChain)) + ); + return chain.filter((_roleSetInChain, index) => !alreadyMemberFlags[index]); + } + + /** + * Space-mapping wrapper over {@link getRoleSetsToJoinOnAccept} — the + * single source both the `spacesToJoinOnAccept` resolver field and the + * org-invited notification adapter call, so no second mapping exists + * anywhere in the codebase. + */ + public async getSpacesToJoinOnAccept( + roleSet: IRoleSet, + actorID: string, + invitedToParent: boolean + ): Promise { + const roleSetsToJoin = await this.getRoleSetsToJoinOnAccept( + roleSet, + actorID, + invitedToParent + ); + return await Promise.all( + roleSetsToJoin.map(roleSetToJoin => + this.communityResolverService.getSpaceForRoleSetOrFail(roleSetToJoin.id) + ) + ); + } + /** * Re-evaluates (FR-015) whether the combined Subspace-application flow is * authorised to grant the ancestor chain for the supplied target role-set. @@ -2290,7 +2389,8 @@ export class RoleSetService { actorID: string, actorType: ActorType, actorContext: ActorContext | undefined, - triggerNewMemberEvents: boolean + triggerNewMemberEvents: boolean, + membershipOrigin: CommunityMembershipOrigin = CommunityMembershipOrigin.DIRECT ): Promise { await this.roleSetCacheService.deleteOpenApplicationFromCache( actorID, @@ -2307,7 +2407,8 @@ export class RoleSetService { roleSet, roleType, actorContext, - triggerNewMemberEvents + triggerNewMemberEvents, + membershipOrigin ); if ( diff --git a/src/domain/access/role/role.service.spec.ts b/src/domain/access/role/role.service.spec.ts index 1e84b29cd7..caa51fac84 100644 --- a/src/domain/access/role/role.service.spec.ts +++ b/src/domain/access/role/role.service.spec.ts @@ -143,6 +143,50 @@ describe('RoleService', () => { expect(result.virtualContributorPolicy).toEqual(vcPolicy); }); + + // Role definitions are module-level constants shared by every caller + // (organizationRoleDefinitions, spaceCommunityRoles, ...), and + // RoleSetService.updateRoleResourceID() mutates credential.resourceID in + // place after creation. If createRole aliased the input instead of copying + // it, two entities built from the same definition would share one + // credential object and the second resourceID would overwrite the first — + // which is exactly how concurrently created organizations ended up holding + // each other's organization-admin credential. + it('should not alias the input, so a shared definition cannot leak between roles', () => { + const sharedDefinition = buildCreateRoleInput(); + + const first = service.createRole(sharedDefinition); + const second = service.createRole(sharedDefinition); + + expect(first.credential).not.toBe(second.credential); + expect(first.credential).not.toBe(sharedDefinition.credentialData); + expect(first.parentCredentials).not.toBe(second.parentCredentials); + expect(first.userPolicy).not.toBe(second.userPolicy); + expect(first.organizationPolicy).not.toBe(second.organizationPolicy); + expect(first.virtualContributorPolicy).not.toBe( + second.virtualContributorPolicy + ); + + // Simulate updateRoleResourceID on the second role only. + second.credential.resourceID = 'organization-B'; + + expect(first.credential.resourceID).toBe('resource-1'); + expect(sharedDefinition.credentialData.resourceID).toBe('resource-1'); + }); + + it('should not alias nested parent credentials', () => { + const sharedDefinition = buildCreateRoleInput(); + + const first = service.createRole(sharedDefinition); + const second = service.createRole(sharedDefinition); + + second.parentCredentials[0].resourceID = 'organization-B'; + + expect(first.parentCredentials[0].resourceID).toBe('resource-1'); + expect(sharedDefinition.parentCredentialsData[0].resourceID).toBe( + 'resource-1' + ); + }); }); describe('removeRole', () => { diff --git a/src/domain/access/role/role.service.ts b/src/domain/access/role/role.service.ts index c28fb2809c..c6dc7add9b 100644 --- a/src/domain/access/role/role.service.ts +++ b/src/domain/access/role/role.service.ts @@ -17,11 +17,22 @@ export class RoleService { public createRole(roleData: CreateRoleInput): IRole { const role = Role.create(roleData); - role.credential = roleData.credentialData; - role.parentCredentials = roleData.parentCredentialsData; - role.userPolicy = roleData.userPolicyData; - role.organizationPolicy = roleData.organizationPolicyData; - role.virtualContributorPolicy = roleData.virtualContributorPolicyData; + // Deep-copy every embedded object rather than aliasing the caller's. + // Role definitions are supplied as module-level constants + // (organizationRoleDefinitions, spaceCommunityRoles, subspaceCommunityRoles), + // so a shared reference here is shared by every concurrent creation — and + // updateRoleResourceID() then mutates credential.resourceID in place. Two + // organizations created at the same time ended up with each other's + // organization-admin resourceID, which mis-issues admin credentials and + // leaves the organization undeletable. Copying at this single seam fixes + // every caller at once. + role.credential = structuredClone(roleData.credentialData); + role.parentCredentials = structuredClone(roleData.parentCredentialsData); + role.userPolicy = structuredClone(roleData.userPolicyData); + role.organizationPolicy = structuredClone(roleData.organizationPolicyData); + role.virtualContributorPolicy = structuredClone( + roleData.virtualContributorPolicyData + ); return role; } diff --git a/src/domain/actor/actor-lookup/actor.lookup.service.spec.ts b/src/domain/actor/actor-lookup/actor.lookup.service.spec.ts index d2980cedf0..03221acf62 100644 --- a/src/domain/actor/actor-lookup/actor.lookup.service.spec.ts +++ b/src/domain/actor/actor-lookup/actor.lookup.service.spec.ts @@ -1,3 +1,4 @@ +import { ORGANIZATION_MANAGER_CREDENTIAL_TYPES } from '@common/constants/authorization'; import { ActorType } from '@common/enums/actor.type'; import { EntityNotFoundException, @@ -617,6 +618,22 @@ describe('ActorLookupService', () => { const result = await service.getActorsManagedByUser(VALID_UUID); expect(result).toEqual([user]); }); + + it('filters the organization credential lookup by exactly ORGANIZATION_MANAGER_CREDENTIAL_TYPES', async () => { + const user = { id: VALID_UUID, accountID: 'account-1' }; + + entityManager.findOne.mockResolvedValue(user); + entityManager.find + .mockResolvedValueOnce([]) // org credentials + .mockResolvedValueOnce([]); // VCs for user's account + + await service.getActorsManagedByUser(VALID_UUID); + + const [, credentialFindOptions] = entityManager.find.mock.calls[0]; + expect(credentialFindOptions.where.type.value).toEqual([ + ...ORGANIZATION_MANAGER_CREDENTIAL_TYPES, + ]); + }); }); describe('findMentionableContributors', () => { diff --git a/src/domain/actor/actor-lookup/actor.lookup.service.ts b/src/domain/actor/actor-lookup/actor.lookup.service.ts index 293059d4f8..7ae686e7dd 100644 --- a/src/domain/actor/actor-lookup/actor.lookup.service.ts +++ b/src/domain/actor/actor-lookup/actor.lookup.service.ts @@ -1,4 +1,5 @@ -import { AuthorizationCredential, LogContext } from '@common/enums'; +import { ORGANIZATION_MANAGER_CREDENTIAL_TYPES } from '@common/constants/authorization'; +import { LogContext } from '@common/enums'; import { ActorType } from '@common/enums/actor.type'; import { EntityNotFoundException, @@ -635,10 +636,7 @@ export class ActorLookupService { const orgCredentials = await this.entityManager.find(Credential, { where: { actorID: userID, - type: In([ - AuthorizationCredential.ORGANIZATION_OWNER, - AuthorizationCredential.ORGANIZATION_ADMIN, - ]), + type: In([...ORGANIZATION_MANAGER_CREDENTIAL_TYPES]), }, }); diff --git a/src/domain/community/organization-settings/dto/organization.settings.membership.dto.update.ts b/src/domain/community/organization-settings/dto/organization.settings.membership.dto.update.ts index 815e36005c..3e998b0672 100644 --- a/src/domain/community/organization-settings/dto/organization.settings.membership.dto.update.ts +++ b/src/domain/community/organization-settings/dto/organization.settings.membership.dto.update.ts @@ -1,13 +1,28 @@ import { Field, InputType } from '@nestjs/graphql'; -import { IsBoolean } from 'class-validator'; +import { IsBoolean, IsOptional } from 'class-validator'; @InputType() export class UpdateOrganizationSettingsMembershipInput { + // Optional, like its sibling below: the service already merges partially + // (it guards each key on `!== undefined`), so a required field only forced + // every caller to echo back a value it had read earlier — which makes two + // admins editing different switches a last-write-wins clobber. Relaxing + // `Boolean!` to `Boolean` is a backward-compatible schema change; callers + // that still send it behave exactly as before. @Field(() => Boolean, { - nullable: false, + nullable: true, description: 'Allow Users with email addresses matching the domain of this Organization to join.', }) @IsBoolean() - allowUsersMatchingDomainToJoin!: boolean; + @IsOptional() + allowUsersMatchingDomainToJoin?: boolean; + + @Field(() => Boolean, { + nullable: true, + description: 'Allow Spaces to invite this Organization to join them.', + }) + @IsBoolean() + @IsOptional() + allowSpaceInvitations?: boolean; } diff --git a/src/domain/community/organization-settings/organization.settings.membership.interface.ts b/src/domain/community/organization-settings/organization.settings.membership.interface.ts index 19a8d091b0..7dd08bddb9 100644 --- a/src/domain/community/organization-settings/organization.settings.membership.interface.ts +++ b/src/domain/community/organization-settings/organization.settings.membership.interface.ts @@ -8,4 +8,10 @@ export abstract class IOrganizationSettingsMembership { 'Allow Users with email addresses matching the domain of this Organization to join.', }) allowUsersMatchingDomainToJoin!: boolean; + + @Field(() => Boolean, { + nullable: false, + description: 'Allow Spaces to invite this Organization to join them.', + }) + allowSpaceInvitations!: boolean; } diff --git a/src/domain/community/organization-settings/organization.settings.service.spec.ts b/src/domain/community/organization-settings/organization.settings.service.spec.ts index 3ca55a3c10..57d27190f1 100644 --- a/src/domain/community/organization-settings/organization.settings.service.spec.ts +++ b/src/domain/community/organization-settings/organization.settings.service.spec.ts @@ -30,7 +30,10 @@ describe('OrganizationSettingsService', () => { ): IOrganizationSettings => { return { privacy: { contributionRolesPubliclyVisible: false }, - membership: { allowUsersMatchingDomainToJoin: false }, + membership: { + allowUsersMatchingDomainToJoin: false, + allowSpaceInvitations: true, + }, ...overrides, } as IOrganizationSettings; }; @@ -58,6 +61,57 @@ describe('OrganizationSettingsService', () => { expect(result.membership.allowUsersMatchingDomainToJoin).toBe(true); }); + it('should update membership.allowSpaceInvitations when provided', () => { + const settings = buildSettings(); + const updateData: UpdateOrganizationSettingsEntityInput = { + membership: { + allowUsersMatchingDomainToJoin: false, + allowSpaceInvitations: false, + }, + }; + + const result = service.updateSettings(settings, updateData); + + expect(result.membership.allowSpaceInvitations).toBe(false); + }); + + it('should leave membership.allowSpaceInvitations unchanged when undefined', () => { + const settings = buildSettings({ + membership: { + allowUsersMatchingDomainToJoin: false, + allowSpaceInvitations: false, + }, + } as any); + const updateData: UpdateOrganizationSettingsEntityInput = { + membership: { allowUsersMatchingDomainToJoin: true }, + }; + + const result = service.updateSettings(settings, updateData); + + expect(result.membership.allowSpaceInvitations).toBe(false); + expect(result.membership.allowUsersMatchingDomainToJoin).toBe(true); + }); + + it('leaves membership.allowUsersMatchingDomainToJoin unchanged when omitted', () => { + // The field is nullable in the GraphQL input precisely so a client + // editing only the OTHER switch does not have to echo this one back and + // clobber a concurrent change to it. + const settings = buildSettings({ + membership: { + allowUsersMatchingDomainToJoin: true, + allowSpaceInvitations: true, + }, + } as any); + const updateData: UpdateOrganizationSettingsEntityInput = { + membership: { allowSpaceInvitations: false }, + }; + + const result = service.updateSettings(settings, updateData); + + expect(result.membership.allowUsersMatchingDomainToJoin).toBe(true); + expect(result.membership.allowSpaceInvitations).toBe(false); + }); + it('should not change privacy when privacy update data is not provided', () => { const settings = buildSettings({ privacy: { contributionRolesPubliclyVisible: true }, diff --git a/src/domain/community/organization-settings/organization.settings.service.ts b/src/domain/community/organization-settings/organization.settings.service.ts index 5260bc05ce..491b94621d 100644 --- a/src/domain/community/organization-settings/organization.settings.service.ts +++ b/src/domain/community/organization-settings/organization.settings.service.ts @@ -13,17 +13,28 @@ export class OrganizationSettingsService { settings: IOrganizationSettings, updateData: UpdateOrganizationSettingsEntityInput ): IOrganizationSettings { + // Every field below is `Boolean` (nullable) on the input type and carries + // `@IsOptional()`, which skips validation for null as well as undefined. + // An explicit `null` therefore reaches this method unvalidated, and a bare + // `!== undefined` guard would write it into the jsonb settings column — + // permanently breaking the non-null `Boolean!` output field, so every + // later organization-settings query errors. Omission and explicit null + // both mean "leave this setting alone". if (updateData.privacy) { - if (updateData.privacy.contributionRolesPubliclyVisible !== undefined) { + if (updateData.privacy.contributionRolesPubliclyVisible != null) { settings.privacy.contributionRolesPubliclyVisible = updateData.privacy.contributionRolesPubliclyVisible; } } if (updateData.membership) { - if (updateData.membership.allowUsersMatchingDomainToJoin !== undefined) { + if (updateData.membership.allowUsersMatchingDomainToJoin != null) { settings.membership.allowUsersMatchingDomainToJoin = updateData.membership.allowUsersMatchingDomainToJoin; } + if (updateData.membership.allowSpaceInvitations != null) { + settings.membership.allowSpaceInvitations = + updateData.membership.allowSpaceInvitations; + } } return settings; } diff --git a/src/domain/community/organization/organization.entity.spec.ts b/src/domain/community/organization/organization.entity.spec.ts new file mode 100644 index 0000000000..1d5debeb37 --- /dev/null +++ b/src/domain/community/organization/organization.entity.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { Organization } from './organization.entity'; + +// The `@AfterLoad` defaulting hook must heal a `settings.membership` object +// whose row predates the "allow Spaces to invite this organization" key, +// without ever throwing and without touching a row that already carries an +// explicit value (including an explicit `false`). +describe('Organization entity — applyMembershipSettingsDefaults (@AfterLoad)', () => { + it('fills in the missing key with the mandated default (true)', () => { + const organization = new Organization(); + organization.settings = { + membership: { allowUsersMatchingDomainToJoin: false }, + privacy: { contributionRolesPubliclyVisible: true }, + } as any; + + organization.applyMembershipSettingsDefaults(); + + expect(organization.settings.membership.allowSpaceInvitations).toBe(true); + }); + + it('never overwrites an existing explicit false', () => { + const organization = new Organization(); + organization.settings = { + membership: { + allowUsersMatchingDomainToJoin: false, + allowSpaceInvitations: false, + }, + privacy: { contributionRolesPubliclyVisible: true }, + } as any; + + organization.applyMembershipSettingsDefaults(); + + expect(organization.settings.membership.allowSpaceInvitations).toBe(false); + }); + + it('never overwrites an existing explicit true', () => { + const organization = new Organization(); + organization.settings = { + membership: { + allowUsersMatchingDomainToJoin: false, + allowSpaceInvitations: true, + }, + privacy: { contributionRolesPubliclyVisible: true }, + } as any; + + organization.applyMembershipSettingsDefaults(); + + expect(organization.settings.membership.allowSpaceInvitations).toBe(true); + }); + + it('is a no-op (never throws) when settings.membership is entirely absent', () => { + const organization = new Organization(); + organization.settings = {} as any; + + expect(() => organization.applyMembershipSettingsDefaults()).not.toThrow(); + expect(organization.settings.membership).toBeUndefined(); + }); + + it('is a no-op (never throws) when settings itself is absent', () => { + const organization = new Organization(); + + expect(() => organization.applyMembershipSettingsDefaults()).not.toThrow(); + }); +}); diff --git a/src/domain/community/organization/organization.entity.ts b/src/domain/community/organization/organization.entity.ts index 639291c914..0672be06bc 100644 --- a/src/domain/community/organization/organization.entity.ts +++ b/src/domain/community/organization/organization.entity.ts @@ -5,6 +5,7 @@ import { UserGroup } from '@domain/community/user-group/user-group.entity'; import { StorageAggregator } from '@domain/storage/storage-aggregator/storage.aggregator.entity'; import { IGroupable } from '@src/common/interfaces/groupable.interface'; import { + AfterLoad, ChildEntity, Column, Generated, @@ -82,4 +83,19 @@ export class Organization extends Actor implements IOrganization, IGroupable { }) @JoinColumn() roleSet!: RoleSet; + + /** + * Defend on read for the "allow Spaces to invite this organization" + * setting. A `settings.membership` object that predates the backfill + * migration, or was inserted by an old pod during a rolling deploy, lacks + * this key — without this hook the non-null GraphQL field would surface a + * null. Runs for every entity load regardless of query path. + */ + @AfterLoad() + applyMembershipSettingsDefaults() { + if (!this.settings?.membership) { + return; + } + this.settings.membership.allowSpaceInvitations ??= true; + } } diff --git a/src/domain/community/organization/organization.service.ts b/src/domain/community/organization/organization.service.ts index ead349c305..625048da80 100644 --- a/src/domain/community/organization/organization.service.ts +++ b/src/domain/community/organization/organization.service.ts @@ -250,6 +250,7 @@ export class OrganizationService { const settings: IOrganizationSettings = { membership: { allowUsersMatchingDomainToJoin: false, + allowSpaceInvitations: true, }, privacy: { // Note: not currently used but will be near term. diff --git a/src/domain/community/user-settings/dto/user.settings.notification.organization.dto.create.ts b/src/domain/community/user-settings/dto/user.settings.notification.organization.dto.create.ts index 5425890167..02bcce40c6 100644 --- a/src/domain/community/user-settings/dto/user.settings.notification.organization.dto.create.ts +++ b/src/domain/community/user-settings/dto/user.settings.notification.organization.dto.create.ts @@ -1,6 +1,6 @@ import { Field, InputType } from '@nestjs/graphql'; import { Type } from 'class-transformer'; -import { ValidateNested } from 'class-validator'; +import { IsOptional, ValidateNested } from 'class-validator'; import { CreateUserSettingsNotificationChannelsInput } from './user.settings.notification.dto.channels.create'; @InputType() @@ -22,4 +22,14 @@ export class CreateUserSettingsNotificationOrganizationInput { @ValidateNested() @Type(() => CreateUserSettingsNotificationChannelsInput) adminMentioned!: CreateUserSettingsNotificationChannelsInput; + + @Field(() => CreateUserSettingsNotificationChannelsInput, { + nullable: true, + description: + 'Receive a notification when an organization you administer is invited to a Space', + }) + @ValidateNested() + @Type(() => CreateUserSettingsNotificationChannelsInput) + @IsOptional() + adminSpaceCommunityInvitation?: CreateUserSettingsNotificationChannelsInput; } diff --git a/src/domain/community/user-settings/dto/user.settings.notification.organization.dto.update.ts b/src/domain/community/user-settings/dto/user.settings.notification.organization.dto.update.ts index fe8f899f52..495a44fc9e 100644 --- a/src/domain/community/user-settings/dto/user.settings.notification.organization.dto.update.ts +++ b/src/domain/community/user-settings/dto/user.settings.notification.organization.dto.update.ts @@ -22,4 +22,13 @@ export class UpdateUserSettingsNotificationOrganizationInput { @ValidateNested() @Type(() => NotificationSettingInput) adminMentioned?: NotificationSettingInput; + + @Field(() => NotificationSettingInput, { + nullable: true, + description: + 'Receive a notification when an organization you administer is invited to a Space', + }) + @ValidateNested() + @Type(() => NotificationSettingInput) + adminSpaceCommunityInvitation?: NotificationSettingInput; } diff --git a/src/domain/community/user-settings/dto/user.settings.notification.space.admin.dto.create.ts b/src/domain/community/user-settings/dto/user.settings.notification.space.admin.dto.create.ts index cc6315311e..c3a224c020 100644 --- a/src/domain/community/user-settings/dto/user.settings.notification.space.admin.dto.create.ts +++ b/src/domain/community/user-settings/dto/user.settings.notification.space.admin.dto.create.ts @@ -22,6 +22,15 @@ export class CreateUserSettingsNotificationSpaceAdminInput { @Type(() => CreateUserSettingsNotificationChannelsInput) communityApplicationReceived!: CreateUserSettingsNotificationChannelsInput; + @Field(() => CreateUserSettingsNotificationChannelsInput, { + nullable: false, + description: + 'Receive a notification when someone responds to an invitation you sent (admin)', + }) + @ValidateNested() + @Type(() => CreateUserSettingsNotificationChannelsInput) + communityInvitationResponse!: CreateUserSettingsNotificationChannelsInput; + @Field(() => CreateUserSettingsNotificationChannelsInput, { nullable: false, description: diff --git a/src/domain/community/user-settings/dto/user.settings.notification.space.admin.dto.update.ts b/src/domain/community/user-settings/dto/user.settings.notification.space.admin.dto.update.ts index abc00b2f8c..b2bfe2f411 100644 --- a/src/domain/community/user-settings/dto/user.settings.notification.space.admin.dto.update.ts +++ b/src/domain/community/user-settings/dto/user.settings.notification.space.admin.dto.update.ts @@ -22,6 +22,15 @@ export class UpdateUserSettingsNotificationSpaceAdminInput { @Type(() => NotificationSettingInput) communityApplicationReceived?: NotificationSettingInput; + @Field(() => NotificationSettingInput, { + nullable: true, + description: + 'Receive a notification when someone responds to an invitation you sent (admin)', + }) + @ValidateNested() + @Type(() => NotificationSettingInput) + communityInvitationResponse?: NotificationSettingInput; + @Field(() => NotificationSettingInput, { nullable: true, description: diff --git a/src/domain/community/user-settings/user.settings.entity.spec.ts b/src/domain/community/user-settings/user.settings.entity.spec.ts index 5b76e003b3..eeb6edca7a 100644 --- a/src/domain/community/user-settings/user.settings.entity.spec.ts +++ b/src/domain/community/user-settings/user.settings.entity.spec.ts @@ -69,3 +69,132 @@ describe('UserSettings entity — applyConversationMessageNotificationDefaults ( ).not.toThrow(); }); }); + +// The `@AfterLoad` defaulting hook must heal a `notification.organization` +// object whose row predates the "organization invited to a Space" key, +// without ever throwing and without touching a row that already carries it. +describe('UserSettings entity — applyOrganizationSpaceInvitationDefaults (@AfterLoad)', () => { + const DEFAULT_CHANNELS = { email: true, inApp: true, push: true }; + + it('fills in the missing key with the mandated default', () => { + const settings = new UserSettings(); + settings.notification = { + organization: { + adminMentioned: { email: true, inApp: true, push: true }, + }, + } as any; + + settings.applyOrganizationSpaceInvitationDefaults(); + + expect( + settings.notification.organization.adminSpaceCommunityInvitation + ).toEqual(DEFAULT_CHANNELS); + }); + + it('never overwrites an existing (non-default) row', () => { + const settings = new UserSettings(); + settings.notification = { + organization: { + adminSpaceCommunityInvitation: { + email: false, + inApp: false, + push: false, + }, + }, + } as any; + + settings.applyOrganizationSpaceInvitationDefaults(); + + expect( + settings.notification.organization.adminSpaceCommunityInvitation + ).toEqual({ email: false, inApp: false, push: false }); + }); + + it('is a no-op (never throws) when notification.organization is entirely absent', () => { + const settings = new UserSettings(); + settings.notification = {} as any; + + expect(() => + settings.applyOrganizationSpaceInvitationDefaults() + ).not.toThrow(); + expect(settings.notification.organization).toBeUndefined(); + }); + + it('is a no-op (never throws) when notification itself is absent', () => { + const settings = new UserSettings(); + + expect(() => + settings.applyOrganizationSpaceInvitationDefaults() + ).not.toThrow(); + }); +}); + +/** + * 061-organization-space-invitations: `space.admin.communityInvitationResponse` + * was SPLIT OUT of `space.admin.communityNewMember`, so its `@AfterLoad` + * backstop must seed the PREDECESSOR's value — exactly as migration + * 1788600000000's `COALESCE(notification #> '{space,admin,communityNewMember}', + * default)` does — and not a flat all-on. + * + * This matters beyond one read: the hook mutates the loaded entity, so the + * value it stamps is persisted on the next save of that row. Once persisted, + * the migration's `WHERE ... IS NULL` guard can never correct it. + */ +describe('UserSettings entity — applyInvitationResponseDefaults (@AfterLoad)', () => { + it('seeds the predecessor value, so an admin who muted communityNewMember stays muted', () => { + const settings = new UserSettings(); + settings.notification = { + space: { + admin: { + communityNewMember: { email: false, inApp: false, push: false }, + }, + }, + } as any; + + settings.applyInvitationResponseDefaults(); + + expect( + settings.notification.space.admin.communityInvitationResponse + ).toEqual({ email: false, inApp: false, push: false }); + }); + + it('falls back to the mandated all-on default when the predecessor is absent too', () => { + const settings = new UserSettings(); + settings.notification = { space: { admin: {} } } as any; + + settings.applyInvitationResponseDefaults(); + + expect( + settings.notification.space.admin.communityInvitationResponse + ).toEqual({ email: true, inApp: true, push: true }); + }); + + it('never overwrites an existing row', () => { + const settings = new UserSettings(); + settings.notification = { + space: { + admin: { + communityNewMember: { email: false, inApp: false, push: false }, + communityInvitationResponse: { + email: true, + inApp: false, + push: false, + }, + }, + }, + } as any; + + settings.applyInvitationResponseDefaults(); + + expect( + settings.notification.space.admin.communityInvitationResponse + ).toEqual({ email: true, inApp: false, push: false }); + }); + + it('is a no-op (never throws) when notification.space.admin is absent', () => { + const settings = new UserSettings(); + settings.notification = {} as any; + + expect(() => settings.applyInvitationResponseDefaults()).not.toThrow(); + }); +}); diff --git a/src/domain/community/user-settings/user.settings.entity.ts b/src/domain/community/user-settings/user.settings.entity.ts index 347be4f5db..ac5d8ccceb 100644 --- a/src/domain/community/user-settings/user.settings.entity.ts +++ b/src/domain/community/user-settings/user.settings.entity.ts @@ -7,6 +7,10 @@ import { IUserSettingsDashboard } from './user.settings.dashboard.interface'; import { DESIGN_VERSION_CURRENT_DEFAULT } from './user.settings.design.version.constants'; import { IUserSettingsHomeSpace } from './user.settings.home.space.interface'; import { IUserSettings } from './user.settings.interface'; +import { + DEFAULT_INVITATION_RESPONSE_CHANNELS, + DEFAULT_ORGANIZATION_SPACE_INVITATION_CHANNELS, +} from './user.settings.notification.defaults.constants'; import { IUserSettingsNotification } from './user.settings.notification.interface'; import { IUserSettingsPrivacy } from './user.settings.privacy.interface'; @@ -116,4 +120,53 @@ export class UserSettings extends AuthorizableEntity implements IUserSettings { }; } } + + /** + * Defend on read for the "organization you administer is invited to a + * Space" notification preference. A `user_settings` row that predates the + * backfill migration or was inserted by an old pod during a rolling + * deploy lacks this key. Without this hook the non-null GraphQL field + * would surface a null and crash the recipients batch. Runs for every + * entity load regardless of query path. + */ + @AfterLoad() + applyOrganizationSpaceInvitationDefaults() { + if (!this.notification?.organization) { + return; + } + if (!this.notification.organization.adminSpaceCommunityInvitation) { + this.notification.organization.adminSpaceCommunityInvitation = { + ...DEFAULT_ORGANIZATION_SPACE_INVITATION_CHANNELS, + }; + } + } + + /** + * Defend on read for the "someone responded to an invitation you sent" + * notification preference. A `user_settings` row that predates the + * backfill migration or was inserted by an old pod during a rolling + * deploy lacks this key; without this hook the non-null GraphQL field + * would surface a null and the recipients batch would drop the outcome + * notification. Runs for every entity load regardless of query path. + * + * Seeds the row's PREDECESSOR (`communityNewMember`) before the mandated + * default, mirroring the `COALESCE` in migration 1788600000000. This row + * was split out of `communityNewMember`, so seeding a flat all-on here + * would silently re-enable, on all three channels, an event a Space admin + * had deliberately switched off — and because this hook's value is + * persisted on the next save of the entity, the migration's + * `WHERE ... IS NULL` guard could never correct it afterwards. + */ + @AfterLoad() + applyInvitationResponseDefaults() { + if (!this.notification?.space?.admin) { + return; + } + if (!this.notification.space.admin.communityInvitationResponse) { + this.notification.space.admin.communityInvitationResponse = { + ...(this.notification.space.admin.communityNewMember ?? + DEFAULT_INVITATION_RESPONSE_CHANNELS), + }; + } + } } diff --git a/src/domain/community/user-settings/user.settings.notification.defaults.constants.ts b/src/domain/community/user-settings/user.settings.notification.defaults.constants.ts new file mode 100644 index 0000000000..31c0371732 --- /dev/null +++ b/src/domain/community/user-settings/user.settings.notification.defaults.constants.ts @@ -0,0 +1,37 @@ +import { IUserSettingsNotificationChannels } from './user.settings.notification.channels.interface'; + +/** + * Mandated channel defaults for notification settings rows that were added to + * `user_settings.notification` after the column shipped. + * + * Each of these rows is defended in three places that MUST agree: + * + * 1. the backfill migration, which writes the value into existing rows; + * 2. the `@AfterLoad` hook on `UserSettings`, which fills the key in on read + * for a row that predates the backfill or was inserted by an old pod + * during a rolling deploy; + * 3. `NotificationRecipientsService`, which defends again at recipient + * resolution time. + * + * They previously carried three independent copies of the same literal, so + * changing a mandated default in one place left the other two silently + * disagreeing. Declaring them once here is what keeps (2) and (3) in step; + * the migration in (1) is a historical record and is deliberately not + * refactored to import these. + */ + +/** "An organization you administer is invited to a Space" — all channels on. */ +export const DEFAULT_ORGANIZATION_SPACE_INVITATION_CHANNELS: IUserSettingsNotificationChannels = + Object.freeze({ + email: true, + inApp: true, + push: true, + }); + +/** "Someone responded to an invitation you sent" — all channels on. */ +export const DEFAULT_INVITATION_RESPONSE_CHANNELS: IUserSettingsNotificationChannels = + Object.freeze({ + email: true, + inApp: true, + push: true, + }); diff --git a/src/domain/community/user-settings/user.settings.notification.organization.interface.ts b/src/domain/community/user-settings/user.settings.notification.organization.interface.ts index 87c44c8863..c6c424d51b 100644 --- a/src/domain/community/user-settings/user.settings.notification.organization.interface.ts +++ b/src/domain/community/user-settings/user.settings.notification.organization.interface.ts @@ -16,4 +16,11 @@ export abstract class IUserSettingsNotificationOrganization { 'Receive a notification when the organization you are admin of is mentioned', }) adminMentioned!: IUserSettingsNotificationChannels; + + @Field(() => IUserSettingsNotificationChannels, { + nullable: false, + description: + 'Receive a notification when an organization you administer is invited to a Space', + }) + adminSpaceCommunityInvitation!: IUserSettingsNotificationChannels; } diff --git a/src/domain/community/user-settings/user.settings.notification.space.admin.interface.ts b/src/domain/community/user-settings/user.settings.notification.space.admin.interface.ts index be54b9a4d6..aa34527df9 100644 --- a/src/domain/community/user-settings/user.settings.notification.space.admin.interface.ts +++ b/src/domain/community/user-settings/user.settings.notification.space.admin.interface.ts @@ -16,6 +16,13 @@ export abstract class IUserSettingsNotificationSpaceAdmin { }) communityApplicationReceived!: IUserSettingsNotificationChannels; + @Field(() => IUserSettingsNotificationChannels, { + nullable: false, + description: + 'Receive a notification when someone responds to an invitation you sent (admin)', + }) + communityInvitationResponse!: IUserSettingsNotificationChannels; + @Field(() => IUserSettingsNotificationChannels, { nullable: false, description: diff --git a/src/domain/community/user-settings/user.settings.service.spec.ts b/src/domain/community/user-settings/user.settings.service.spec.ts index 5dcc3553b8..cc2be90ddc 100644 --- a/src/domain/community/user-settings/user.settings.service.spec.ts +++ b/src/domain/community/user-settings/user.settings.service.spec.ts @@ -70,6 +70,7 @@ describe('UserSettingsService', () => { organization: { adminMentioned: defaultNotificationSetting(), adminMessageReceived: defaultNotificationSetting(), + adminSpaceCommunityInvitation: defaultNotificationSetting(), }, user: { messageReceived: defaultNotificationSetting(), @@ -643,6 +644,49 @@ describe('UserSettingsService', () => { }); }); + describe('updateSettings - notification.organization', () => { + it('should update adminSpaceCommunityInvitation notification', () => { + const settings = buildSettings(); + const updateData: UpdateUserSettingsEntityInput = { + notification: { + organization: { + adminSpaceCommunityInvitation: { email: false, push: false }, + }, + }, + }; + + const result = service.updateSettings(settings, updateData); + + expect( + result.notification.organization.adminSpaceCommunityInvitation.email + ).toBe(false); + expect( + result.notification.organization.adminSpaceCommunityInvitation.push + ).toBe(false); + expect( + result.notification.organization.adminSpaceCommunityInvitation.inApp + ).toBe(false); + }); + + it('should leave adminSpaceCommunityInvitation untouched when omitted', () => { + const settings = buildSettings(); + const updateData: UpdateUserSettingsEntityInput = { + notification: { + organization: { + adminMentioned: { email: true }, + }, + }, + }; + + const result = service.updateSettings(settings, updateData); + + expect( + result.notification.organization.adminSpaceCommunityInvitation + ).toEqual(defaultNotificationSetting()); + expect(result.notification.organization.adminMentioned.email).toBe(true); + }); + }); + describe('updateSettings - notification.space', () => { it('should update admin.communityApplicationReceived notification', () => { const settings = buildSettings(); diff --git a/src/domain/community/user-settings/user.settings.service.ts b/src/domain/community/user-settings/user.settings.service.ts index 3a2cbdf0b9..eecb19714a 100644 --- a/src/domain/community/user-settings/user.settings.service.ts +++ b/src/domain/community/user-settings/user.settings.service.ts @@ -161,6 +161,10 @@ export class UserSettingsService { settings.notification.organization.adminMessageReceived, notificationOrganizationData.adminMessageReceived ); + this.updateNotificationSetting( + settings.notification.organization.adminSpaceCommunityInvitation, + notificationOrganizationData.adminSpaceCommunityInvitation + ); } const notificationSpaceData = updateData.notification?.space; @@ -177,6 +181,10 @@ export class UserSettingsService { settings.notification.space.admin.communityNewMember, adminData.communityNewMember ); + this.updateNotificationSetting( + settings.notification.space.admin.communityInvitationResponse, + adminData.communityInvitationResponse + ); this.updateNotificationSetting( settings.notification.space.admin.communicationMessageReceived, adminData.communicationMessageReceived diff --git a/src/domain/community/user/user.service.ts b/src/domain/community/user/user.service.ts index b113b12c17..49c4aa4541 100644 --- a/src/domain/community/user/user.service.ts +++ b/src/domain/community/user/user.service.ts @@ -326,6 +326,11 @@ export class UserService { organization: { adminMessageReceived: { email: true, inApp: true, push: true }, adminMentioned: { email: true, inApp: true, push: true }, + adminSpaceCommunityInvitation: { + email: true, + inApp: true, + push: true, + }, }, platform: { forumDiscussionCreated: { email: true, inApp: false, push: false }, @@ -346,6 +351,11 @@ export class UserService { push: true, }, communityNewMember: { email: true, inApp: true, push: true }, + communityInvitationResponse: { + email: true, + inApp: true, + push: true, + }, communicationMessageReceived: { email: true, inApp: true, diff --git a/src/migrations/1788400000000-AddOrganizationAllowSpaceInvitationsSetting.ts b/src/migrations/1788400000000-AddOrganizationAllowSpaceInvitationsSetting.ts new file mode 100644 index 0000000000..63a4d9d61c --- /dev/null +++ b/src/migrations/1788400000000-AddOrganizationAllowSpaceInvitationsSetting.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Backfills the new "allow Spaces to invite this organization" setting + * (`allowSpaceInvitations`) onto every existing `organization` row's + * `settings` jsonb column, at the mandated default `true`. Modelled on + * `AddCalloutReactionNotificationSettings`: + * + * - `up`: additive-only `jsonb_set` guarded by + * `WHERE settings #> '{membership,allowSpaceInvitations}' IS NULL` — + * never touches an existing key, safely re-runnable. The inner + * `jsonb_set` additionally materializes `settings.membership` itself if + * absent. + * - `down`: intentional no-op — see the note on the method. + * + * Belt-and-braces: `Organization.applyMembershipSettingsDefaults` + * (`@AfterLoad`) and the invite guard's `?? true` read are the backstop for + * rows inserted by an old pod during a rolling deploy after this migration + * has already run. + */ +export class AddOrganizationAllowSpaceInvitationsSetting1788400000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE organization + SET settings = jsonb_set( + jsonb_set( + settings, + '{membership}'::text[], + -- Materialize membership with its OTHER declared key, not an empty + -- object: allowUsersMatchingDomainToJoin is declared non-null on + -- OrganizationSettingsMembership, and applyMembershipSettingsDefaults + -- only ever fills allowSpaceInvitations. Seeding an empty object here + -- would persist a membership object the entity's own type says cannot + -- exist. false is what organization.service.ts writes at creation. + COALESCE( + settings -> 'membership', + '{"allowUsersMatchingDomainToJoin": false}'::jsonb + ), + true + ), + '{membership,allowSpaceInvitations}'::text[], + 'true'::jsonb, + true + ) + WHERE settings #> '{membership,allowSpaceInvitations}' IS NULL + `); + } + + // No automatic rollback. Stripping the key is not the inverse of seeding it: + // `up` writes the constant `true` wherever the key is absent, so a + // down-then-up cycle silently re-enables Space invitations for every + // organization that had deliberately opted OUT — reversing a recorded + // consent decision, which SC-007 forbids ("no existing user's recorded + // choice is silently overridden"). The key is additive and inert to older + // code, so leaving it costs nothing on a rollback. Operators who must truly + // revert should restore a pre-migration backup. + public async down(_queryRunner: QueryRunner): Promise { + // Intentional no-op. See note above. + } +} diff --git a/src/migrations/1788500000000-AddOrganizationSpaceInvitationNotificationSettings.ts b/src/migrations/1788500000000-AddOrganizationSpaceInvitationNotificationSettings.ts new file mode 100644 index 0000000000..8705017f36 --- /dev/null +++ b/src/migrations/1788500000000-AddOrganizationSpaceInvitationNotificationSettings.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Backfills the new "an organization you administer is invited to a Space" + * notification row (`adminSpaceCommunityInvitation`) onto every existing + * `user_settings` row, at the mandated defaults + * `{ email: true, inApp: true, push: true }`. Modelled on + * `AddCalloutReactionNotificationSettings`: + * + * - `up`: additive-only `jsonb_set` guarded by + * `WHERE notification #> '{organization,adminSpaceCommunityInvitation}' IS NULL` + * — never touches an existing key, safely re-runnable. The inner + * `jsonb_set` additionally materializes `notification.organization` + * itself if absent. + * - `down`: intentional no-op — see the note on the method. + * + * Belt-and-braces: `UserSettings.applyOrganizationSpaceInvitationDefaults` + * (`@AfterLoad`) and the recipients-service + * `DEFAULT_ORGANIZATION_SPACE_INVITATION_CHANNELS` fallback are the + * read-side backstop for rows inserted by an old pod during a rolling + * deploy after this migration has already run. + */ +export class AddOrganizationSpaceInvitationNotificationSettings1788500000000 + implements MigrationInterface +{ + private static readonly DEFAULT_VALUE = JSON.stringify({ + email: true, + inApp: true, + push: true, + }); + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + ` + UPDATE user_settings + SET notification = jsonb_set( + jsonb_set( + notification, + '{organization}'::text[], + COALESCE(notification -> 'organization', '{}'::jsonb), + true + ), + '{organization,adminSpaceCommunityInvitation}'::text[], + $1::jsonb, + true + ) + WHERE notification #> '{organization,adminSpaceCommunityInvitation}' IS NULL + `, + [ + AddOrganizationSpaceInvitationNotificationSettings1788500000000 + .DEFAULT_VALUE, + ] + ); + } + + // No automatic rollback. Stripping the key is not the inverse of seeding it: + // `up` writes the all-on default wherever the key is absent, so a + // down-then-up cycle silently re-enables, on every channel, a notification + // that an organization admin had switched off — exactly the silent override + // of a recorded choice SC-007 forbids. The key is additive and inert to + // older code, so leaving it costs nothing on a rollback. Operators who must + // truly revert should restore a pre-migration backup. + public async down(_queryRunner: QueryRunner): Promise { + // Intentional no-op. See note above. + } +} diff --git a/src/migrations/1788600000000-AddSpaceAdminInvitationResponseNotificationSetting.ts b/src/migrations/1788600000000-AddSpaceAdminInvitationResponseNotificationSetting.ts new file mode 100644 index 0000000000..647f3632d0 --- /dev/null +++ b/src/migrations/1788600000000-AddSpaceAdminInvitationResponseNotificationSetting.ts @@ -0,0 +1,92 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Backfills the new "someone responded to an invitation" notification row + * (`space.admin.communityInvitationResponse`) onto every existing + * `user_settings` row. + * + * Before this row existed, the invitation-response notifications this row + * now governs (organization and user, accept and decline, plus the + * pre-existing Virtual-Contributor declined event) were governed by + * `space.admin.communityNewMember` — which also governs the generic + * "a new member joined" notification. Splitting them gives invitation + * responses their own control, as required by the product decision that a + * response to an invitation is a distinct event from someone joining + * unprompted. + * + * The seeded value is therefore the row's PREDECESSOR — the user's existing + * `space.admin.communityNewMember` value — falling back to the mandated + * defaults `{ email: true, inApp: true, push: true }` when that key is + * absent. Seeding a flat all-on would silently re-enable, on all three + * channels, an event that a Space admin who muted `communityNewMember` had + * deliberately switched off. A user who never changed the predecessor is + * already all-on, so they get the documented default either way. + * + * Same shape as `AddOrganizationSpaceInvitationNotificationSettings`: + * + * - `up`: additive-only `jsonb_set` guarded by + * `WHERE notification #> '{space,admin,communityInvitationResponse}' IS NULL` + * — never touches an existing key, safely re-runnable. The nested + * `jsonb_set` calls materialize `notification.space` and + * `notification.space.admin` if either is absent. + * - `down`: intentional no-op — see the note on the method. + * + * Belt-and-braces: `UserSettings.applyInvitationResponseDefaults` + * (`@AfterLoad`) and the recipients-service + * `DEFAULT_INVITATION_RESPONSE_CHANNELS` fallback are the read-side + * backstop for rows inserted by an old pod during a rolling deploy after + * this migration has already run. + */ +export class AddSpaceAdminInvitationResponseNotificationSetting1788600000000 + implements MigrationInterface +{ + private static readonly DEFAULT_VALUE = JSON.stringify({ + email: true, + inApp: true, + push: true, + }); + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + ` + UPDATE user_settings + SET notification = jsonb_set( + jsonb_set( + jsonb_set( + notification, + '{space}'::text[], + COALESCE(notification -> 'space', '{}'::jsonb), + true + ), + '{space,admin}'::text[], + COALESCE(notification #> '{space,admin}', '{}'::jsonb), + true + ), + '{space,admin,communityInvitationResponse}'::text[], + COALESCE( + notification #> '{space,admin,communityNewMember}', + $1::jsonb + ), + true + ) + WHERE notification #> '{space,admin,communityInvitationResponse}' IS NULL + `, + [ + AddSpaceAdminInvitationResponseNotificationSetting1788600000000.DEFAULT_VALUE, + ] + ); + } + + // No automatic rollback, and this one is the sharpest of the three: `up` + // DERIVES the seeded value from `communityNewMember`. Stripping the key on + // `down` therefore discards whatever the admin has since chosen for + // invitation responses, and the next `up` re-derives it from a predecessor + // they may have set differently — silently overriding a recorded choice, + // which SC-007 forbids. The key is additive and inert to older code (the + // `@AfterLoad` backstop and DEFAULT_INVITATION_RESPONSE_CHANNELS handle its + // absence, never its presence), so leaving it costs nothing on a rollback. + // Operators who must truly revert should restore a pre-migration backup. + public async down(_queryRunner: QueryRunner): Promise { + // Intentional no-op. See note above. + } +} diff --git a/src/platform/in-app-notification-payload/dto/space/notification.in.app.payload.space.community.invitation.ts b/src/platform/in-app-notification-payload/dto/space/notification.in.app.payload.space.community.invitation.ts index 035e39796e..3dc8ae9012 100644 --- a/src/platform/in-app-notification-payload/dto/space/notification.in.app.payload.space.community.invitation.ts +++ b/src/platform/in-app-notification-payload/dto/space/notification.in.app.payload.space.community.invitation.ts @@ -9,4 +9,7 @@ import { InAppNotificationPayloadSpaceBase } from './notification.in.app.payload export abstract class InAppNotificationPayloadSpaceCommunityInvitation extends InAppNotificationPayloadSpaceBase { invitationID!: string; declare type: NotificationEventPayload.SPACE_COMMUNITY_INVITATION; + // Set only for the organization-invited event, so the in-app item can + // name the invited organization and link to its Invitations tab. + organizationID?: string; } diff --git a/src/platform/in-app-notification-payload/field-resolvers/space/in.app.notification.payload.space.community.invitation.resolver.fields.spec.ts b/src/platform/in-app-notification-payload/field-resolvers/space/in.app.notification.payload.space.community.invitation.resolver.fields.spec.ts new file mode 100644 index 0000000000..11bc94cd72 --- /dev/null +++ b/src/platform/in-app-notification-payload/field-resolvers/space/in.app.notification.payload.space.community.invitation.resolver.fields.spec.ts @@ -0,0 +1,76 @@ +import { vi } from 'vitest'; +import { InAppNotificationPayloadSpaceCommunityInvitationResolverFields } from './in.app.notification.payload.space.community.invitation.resolver.fields'; + +describe('InAppNotificationPayloadSpaceCommunityInvitationResolverFields', () => { + const resolver = + new InAppNotificationPayloadSpaceCommunityInvitationResolverFields(); + + const makeLoader = (returnValue: unknown) => ({ + load: vi.fn().mockResolvedValue(returnValue), + }); + + describe('space', () => { + it('loads the Space by spaceID', async () => { + const loader = makeLoader({ id: 'space-1' }); + + const result = await resolver.space( + { spaceID: 'space-1' } as any, + loader as any + ); + + expect(loader.load).toHaveBeenCalledWith('space-1'); + expect(result).toEqual({ id: 'space-1' }); + }); + }); + + describe('organization', () => { + it('returns null without calling the loader when organizationID is absent (the user-invite event)', async () => { + const loader = makeLoader({ id: 'org-1' }); + + const result = await resolver.organization( + { spaceID: 'space-1' } as any, + loader as any + ); + + expect(result).toBeNull(); + expect(loader.load).not.toHaveBeenCalled(); + }); + + it('loads the Organization by organizationID when present (the org-invite event)', async () => { + const loader = makeLoader({ id: 'org-1' }); + + const result = await resolver.organization( + { spaceID: 'space-1', organizationID: 'org-1' } as any, + loader as any + ); + + expect(loader.load).toHaveBeenCalledWith('org-1'); + expect(result).toEqual({ id: 'org-1' }); + }); + }); + + describe('invitation', () => { + it('loads the Invitation by invitationID', async () => { + const loader = makeLoader({ id: 'inv-1' }); + + const result = await resolver.invitation( + { invitationID: 'inv-1' } as any, + loader as any + ); + + expect(loader.load).toHaveBeenCalledWith('inv-1'); + expect(result).toEqual({ id: 'inv-1' }); + }); + + it('returns null when the invitation cannot be found (resolveToNull)', async () => { + const loader = makeLoader(null); + + const result = await resolver.invitation( + { invitationID: 'missing' } as any, + loader as any + ); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/src/platform/in-app-notification-payload/field-resolvers/space/in.app.notification.payload.space.community.invitation.resolver.fields.ts b/src/platform/in-app-notification-payload/field-resolvers/space/in.app.notification.payload.space.community.invitation.resolver.fields.ts index 002659e2de..cd93e1f27b 100644 --- a/src/platform/in-app-notification-payload/field-resolvers/space/in.app.notification.payload.space.community.invitation.resolver.fields.ts +++ b/src/platform/in-app-notification-payload/field-resolvers/space/in.app.notification.payload.space.community.invitation.resolver.fields.ts @@ -1,6 +1,10 @@ +import { OrganizationLoaderCreator } from '@core/dataloader/creators'; +import { InvitationLoaderCreator } from '@core/dataloader/creators/loader.creators/in-app-notification/invitation.loader.creator'; import { SpaceLoaderCreator } from '@core/dataloader/creators/loader.creators/in-app-notification/space.loader.creator'; import { Loader } from '@core/dataloader/decorators'; import { ILoader } from '@core/dataloader/loader.interface'; +import { IInvitation } from '@domain/access/invitation'; +import { IOrganization } from '@domain/community/organization'; import { ISpace } from '@domain/space/space/space.interface'; import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { InAppNotificationPayloadSpaceCommunityInvitation } from '@platform/in-app-notification-payload/dto/space/notification.in.app.payload.space.community.invitation'; @@ -18,4 +22,33 @@ export class InAppNotificationPayloadSpaceCommunityInvitationResolverFields { ): Promise { return loader.load(payload.spaceID); } + + @ResolveField(() => IOrganization, { + nullable: true, + description: + 'The organization the invitation is for, when the invitee is an organization.', + }) + public async organization( + @Parent() payload: InAppNotificationPayloadSpaceCommunityInvitation, + @Loader(OrganizationLoaderCreator, { resolveToNull: true }) + loader: ILoader + ): Promise { + if (!payload.organizationID) { + return null; + } + return loader.load(payload.organizationID); + } + + @ResolveField(() => IInvitation, { + nullable: true, + description: + 'The underlying invitation — role(s) offered, whether the parent Space is also joined, and the Spaces that will be joined on acceptance.', + }) + public async invitation( + @Parent() payload: InAppNotificationPayloadSpaceCommunityInvitation, + @Loader(InvitationLoaderCreator, { resolveToNull: true }) + loader: ILoader + ): Promise { + return loader.load(payload.invitationID); + } } diff --git a/src/platform/in-app-notification/in.app.notification.service.spec.ts b/src/platform/in-app-notification/in.app.notification.service.spec.ts index d68c337a4a..6a11d7755f 100644 --- a/src/platform/in-app-notification/in.app.notification.service.spec.ts +++ b/src/platform/in-app-notification/in.app.notification.service.spec.ts @@ -258,6 +258,55 @@ describe('InAppNotificationService', () => { expect(result.contributorActorId).toBe('vc-actor'); }); + it('should extract spaceID, invitationID and organizationID for ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION', () => { + const payload = { + spaceID: 'space-1', + invitationID: 'inv-1', + organizationID: 'org-1', + }; + notificationRepo.create!.mockImplementation((input: any) => input); + + const result = service.createInAppNotification({ + type: NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + category: 'organization' as any, + triggeredByID: 'user-1', + triggeredAt: new Date(), + receiverID: 'user-2', + payload: payload as any, + }); + + expect(result.spaceID).toBe('space-1'); + expect(result.invitationID).toBe('inv-1'); + expect(result.organizationID).toBe('org-1'); + }); + + it.each([ + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED, + ])('should extract spaceID and contributorActorId — never organizationID — for %s', type => { + const payload = { spaceID: 'space-1', actorID: 'org-1' }; + notificationRepo.create!.mockImplementation((input: any) => input); + + const result = service.createInAppNotification({ + type, + category: 'admin' as any, + triggeredByID: 'user-1', + triggeredAt: new Date(), + receiverID: 'user-2', + payload: payload as any, + }); + + expect(result.spaceID).toBe('space-1'); + // These are SPACE-admin rows about an organization, so they use the + // Actor FK like their USER/VC siblings. `organizationID` marks a row as + // belonging to that organization's OWN feed, and + // `deleteAllForReceiverInOrganization` wipes those when a user stops + // being an associate — which would take this unrelated Space-admin row + // with it. + expect(result.contributorActorId).toBe('org-1'); + expect(result.organizationID).toBeUndefined(); + }); + it('should extract spaceID for SPACE_LEAD_COMMUNICATION_MESSAGE', () => { const payload = { spaceID: 'space-1' }; notificationRepo.create!.mockImplementation((input: any) => input); diff --git a/src/platform/in-app-notification/in.app.notification.service.ts b/src/platform/in-app-notification/in.app.notification.service.ts index 03d25902f0..be90587122 100644 --- a/src/platform/in-app-notification/in.app.notification.service.ts +++ b/src/platform/in-app-notification/in.app.notification.service.ts @@ -445,6 +445,23 @@ export class InAppNotificationService { ).organizationID; break; + case NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION: { + const typedPayload = + payload as InAppNotificationPayloadSpaceCommunityInvitation; + result.spaceID = typedPayload.spaceID; + result.invitationID = typedPayload.invitationID; + result.organizationID = typedPayload.organizationID; + break; + } + + case NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED: { + const typedPayload = + payload as InAppNotificationPayloadSpaceCommunityActor; + result.spaceID = typedPayload.spaceID; + result.organizationID = typedPayload.actorID; + break; + } + // ======================================== // SPACE NOTIFICATIONS // ======================================== @@ -484,6 +501,36 @@ export class InAppNotificationService { ).actorID; break; + case NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED: + case NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED: { + const typedPayload = + payload as InAppNotificationPayloadSpaceCommunityActor; + result.spaceID = typedPayload.spaceID; + // `contributorActorId`, NOT `organizationID` — matching the USER and + // Virtual-Contributor siblings below. These are SPACE-admin + // notifications about an organization; `organizationID` means "this + // notification belongs to that organization's own feed", and + // `removeActorFromRole` uses it to wipe a user's notifications when + // they stop being an ASSOCIATE of that organization + // (deleteAllForReceiverInOrganization). A Space admin who also happens + // to be an associate of the invited organization would then lose this + // Space-admin row the moment they left the organization — two + // unrelated memberships, one delete. The Actor FK still cascades when + // the organization itself is deleted, because an Organization IS an + // Actor. + result.contributorActorId = typedPayload.actorID; + break; + } + + case NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED: + case NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED: { + const typedPayload = + payload as InAppNotificationPayloadSpaceCommunityActor; + result.spaceID = typedPayload.spaceID; + result.contributorActorId = typedPayload.actorID; + break; + } + case NotificationEvent.SPACE_LEAD_COMMUNICATION_MESSAGE: result.spaceID = ( payload as InAppNotificationPayloadSpaceCommunicationMessageDirect diff --git a/src/platform/platform/platform.service.authorization.ts b/src/platform/platform/platform.service.authorization.ts index 48ddd59add..73f9a34ec3 100644 --- a/src/platform/platform/platform.service.authorization.ts +++ b/src/platform/platform/platform.service.authorization.ts @@ -344,7 +344,11 @@ export class PlatformAuthorizationService { platformAdminNotifications.cascade = false; credentialRules.push(platformAdminNotifications); - // Allow organization admins to access organization admin notification settings + // Allow organization admins to access organization admin notification + // settings. ADMIN only, matching who actually receives the organization + // notifications (ORGANIZATION_NOTIFICATION_CREDENTIAL_TYPES) — an owner + // who is not also an admin receives none of them, so there is nothing for + // them to switch off here. const receiveNotificationsOrganizationAdmin = this.authorizationPolicyService.createCredentialRuleUsingTypesOnly( [AuthorizationPrivilege.RECEIVE_NOTIFICATIONS_ORGANIZATION_ADMIN], diff --git a/src/services/adapters/notification-adapter/dto/organization/notification.dto.input.organization.space.community.invitation.ts b/src/services/adapters/notification-adapter/dto/organization/notification.dto.input.organization.space.community.invitation.ts new file mode 100644 index 0000000000..dd101def46 --- /dev/null +++ b/src/services/adapters/notification-adapter/dto/organization/notification.dto.input.organization.space.community.invitation.ts @@ -0,0 +1,14 @@ +import { RoleName } from '@common/enums/role.name'; +import { ICommunity } from '@domain/community/community/community.interface'; +import { NotificationInputBase } from '../notification.dto.input.base'; + +export interface NotificationInputOrganizationSpaceCommunityInvitation + extends NotificationInputBase { + community: ICommunity; + invitationID: string; + invitedContributorID: string; + welcomeMessage?: string; + extraRoles: RoleName[]; + invitedToParent: boolean; + organizationHasNoAdministrators: boolean; +} diff --git a/src/services/adapters/notification-adapter/dto/organization/notification.dto.input.organization.space.community.joined.ts b/src/services/adapters/notification-adapter/dto/organization/notification.dto.input.organization.space.community.joined.ts new file mode 100644 index 0000000000..707d92cc10 --- /dev/null +++ b/src/services/adapters/notification-adapter/dto/organization/notification.dto.input.organization.space.community.joined.ts @@ -0,0 +1,17 @@ +import { NotificationInputBase } from '../notification.dto.input.base'; + +/** + * An organization has become a member of a Space by accepting an + * invitation. Notifies every ADMIN of the organization EXCEPT the one + * who accepted (R33) — the welcome exists to tell *the others* that the + * invitation is answered and no further action is needed, so telling the + * acceptor they accepted informs nobody. `withoutAcceptor` in + * `notification.organization.adapter.ts` applies that filter to all + * three channels; where the acceptor is the organization's only admin + * the event resolves to no recipients and is not sent. + */ +export interface NotificationInputOrganizationSpaceCommunityJoined + extends NotificationInputBase { + organizationID: string; + spaceID: string; +} diff --git a/src/services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.invitation.outcome.ts b/src/services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.invitation.outcome.ts new file mode 100644 index 0000000000..6986fd59cc --- /dev/null +++ b/src/services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.invitation.outcome.ts @@ -0,0 +1,20 @@ +import { NotificationInputBase } from '../notification.dto.input.base'; + +/** + * A response to a Space community invitation — accepted or declined — as + * seen from the Space side. Actor-agnostic: `invitedActorID` is the + * organization or user whose invitation was answered. + * + * `invitationCreatedBy` is provenance only — it records who sent the + * invitation and does NOT scope the recipients. The event goes to every + * admin of the Space (see + * `NotificationSpaceAdapter.spaceAdminInvitationOutcome`), which is what + * keeps it deliverable when the inviter has since been deleted or demoted, + * and is why the field may legitimately be `''`. + */ +export interface NotificationInputSpaceCommunityInvitationOutcome + extends NotificationInputBase { + invitedActorID: string; + spaceID: string; + invitationCreatedBy: string; // The user who created/sent the invitation +} diff --git a/src/services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.new.member.ts b/src/services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.new.member.ts index b579bd1efe..a7c9b563b4 100644 --- a/src/services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.new.member.ts +++ b/src/services/adapters/notification-adapter/dto/space/notification.dto.input.space.community.new.member.ts @@ -1,4 +1,5 @@ import { ActorType } from '@common/enums/actor.type'; +import { CommunityMembershipOrigin } from '@common/enums/community.membership.origin'; import { ICommunity } from '@domain/community/community/community.interface'; import { NotificationInputBase } from '../notification.dto.input.base'; @@ -7,4 +8,11 @@ export interface NotificationInputCommunityNewMember actorID: string; actorType: ActorType; community: ICommunity; + /** + * How the membership came about. The member-side "welcome to the Space" + * notification always fires; the Space-admin "a new member joined" + * notification is suppressed for anything other than DIRECT, because the + * brief scopes it to memberships with no invitation or application step. + */ + membershipOrigin?: CommunityMembershipOrigin; } diff --git a/src/services/adapters/notification-adapter/notification.adapter.module.ts b/src/services/adapters/notification-adapter/notification.adapter.module.ts index a789c21e06..25e7377ca6 100644 --- a/src/services/adapters/notification-adapter/notification.adapter.module.ts +++ b/src/services/adapters/notification-adapter/notification.adapter.module.ts @@ -1,9 +1,10 @@ +import { RoleSetModule } from '@domain/access/role-set/role.set.module'; import { ActorLookupModule } from '@domain/actor/actor-lookup/actor.lookup.module'; import { CalloutLookupModule } from '@domain/collaboration/callout/callout.lookup/callout.lookup.module'; import { MessageDetailsModule } from '@domain/communication/message.details/message.details.module'; import { UserLookupModule } from '@domain/community/user-lookup/user.lookup.module'; import { SpaceLookupModule } from '@domain/space/space.lookup/space.lookup.module'; -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { NotificationRecipientsModule } from '@services/api/notification-recipients/notification.recipients.module'; import { EntityResolverModule } from '@services/infrastructure/entity-resolver/entity.resolver.module'; import { MessagingRedisModule } from '@services/infrastructure/redis-client/messaging-redis.module'; @@ -35,6 +36,7 @@ import { NotificationVirtualContributorAdapter } from './notification.virtual.co SpaceLookupModule, UserLookupModule, CalloutLookupModule, + forwardRef(() => RoleSetModule), ], providers: [ NotificationAdapter, diff --git a/src/services/adapters/notification-adapter/notification.organization.adapter.spec.ts b/src/services/adapters/notification-adapter/notification.organization.adapter.spec.ts index 588565eb3b..6184bbb88a 100644 --- a/src/services/adapters/notification-adapter/notification.organization.adapter.spec.ts +++ b/src/services/adapters/notification-adapter/notification.organization.adapter.spec.ts @@ -1,10 +1,17 @@ import { NotificationEvent } from '@common/enums/notification.event'; +import { RoleSetService } from '@domain/access/role-set/role.set.service'; +import { ActorLookupService } from '@domain/actor/actor-lookup/actor.lookup.service'; import { MessageDetailsService } from '@domain/communication/message.details/message.details.service'; +import { SpaceLookupService } from '@domain/space/space.lookup/space.lookup.service'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; +import { CommunityResolverService } from '@services/infrastructure/entity-resolver/community.resolver.service'; +import { UrlGeneratorService } from '@services/infrastructure/url-generator/url.generator.service'; import { defaultMockerFactory } from '@test/utils/default.mocker.factory'; import { vi } from 'vitest'; import { NotificationExternalAdapter } from '../notification-external-adapter/notification.external.adapter'; import { NotificationInAppAdapter } from '../notification-in-app-adapter/notification.in.app.adapter'; +import { NotificationPushAdapter } from '../notification-push-adapter/notification.push.adapter'; import { NotificationAdapter } from './notification.adapter'; import { NotificationOrganizationAdapter } from './notification.organization.adapter'; @@ -13,7 +20,14 @@ describe('NotificationOrganizationAdapter', () => { let notificationAdapter: NotificationAdapter; let externalAdapter: NotificationExternalAdapter; let inAppAdapter: NotificationInAppAdapter; + let pushAdapter: NotificationPushAdapter; let messageDetailsService: MessageDetailsService; + let communityResolverService: CommunityResolverService; + let roleSetService: RoleSetService; + let actorLookupService: ActorLookupService; + let urlGeneratorService: UrlGeneratorService; + let configService: ConfigService; + let spaceLookupService: SpaceLookupService; beforeEach(async () => { vi.restoreAllMocks(); @@ -34,9 +48,18 @@ describe('NotificationOrganizationAdapter', () => { inAppAdapter = module.get( NotificationInAppAdapter ); + pushAdapter = module.get(NotificationPushAdapter); messageDetailsService = module.get( MessageDetailsService ); + communityResolverService = module.get( + CommunityResolverService + ); + roleSetService = module.get(RoleSetService); + actorLookupService = module.get(ActorLookupService); + urlGeneratorService = module.get(UrlGeneratorService); + configService = module.get(ConfigService); + spaceLookupService = module.get(SpaceLookupService); }); it('should be defined', () => { @@ -179,4 +202,400 @@ describe('NotificationOrganizationAdapter', () => { ).not.toHaveBeenCalled(); }); }); + + // These two flows are PRE-EXISTING and have nothing to do with feature 061. + // They are asserted here because 061 deleted their push self-exclusion while + // applying R34 (which is about the INVITATION dispatch only) and nothing + // caught it: an organization admin who mentioned their own organization, or + // sent it a message, started getting a push about their own action. R34's + // "an invitation is a call to action" reasoning does not transfer to an FYI. + describe('pre-existing flows keep excluding the actor from push (regression guard)', () => { + const mockRecipientsWithPush = (ids: string[]) => + vi + .mocked(notificationAdapter.getNotificationRecipients) + .mockResolvedValue({ + emailRecipients: [], + inAppRecipients: [], + pushRecipients: ids.map(id => ({ id })), + } as any); + + it('organizationMention: the actor who mentioned the organization gets no push', async () => { + mockRecipientsWithPush(['mentioner-1', 'other-admin']); + + await adapter.organizationMention({ + triggeredBy: 'mentioner-1', + organizationID: 'org-1', + roomID: 'room-1', + messageID: 'msg-1', + } as any); + + expect(pushAdapter.sendPushNotifications).toHaveBeenCalledWith( + [{ id: 'other-admin' }], + NotificationEvent.ORGANIZATION_ADMIN_MENTIONED, + expect.anything() + ); + }); + + it('organizationMention: sends no push at all when the actor is the only recipient', async () => { + mockRecipientsWithPush(['mentioner-1']); + + await adapter.organizationMention({ + triggeredBy: 'mentioner-1', + organizationID: 'org-1', + roomID: 'room-1', + messageID: 'msg-1', + } as any); + + expect(pushAdapter.sendPushNotifications).not.toHaveBeenCalledWith( + expect.anything(), + NotificationEvent.ORGANIZATION_ADMIN_MENTIONED, + expect.anything() + ); + }); + + it('organizationSendMessage: the sender gets no admin push (they get the sender event instead)', async () => { + mockRecipientsWithPush(['sender-1', 'other-admin']); + + await adapter.organizationSendMessage({ + triggeredBy: 'sender-1', + organizationID: 'org-1', + message: 'hello', + } as any); + + expect(pushAdapter.sendPushNotifications).toHaveBeenCalledWith( + [{ id: 'other-admin' }], + NotificationEvent.ORGANIZATION_ADMIN_MESSAGE, + expect.anything() + ); + }); + }); + + describe('organizationSpaceCommunityInvitationCreated', () => { + const baseEventData = { + triggeredBy: 'inviter-1', + community: { id: 'community-1' } as any, + invitationID: 'inv-1', + invitedContributorID: 'org-1', + welcomeMessage: 'Welcome!', + extraRoles: [], + invitedToParent: false, + organizationHasNoAdministrators: false, + }; + + const setUpCommonMocks = () => { + vi.mocked( + communityResolverService.getSpaceForCommunityOrFail + ).mockResolvedValue({ + id: 'space-1', + about: { profile: { displayName: 'My Space' } }, + } as any); + vi.mocked( + communityResolverService.getRoleSetIdForSpace + ).mockResolvedValue('rs-1'); + vi.mocked(roleSetService.getSpacesToJoinOnAccept).mockResolvedValue([ + { id: 'space-1', about: { profile: { displayName: 'My Space' } } }, + ] as any); + }; + + it('zero-admin escalation: sends exactly one external notification with recipientEmail and empty recipients, no in-app, no push', async () => { + setUpCommonMocks(); + vi.mocked(configService.get).mockReturnValue('support@alkem.io'); + vi.mocked( + externalAdapter.buildOrganizationSpaceCommunityInvitationPayload + ).mockResolvedValue({ recipientEmail: 'support@alkem.io' } as any); + + await adapter.organizationSpaceCommunityInvitationCreated({ + ...baseEventData, + organizationHasNoAdministrators: true, + } as any); + + expect( + externalAdapter.buildOrganizationSpaceCommunityInvitationPayload + ).toHaveBeenCalledWith( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + 'inviter-1', + [], + 'org-1', + expect.objectContaining({ id: 'space-1' }), + expect.any(Array), + [], + 'Welcome!', + 'support@alkem.io' + ); + expect(externalAdapter.sendExternalNotifications).toHaveBeenCalledTimes( + 1 + ); + expect( + notificationAdapter.getNotificationRecipients + ).not.toHaveBeenCalled(); + expect(inAppAdapter.sendInAppNotifications).not.toHaveBeenCalled(); + expect(pushAdapter.sendPushNotifications).not.toHaveBeenCalled(); + }); + + it('normal path: sends email, in-app and push to the resolved recipients', async () => { + setUpCommonMocks(); + vi.mocked( + notificationAdapter.getNotificationRecipients + ).mockResolvedValue({ + emailRecipients: [{ id: 'admin-1' }], + inAppRecipients: [{ id: 'admin-1' }], + pushRecipients: [{ id: 'admin-1' }], + } as any); + vi.mocked( + externalAdapter.buildOrganizationSpaceCommunityInvitationPayload + ).mockResolvedValue({} as any); + vi.mocked(actorLookupService.getFullActorByIdOrFail).mockResolvedValue({ + id: 'org-1', + nameID: 'acme', + profile: { displayName: 'Acme' }, + } as any); + vi.mocked( + urlGeneratorService.getOrganizationSettingsInvitationsUrlPath + ).mockReturnValue('/organization/acme/settings/invitations'); + + await adapter.organizationSpaceCommunityInvitationCreated( + baseEventData as any + ); + + expect( + notificationAdapter.getNotificationRecipients + ).toHaveBeenCalledWith( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + baseEventData, + undefined, + undefined, + 'org-1' + ); + expect(externalAdapter.sendExternalNotifications).toHaveBeenCalled(); + expect(inAppAdapter.sendInAppNotifications).toHaveBeenCalledWith( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + expect.anything(), + 'inviter-1', + ['admin-1'], + expect.objectContaining({ + spaceID: 'space-1', + invitationID: 'inv-1', + organizationID: 'org-1', + }) + ); + expect(pushAdapter.sendPushNotifications).toHaveBeenCalledWith( + [{ id: 'admin-1' }], + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + expect.objectContaining({ + url: '/organization/acme/settings/invitations', + }) + ); + // Push title/body never carry the welcome message text. + const pushCall = vi.mocked(pushAdapter.sendPushNotifications).mock + .calls[0]; + expect(pushCall[2].title).not.toContain('Welcome!'); + expect(pushCall[2].body).not.toContain('Welcome!'); + }); + + it('does NOT filter the inviting admin out of push — an invitation is a call to action', async () => { + // The Space admin who sent the invitation may also be the invited + // organization's ONLY admin, in which case they are the one person who + // can answer it. Filtering them from push alone was the exact + // per-channel split R33 exists to eliminate, and filtering them from + // all three would let the invitation rot unanswered. + setUpCommonMocks(); + vi.mocked( + notificationAdapter.getNotificationRecipients + ).mockResolvedValue({ + emailRecipients: [{ id: 'inviter-1' }], + inAppRecipients: [{ id: 'inviter-1' }], + pushRecipients: [{ id: 'inviter-1' }], + } as any); + vi.mocked( + externalAdapter.buildOrganizationSpaceCommunityInvitationPayload + ).mockResolvedValue({} as any); + vi.mocked(actorLookupService.getFullActorByIdOrFail).mockResolvedValue({ + id: 'org-1', + nameID: 'acme', + profile: { displayName: 'Acme' }, + } as any); + + await adapter.organizationSpaceCommunityInvitationCreated( + baseEventData as any + ); + + expect(externalAdapter.sendExternalNotifications).toHaveBeenCalled(); + expect(inAppAdapter.sendInAppNotifications).toHaveBeenCalledWith( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + expect.anything(), + 'inviter-1', + ['inviter-1'], + expect.anything() + ); + expect(pushAdapter.sendPushNotifications).toHaveBeenCalledWith( + [{ id: 'inviter-1' }], + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + expect.anything() + ); + }); + + it('skips email when there are no email recipients', async () => { + setUpCommonMocks(); + vi.mocked( + notificationAdapter.getNotificationRecipients + ).mockResolvedValue({ + emailRecipients: [], + inAppRecipients: [], + pushRecipients: [], + } as any); + + await adapter.organizationSpaceCommunityInvitationCreated( + baseEventData as any + ); + + expect( + externalAdapter.buildOrganizationSpaceCommunityInvitationPayload + ).not.toHaveBeenCalled(); + expect(externalAdapter.sendExternalNotifications).not.toHaveBeenCalled(); + }); + }); + + describe('organizationSpaceCommunityJoined', () => { + // The accepting admin must be excluded on EVERY channel. This is the + // "welcome" notice whose stated purpose is that the OTHER admins learn + // no action is needed; an admin of both the organization and the Space + // sits on this recipient set AND on the Space-side + // SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED set, so leaving + // them in produced the double notification the product brief ruled out + // ("one for X accepted, immediately followed by X joined"). Push was + // already filtered; email and in-app were not. + const eventData = { + triggeredBy: 'acceptor-1', + organizationID: 'org-1', + spaceID: 'space-1', + } as any; + + beforeEach(() => { + vi.mocked(spaceLookupService.getSpaceOrFail).mockResolvedValue({ + id: 'space-1', + about: { profile: { displayName: 'My Space' } }, + } as any); + vi.mocked(actorLookupService.getFullActorByIdOrFail).mockResolvedValue({ + id: 'org-1', + nameID: 'acme', + profile: { displayName: 'Acme' }, + } as any); + vi.mocked( + externalAdapter.buildActorSpaceCommunityInvitationOutcomePayload + ).mockResolvedValue({} as any); + }); + + // The welcome resolves TWO recipient sets: its own (organization admins) + // and the Space-side outcome's (Space admins), which it subtracts. + const mockRecipients = ( + orgAdmins: string[], + spaceAdmins: string[] = [] + ) => { + vi.mocked( + notificationAdapter.getNotificationRecipients + ).mockImplementation(async (event: any) => { + const ids = + event === + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED + ? spaceAdmins + : orgAdmins; + const list = ids.map(id => ({ id })); + return { + emailRecipients: list, + inAppRecipients: list, + pushRecipients: list, + } as any; + }); + }; + + it('excludes an admin of BOTH the Space and the organization — the Space-side outcome already tells them', async () => { + // Alice admins the Space and Acme; Bob (Acme's other admin) accepts. + // Bob is the acceptor. Alice is on BOTH sets, so without the second + // exclusion she receives "Bob accepted the invitation of Acme" AND + // "Acme is now a member... No further action is needed from you" for + // one click — verbatim the pair the product brief rules out. + mockRecipients(['acceptor-bob', 'alice', 'carol'], ['alice']); + + await adapter.organizationSpaceCommunityJoined({ + ...eventData, + triggeredBy: 'acceptor-bob', + }); + + expect( + externalAdapter.buildActorSpaceCommunityInvitationOutcomePayload + ).toHaveBeenCalledWith( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED, + 'acceptor-bob', + [{ id: 'carol' }], + 'org-1', + expect.objectContaining({ id: 'space-1' }) + ); + expect(inAppAdapter.sendInAppNotifications).toHaveBeenCalledWith( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED, + expect.anything(), + 'acceptor-bob', + ['carol'], + expect.anything() + ); + expect(pushAdapter.sendPushNotifications).toHaveBeenCalledWith( + [{ id: 'carol' }], + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED, + expect.anything() + ); + }); + + it('excludes the admin who accepted from email, in-app AND push', async () => { + mockRecipients(['acceptor-1', 'other-admin']); + + await adapter.organizationSpaceCommunityJoined(eventData); + + expect( + externalAdapter.buildActorSpaceCommunityInvitationOutcomePayload + ).toHaveBeenCalledWith( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED, + 'acceptor-1', + [{ id: 'other-admin' }], + 'org-1', + expect.objectContaining({ id: 'space-1' }) + ); + expect(inAppAdapter.sendInAppNotifications).toHaveBeenCalledWith( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED, + expect.anything(), + 'acceptor-1', + ['other-admin'], + expect.objectContaining({ + spaceID: 'space-1', + actorID: 'org-1', + }) + ); + expect(pushAdapter.sendPushNotifications).toHaveBeenCalledWith( + [{ id: 'other-admin' }], + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED, + expect.anything() + ); + }); + + it("sends nothing at all when the acceptor is the organization's only admin", async () => { + // The single-admin case is the whole reason the filter cannot be a + // per-channel afterthought: there is no "other admin" to welcome, so + // the acceptor would otherwise receive a welcome for a Space they + // just joined by their own click. + vi.mocked( + notificationAdapter.getNotificationRecipients + ).mockResolvedValue({ + emailRecipients: [{ id: 'acceptor-1' }], + inAppRecipients: [{ id: 'acceptor-1' }], + pushRecipients: [{ id: 'acceptor-1' }], + } as any); + + await adapter.organizationSpaceCommunityJoined(eventData); + + expect( + externalAdapter.buildActorSpaceCommunityInvitationOutcomePayload + ).not.toHaveBeenCalled(); + expect(externalAdapter.sendExternalNotifications).not.toHaveBeenCalled(); + expect(inAppAdapter.sendInAppNotifications).not.toHaveBeenCalled(); + expect(pushAdapter.sendPushNotifications).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/services/adapters/notification-adapter/notification.organization.adapter.ts b/src/services/adapters/notification-adapter/notification.organization.adapter.ts index b1e3d5a08b..af81819fea 100644 --- a/src/services/adapters/notification-adapter/notification.organization.adapter.ts +++ b/src/services/adapters/notification-adapter/notification.organization.adapter.ts @@ -1,17 +1,31 @@ +import { ActorType } from '@common/enums/actor.type'; +import { LogContext } from '@common/enums/logging.context'; import { NotificationEvent } from '@common/enums/notification.event'; import { NotificationEventCategory } from '@common/enums/notification.event.category'; import { NotificationEventPayload } from '@common/enums/notification.event.payload'; +import { IRoleSet } from '@domain/access/role-set'; +import { RoleSetService } from '@domain/access/role-set/role.set.service'; +import { ActorLookupService } from '@domain/actor/actor-lookup/actor.lookup.service'; import { MessageDetailsService } from '@domain/communication/message.details/message.details.service'; -import { Inject, Injectable, LoggerService } from '@nestjs/common'; +import { SpaceLookupService } from '@domain/space/space.lookup/space.lookup.service'; +import { forwardRef, Inject, Injectable, LoggerService } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config/dist/config.service'; import { InAppNotificationPayloadOrganizationMessageDirect } from '@platform/in-app-notification-payload/dto/organization/notification.in.app.payload.organization.message.direct'; import { InAppNotificationPayloadOrganizationMessageRoom } from '@platform/in-app-notification-payload/dto/organization/notification.in.app.payload.organization.message.room'; +import { InAppNotificationPayloadSpaceCommunityActor } from '@platform/in-app-notification-payload/dto/space/notification.in.app.payload.space.community.actor'; +import { InAppNotificationPayloadSpaceCommunityInvitation } from '@platform/in-app-notification-payload/dto/space/notification.in.app.payload.space.community.invitation'; import { NotificationRecipientResult } from '@services/api/notification-recipients/dto/notification.recipients.dto.result'; +import { CommunityResolverService } from '@services/infrastructure/entity-resolver/community.resolver.service'; +import { UrlGeneratorService } from '@services/infrastructure/url-generator/url.generator.service'; +import { AlkemioConfig } from '@src/types'; import { WINSTON_MODULE_NEST_PROVIDER } from 'nest-winston'; import { NotificationExternalAdapter } from '../notification-external-adapter/notification.external.adapter'; import { NotificationInAppAdapter } from '../notification-in-app-adapter/notification.in.app.adapter'; import { NotificationPushAdapter } from '../notification-push-adapter/notification.push.adapter'; import { NotificationInputBase } from './dto/notification.dto.input.base'; import { NotificationInputOrganizationMention } from './dto/organization/notification.dto.input.organization.mention'; +import { NotificationInputOrganizationSpaceCommunityInvitation } from './dto/organization/notification.dto.input.organization.space.community.invitation'; +import { NotificationInputOrganizationSpaceCommunityJoined } from './dto/organization/notification.dto.input.organization.space.community.joined'; import { NotificationInputOrganizationMessage } from './dto/organization/notification.input.organization.message'; import { NotificationAdapter } from './notification.adapter'; @@ -24,7 +38,14 @@ export class NotificationOrganizationAdapter { private notificationExternalAdapter: NotificationExternalAdapter, private notificationInAppAdapter: NotificationInAppAdapter, private notificationPushAdapter: NotificationPushAdapter, - private messageDetailsService: MessageDetailsService + private messageDetailsService: MessageDetailsService, + private actorLookupService: ActorLookupService, + private communityResolverService: CommunityResolverService, + private urlGeneratorService: UrlGeneratorService, + private spaceLookupService: SpaceLookupService, + private configService: ConfigService, + @Inject(forwardRef(() => RoleSetService)) + private roleSetService: RoleSetService ) {} public async organizationMention( @@ -79,6 +100,11 @@ export class NotificationOrganizationAdapter { } // Send push notifications + // The actor who caused the event is excluded: they just mentioned their own + // organization and do not need a push telling them so. Pre-existing + // behaviour, unrelated to feature 061 — R34 removed this filter from the + // *invitation* dispatch only, because an invitation is a call to action + // rather than an FYI. A mention IS an FYI, so the filter stays. const pushRecipientsFiltered = recipients.pushRecipients.filter( recipient => recipient.id !== eventData.triggeredBy ); @@ -141,6 +167,12 @@ export class NotificationOrganizationAdapter { } // Send push notifications + // The sender is excluded: they receive the separate + // ORGANIZATION_MESSAGE_SENDER dispatch below, and would otherwise be pushed + // twice for one message. Pre-existing behaviour, unrelated to feature 061 — + // R34 removed this filter from the *invitation* dispatch only, because an + // invitation is a call to action rather than an FYI. A message IS an FYI, + // so the filter stays. const pushRecipientsFiltered = recipients.pushRecipients.filter( recipient => recipient.id !== eventData.triggeredBy ); @@ -226,4 +258,273 @@ export class NotificationOrganizationAdapter { organizationID ); } + + /** + * A Space invited an organization. Notifies every ADMIN of the + * organization (email, in-app, push) with the inviter, the offered + * role(s), the message and every Space acceptance would join. When the + * organization has no administrators, the invitation still + * exists — only the support escalation email fires, with no recipient + * lookup and no in-app/push (the organization has nobody to notify). + */ + public async organizationSpaceCommunityInvitationCreated( + eventData: NotificationInputOrganizationSpaceCommunityInvitation + ): Promise { + const event = + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION; + const space = + await this.communityResolverService.getSpaceForCommunityOrFail( + eventData.community.id + ); + const roleSetID = await this.communityResolverService.getRoleSetIdForSpace( + space.id + ); + const spacesToJoin = roleSetID + ? await this.roleSetService.getSpacesToJoinOnAccept( + { id: roleSetID } as IRoleSet, + eventData.invitedContributorID, + eventData.invitedToParent + ) + : [space]; + + if (eventData.organizationHasNoAdministrators) { + const supportEmail = this.configService.get( + 'notifications.organization_invitations.support_email', + { infer: true } + ); + const payload = + await this.notificationExternalAdapter.buildOrganizationSpaceCommunityInvitationPayload( + event, + eventData.triggeredBy, + [], + eventData.invitedContributorID, + space, + spacesToJoin, + eventData.extraRoles, + eventData.welcomeMessage, + supportEmail + ); + this.notificationExternalAdapter.sendExternalNotifications( + event, + payload + ); + this.logger.verbose?.( + `Organization ${eventData.invitedContributorID} has no administrators — invitation escalated to platform support`, + LogContext.NOTIFICATIONS + ); + return; + } + + const recipients = await this.notificationAdapter.getNotificationRecipients( + event, + eventData, + undefined, + undefined, + eventData.invitedContributorID + ); + + if (recipients.emailRecipients.length > 0) { + const payload = + await this.notificationExternalAdapter.buildOrganizationSpaceCommunityInvitationPayload( + event, + eventData.triggeredBy, + recipients.emailRecipients, + eventData.invitedContributorID, + space, + spacesToJoin, + eventData.extraRoles, + eventData.welcomeMessage + ); + this.notificationExternalAdapter.sendExternalNotifications( + event, + payload + ); + } + + const inAppReceiverIDs = recipients.inAppRecipients.map( + recipient => recipient.id + ); + if (inAppReceiverIDs.length > 0) { + const inAppPayload: InAppNotificationPayloadSpaceCommunityInvitation = { + type: NotificationEventPayload.SPACE_COMMUNITY_INVITATION, + spaceID: space.id, + invitationID: eventData.invitationID, + organizationID: eventData.invitedContributorID, + }; + + await this.notificationInAppAdapter.sendInAppNotifications( + event, + NotificationEventCategory.ORGANIZATION, + eventData.triggeredBy, + inAppReceiverIDs, + inAppPayload + ); + } + + // NO per-channel recipient filtering here, deliberately. An invitation is + // a call to action, not an FYI: unlike the outcome/welcome notifications + // (R33), the actor must still be told there is something to accept, because + // a Space admin who is also the invited organization's ONLY admin is the + // one person who can answer it. Filtering them out of push alone was the + // exact per-channel split R33 exists to eliminate. + if (recipients.pushRecipients.length > 0) { + const organization = await this.actorLookupService.getFullActorByIdOrFail( + eventData.invitedContributorID, + { relations: { profile: true } } + ); + const organizationName = + organization.profile?.displayName ?? 'your organization'; + const spaceName = space.about?.profile?.displayName ?? 'a Space'; + await this.notificationPushAdapter.sendPushNotifications( + recipients.pushRecipients, + event, + { + title: `Invitation for ${organizationName} to join ${spaceName}`, + body: `An admin invited ${organizationName} to join ${spaceName}`, + url: this.urlGeneratorService.getOrganizationSettingsInvitationsUrlPath( + organization.nameID + ), + } + ); + } + } + + /** + * The organization has joined a Space after one of its admins accepted + * the invitation. Every ADMIN **except the one who accepted** is + * notified: the point of this notification is that the *others* learn no + * action is needed, mirroring the "welcome to the Space" notification a + * user gets when they accept an invitation themselves. Shares the + * invitation's settings row: it is the closing half of the same + * lifecycle. + * + * TWO exclusions, both applied on EVERY channel, not just push (R33): + * + * 1. the acceptor. They just clicked Accept, so the welcome tells them + * nothing: the product email frames this notification as the one that + * "informs the OTHERS that no action is needed"; + * 2. anyone the Space-side + * SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED outcome will + * reach for the same click. An admin of BOTH the organization and the + * Space is on both recipient sets, and that is precisely the pair the + * brief rules out — "accepting an invite/application shouldn't trigger + * a double notification (one for X accepted, immediately followed by X + * joined)". The outcome cannot be the side that yields: it is the only + * notification co-admins of the Space get about this membership, + * because the generic "a new member joined" is suppressed for it. + */ + public async organizationSpaceCommunityJoined( + eventData: NotificationInputOrganizationSpaceCommunityJoined + ): Promise { + const event = NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED; + const space = await this.spaceLookupService.getSpaceOrFail( + eventData.spaceID, + { relations: { about: { profile: true } } } + ); + + const recipients = await this.notificationAdapter.getNotificationRecipients( + event, + eventData, + undefined, + undefined, + eventData.organizationID + ); + + // The Space-side "X accepted the invitation" outcome is dispatched for the + // SAME click, to every admin of the Space minus the answerer. An admin of + // BOTH the Space and the invited organization is on both recipient sets, + // so leaving them here produces exactly the pair the brief rules out — + // "one for X accepted, immediately followed by X joined". The outcome is + // the notification that must not be narrowed (it is the only one co-admins + // of the Space receive about this membership, since the generic "a new + // member joined" is suppressed for it), so the welcome is the side that + // yields. Unioned across channels on purpose: being told once on any + // channel is enough to make a second notification redundant. + const spaceOutcomeRecipients = + await this.notificationAdapter.getNotificationRecipients( + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED, + eventData, + space.id + ); + const alreadyToldBySpaceOutcome = new Set([ + ...spaceOutcomeRecipients.emailRecipients.map(r => r.id), + ...spaceOutcomeRecipients.inAppRecipients.map(r => r.id), + ...spaceOutcomeRecipients.pushRecipients.map(r => r.id), + ]); + + // Applied once, to every channel — see the docblock. Doing it per + // channel is how push ended up filtered and email/in-app not. + const withoutAcceptor = (list: T[]): T[] => + list.filter( + recipient => + recipient.id !== eventData.triggeredBy && + !alreadyToldBySpaceOutcome.has(recipient.id) + ); + const emailRecipients = withoutAcceptor(recipients.emailRecipients); + const inAppRecipients = withoutAcceptor(recipients.inAppRecipients); + const pushRecipients = withoutAcceptor(recipients.pushRecipients); + + if (emailRecipients.length > 0) { + const payload = + await this.notificationExternalAdapter.buildActorSpaceCommunityInvitationOutcomePayload( + event, + eventData.triggeredBy, + emailRecipients, + eventData.organizationID, + space + ); + this.notificationExternalAdapter.sendExternalNotifications( + event, + payload + ); + } + + const inAppReceiverIDs = inAppRecipients.map(recipient => recipient.id); + if (inAppReceiverIDs.length > 0) { + const inAppPayload: InAppNotificationPayloadSpaceCommunityActor = { + type: NotificationEventPayload.SPACE_COMMUNITY_ACTOR, + spaceID: space.id, + actorID: eventData.organizationID, + actorType: ActorType.ORGANIZATION, + }; + + await this.notificationInAppAdapter.sendInAppNotifications( + event, + NotificationEventCategory.ORGANIZATION, + eventData.triggeredBy, + inAppReceiverIDs, + inAppPayload + ); + } + + if (pushRecipients.length > 0) { + const organizationName = await this.getOrganizationDisplayName( + eventData.organizationID + ); + const spaceName = space.about?.profile?.displayName ?? 'a Space'; + await this.notificationPushAdapter.sendPushNotifications( + pushRecipients, + event, + { + title: `Welcome to ${spaceName}`, + body: `${organizationName} is now a member of ${spaceName}`, + url: await this.urlGeneratorService.getSpaceUrlPathByID(space.id), + } + ); + } + } + + private async getOrganizationDisplayName( + organizationID: string + ): Promise { + try { + const organization = await this.actorLookupService.getFullActorByIdOrFail( + organizationID, + { relations: { profile: true } } + ); + return organization?.profile?.displayName ?? 'Your organization'; + } catch { + return 'Your organization'; + } + } } diff --git a/src/services/adapters/notification-adapter/notification.space.adapter.spec.ts b/src/services/adapters/notification-adapter/notification.space.adapter.spec.ts index 47eae2e778..b6b045f8a6 100644 --- a/src/services/adapters/notification-adapter/notification.space.adapter.spec.ts +++ b/src/services/adapters/notification-adapter/notification.space.adapter.spec.ts @@ -1,5 +1,7 @@ import { LogContext } from '@common/enums'; +import { CommunityMembershipOrigin } from '@common/enums/community.membership.origin'; import { EntityNotFoundException } from '@common/exceptions/entity.not.found.exception'; +import { ActorLookupService } from '@domain/actor/actor-lookup/actor.lookup.service'; import { CalloutLookupService } from '@domain/collaboration/callout/callout.lookup/callout.lookup.service'; import { UserLookupService } from '@domain/community/user-lookup/user.lookup.service'; import { SpaceLookupService } from '@domain/space/space.lookup/space.lookup.service'; @@ -28,6 +30,7 @@ describe('NotificationSpaceAdapter', () => { let calloutReactionEmailSuppressionService: CalloutReactionEmailSuppressionService; let configService: ConfigService; let userLookupService: UserLookupService; + let actorLookupService: ActorLookupService; const mockRecipients = ( emailRecipients: any[] = [], @@ -84,6 +87,7 @@ describe('NotificationSpaceAdapter', () => { ); configService = module.get(ConfigService); userLookupService = module.get(UserLookupService); + actorLookupService = module.get(ActorLookupService); // Default: kill switch enabled vi.mocked(configService.get).mockReturnValue(true as any); @@ -231,7 +235,16 @@ describe('NotificationSpaceAdapter', () => { }); describe('spaceCommunityNewMember', () => { - it('should notify user and admins', async () => { + const newMemberEvent = (membershipOrigin?: CommunityMembershipOrigin) => + ({ + triggeredBy: 'user-1', + community: { id: 'community-1' }, + actorID: 'new-member', + actorType: 'USER', + ...(membershipOrigin ? { membershipOrigin } : {}), + }) as any; + + beforeEach(() => { vi.mocked( communityResolverService.getSpaceForCommunityOrFail ).mockResolvedValue({ id: 'space-1' } as any); @@ -239,13 +252,61 @@ describe('NotificationSpaceAdapter', () => { vi.mocked( externalAdapter.buildSpaceCommunityNewMemberPayload ).mockResolvedValue({} as any); + }); - await adapter.spaceCommunityNewMember({ - triggeredBy: 'user-1', - community: { id: 'community-1' }, - actorID: 'new-member', - actorType: 'USER', - } as any); + it('should notify user and admins', async () => { + await adapter.spaceCommunityNewMember(newMemberEvent()); + + expect( + notificationUserAdapter.userSpaceCommunityJoined + ).toHaveBeenCalled(); + expect(externalAdapter.sendExternalNotifications).toHaveBeenCalled(); + }); + + it('treats a missing membershipOrigin as DIRECT and still notifies admins', async () => { + await adapter.spaceCommunityNewMember( + newMemberEvent(CommunityMembershipOrigin.DIRECT) + ); + + expect(externalAdapter.sendExternalNotifications).toHaveBeenCalled(); + }); + + it('keeps the member welcome but suppresses the admin new-member notification for INVITATION', async () => { + await adapter.spaceCommunityNewMember( + newMemberEvent(CommunityMembershipOrigin.INVITATION) + ); + + // The welcome to the new member always fires ... + expect( + notificationUserAdapter.userSpaceCommunityJoined + ).toHaveBeenCalled(); + // ... but the admins are not told twice: the invitation outcome + // notification already covered it. + expect(externalAdapter.sendExternalNotifications).not.toHaveBeenCalled(); + expect(inAppAdapter.sendInAppNotifications).not.toHaveBeenCalled(); + }); + + it('still notifies the admins for an approved application — nothing replaces it (R40)', async () => { + // An approved application reaches this adapter as DIRECT: there is no + // application-approved event to take the suppressed notification's + // place, so suppressing would tell the approving admin's co-admins + // nothing at all. Pinned here because it is a live platform flow that + // server#4100 must not silently change; it flips only when + // alkem-io/server#6476 adds the replacement event. + await adapter.spaceCommunityNewMember( + newMemberEvent(CommunityMembershipOrigin.DIRECT) + ); + + expect( + notificationUserAdapter.userSpaceCommunityJoined + ).toHaveBeenCalled(); + expect(externalAdapter.sendExternalNotifications).toHaveBeenCalled(); + }); + + it('still notifies the admins for a direct join or admin assignment', async () => { + await adapter.spaceCommunityNewMember( + newMemberEvent(CommunityMembershipOrigin.DIRECT) + ); expect( notificationUserAdapter.userSpaceCommunityJoined @@ -819,4 +880,222 @@ describe('NotificationSpaceAdapter', () => { ); }); }); + + describe('spaceAdminOrganizationInvitationAccepted', () => { + const eventData = { + triggeredBy: 'org-admin-1', + invitedActorID: 'org-1', + invitationCreatedBy: 'inviter-1', + } as any; + const space = { + id: 'space-1', + about: { profile: { displayName: 'My Space' } }, + } as any; + + it('sends email, in-app and push to the Space admins', async () => { + mockRecipients([{ id: 'inviter-1' }], [{ id: 'inviter-1' }], undefined); + vi.mocked( + notificationAdapter.getNotificationRecipients + ).mockResolvedValue({ + emailRecipients: [{ id: 'inviter-1' }], + inAppRecipients: [{ id: 'inviter-1' }], + pushRecipients: [{ id: 'inviter-1' }], + } as any); + vi.mocked( + externalAdapter.buildActorSpaceCommunityInvitationOutcomePayload + ).mockResolvedValue({} as any); + vi.mocked(actorLookupService.getFullActorByIdOrFail).mockResolvedValue({ + id: 'org-1', + profile: { displayName: 'Acme' }, + } as any); + + await adapter.spaceAdminOrganizationInvitationAccepted(eventData, space); + + // Space-scoped, NOT scoped to invitation.createdBy: the recipients + // service resolves every Space admin for this event. + expect( + notificationAdapter.getNotificationRecipients + ).toHaveBeenCalledWith( + expect.any(String), + eventData, + 'space-1', + undefined + ); + expect(externalAdapter.sendExternalNotifications).toHaveBeenCalled(); + expect(inAppAdapter.sendInAppNotifications).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + 'org-admin-1', + ['inviter-1'], + expect.objectContaining({ spaceID: 'space-1', actorID: 'org-1' }) + ); + expect( + (adapter as any).notificationPushAdapter.sendPushNotifications + ).toHaveBeenCalledWith( + [{ id: 'inviter-1' }], + expect.any(String), + expect.objectContaining({ title: 'Invitation accepted' }) + ); + }); + + it('skips email when there are no email recipients', async () => { + vi.mocked( + notificationAdapter.getNotificationRecipients + ).mockResolvedValue({ + emailRecipients: [], + inAppRecipients: [], + pushRecipients: [], + } as any); + + await adapter.spaceAdminOrganizationInvitationAccepted(eventData, space); + + expect( + externalAdapter.buildActorSpaceCommunityInvitationOutcomePayload + ).not.toHaveBeenCalled(); + expect(externalAdapter.sendExternalNotifications).not.toHaveBeenCalled(); + }); + + // R33. An invitation may carry ADMIN as an extra role and the role is + // granted BEFORE this dispatch, so whoever answered can already be on the + // Space-admin recipient set. Push was filtered; email and in-app were not, + // which mailed them " accepted the invitation to join + // " about their own click. + it('excludes whoever answered the invitation from email, in-app AND push', async () => { + const answeredThemselves = { + triggeredBy: 'new-admin-1', + invitedActorID: 'org-1', + } as any; + vi.mocked( + notificationAdapter.getNotificationRecipients + ).mockResolvedValue({ + emailRecipients: [{ id: 'new-admin-1' }, { id: 'co-admin-1' }], + inAppRecipients: [{ id: 'new-admin-1' }, { id: 'co-admin-1' }], + pushRecipients: [{ id: 'new-admin-1' }, { id: 'co-admin-1' }], + } as any); + vi.mocked( + externalAdapter.buildActorSpaceCommunityInvitationOutcomePayload + ).mockResolvedValue({} as any); + vi.mocked(actorLookupService.getFullActorByIdOrFail).mockResolvedValue({ + id: 'org-1', + profile: { displayName: 'Acme' }, + } as any); + + await adapter.spaceAdminOrganizationInvitationAccepted( + answeredThemselves, + space + ); + + expect( + externalAdapter.buildActorSpaceCommunityInvitationOutcomePayload + ).toHaveBeenCalledWith( + expect.any(String), + 'new-admin-1', + [{ id: 'co-admin-1' }], + 'org-1', + expect.objectContaining({ id: 'space-1' }) + ); + expect(inAppAdapter.sendInAppNotifications).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + 'new-admin-1', + ['co-admin-1'], + expect.objectContaining({ spaceID: 'space-1', actorID: 'org-1' }) + ); + expect( + (adapter as any).notificationPushAdapter.sendPushNotifications + ).toHaveBeenCalledWith( + [{ id: 'co-admin-1' }], + expect.any(String), + expect.anything() + ); + }); + + it('sends nothing when the only Space admin is the one who answered', async () => { + const soleAdmin = { + triggeredBy: 'sole-admin-1', + invitedActorID: 'org-1', + } as any; + vi.mocked( + notificationAdapter.getNotificationRecipients + ).mockResolvedValue({ + emailRecipients: [{ id: 'sole-admin-1' }], + inAppRecipients: [{ id: 'sole-admin-1' }], + pushRecipients: [{ id: 'sole-admin-1' }], + } as any); + + await adapter.spaceAdminOrganizationInvitationAccepted(soleAdmin, space); + + expect( + externalAdapter.buildActorSpaceCommunityInvitationOutcomePayload + ).not.toHaveBeenCalled(); + expect(externalAdapter.sendExternalNotifications).not.toHaveBeenCalled(); + expect(inAppAdapter.sendInAppNotifications).not.toHaveBeenCalled(); + expect( + (adapter as any).notificationPushAdapter.sendPushNotifications + ).not.toHaveBeenCalled(); + }); + }); + + describe('spaceAdminOrganizationInvitationDeclined', () => { + const eventData = { + triggeredBy: 'org-admin-1', + invitedActorID: 'org-1', + invitationCreatedBy: 'inviter-1', + } as any; + const space = { + id: 'space-1', + about: { profile: { displayName: 'My Space' } }, + } as any; + + it('sends email, in-app and push to the Space admins', async () => { + vi.mocked( + notificationAdapter.getNotificationRecipients + ).mockResolvedValue({ + emailRecipients: [{ id: 'inviter-1' }], + inAppRecipients: [{ id: 'inviter-1' }], + pushRecipients: [{ id: 'inviter-1' }], + } as any); + vi.mocked( + externalAdapter.buildActorSpaceCommunityInvitationOutcomePayload + ).mockResolvedValue({} as any); + vi.mocked(actorLookupService.getFullActorByIdOrFail).mockResolvedValue({ + id: 'org-1', + profile: { displayName: 'Acme' }, + } as any); + + await adapter.spaceAdminOrganizationInvitationDeclined(eventData, space); + + expect(externalAdapter.sendExternalNotifications).toHaveBeenCalled(); + expect(inAppAdapter.sendInAppNotifications).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + 'org-admin-1', + ['inviter-1'], + expect.objectContaining({ spaceID: 'space-1', actorID: 'org-1' }) + ); + expect( + (adapter as any).notificationPushAdapter.sendPushNotifications + ).toHaveBeenCalledWith( + [{ id: 'inviter-1' }], + expect.any(String), + expect.objectContaining({ title: 'Invitation declined' }) + ); + }); + + it('skips email when there are no email recipients', async () => { + vi.mocked( + notificationAdapter.getNotificationRecipients + ).mockResolvedValue({ + emailRecipients: [], + inAppRecipients: [], + pushRecipients: [], + } as any); + + await adapter.spaceAdminOrganizationInvitationDeclined(eventData, space); + + expect( + externalAdapter.buildActorSpaceCommunityInvitationOutcomePayload + ).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/services/adapters/notification-adapter/notification.space.adapter.ts b/src/services/adapters/notification-adapter/notification.space.adapter.ts index cc229d90ed..6e69dac9b1 100644 --- a/src/services/adapters/notification-adapter/notification.space.adapter.ts +++ b/src/services/adapters/notification-adapter/notification.space.adapter.ts @@ -1,14 +1,17 @@ import { ActorType } from '@common/enums/actor.type'; +import { CommunityMembershipOrigin } from '@common/enums/community.membership.origin'; import { LogContext } from '@common/enums/logging.context'; import { NotificationEvent } from '@common/enums/notification.event'; import { NotificationEventCategory } from '@common/enums/notification.event.category'; import { NotificationEventPayload } from '@common/enums/notification.event.payload'; import { UrlPathElementSpace } from '@common/enums/url.path.element.space'; import { EntityNotFoundException } from '@common/exceptions/entity.not.found.exception'; +import { ActorLookupService } from '@domain/actor/actor-lookup/actor.lookup.service'; import { ICallout } from '@domain/collaboration/callout/callout.interface'; import { CalloutLookupService } from '@domain/collaboration/callout/callout.lookup/callout.lookup.service'; import { IUser } from '@domain/community/user/user.interface'; import { UserLookupService } from '@domain/community/user-lookup/user.lookup.service'; +import { ISpace } from '@domain/space/space/space.interface'; import { SpaceLookupService } from '@domain/space/space.lookup/space.lookup.service'; import { Inject, Injectable, LoggerService } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -50,6 +53,7 @@ import { NotificationInputUpdateSent } from './dto/space/notification.dto.input. import { NotificationInputCommunityApplication } from './dto/space/notification.dto.input.space.community.application'; import { NotificationInputCommunityCalendarEventComment } from './dto/space/notification.dto.input.space.community.calendar.event.comment'; import { NotificationInputCommunityCalendarEventCreated } from './dto/space/notification.dto.input.space.community.calendar.event.created'; +import { NotificationInputSpaceCommunityInvitationOutcome } from './dto/space/notification.dto.input.space.community.invitation.outcome'; import { NotificationInputPlatformInvitation } from './dto/space/notification.dto.input.space.community.invitation.platform'; import { NotificationInputVirtualContributorSpaceCommunityInvitationDeclined } from './dto/space/notification.dto.input.space.community.invitation.vc.declined'; import { NotificationInputCommunityNewMember } from './dto/space/notification.dto.input.space.community.new.member'; @@ -89,7 +93,8 @@ export class NotificationSpaceAdapter { private userLookupService: UserLookupService, private calloutLookupService: CalloutLookupService, private calloutReactionEmailSuppressionService: CalloutReactionEmailSuppressionService, - private configService: ConfigService + private configService: ConfigService, + private actorLookupService: ActorLookupService ) {} private async getTriggeredByDisplayName( @@ -108,6 +113,21 @@ export class NotificationSpaceAdapter { } } + private async getActorDisplayName( + actorID: string, + fallback: string + ): Promise { + try { + const actor = await this.actorLookupService.getFullActorByIdOrFail( + actorID, + { relations: { profile: true } } + ); + return actor?.profile?.displayName ?? fallback; + } catch { + return fallback; + } + } + public async spaceCollaborationCalloutPublished( eventData: NotificationInputCalloutPublished ): Promise { @@ -781,13 +801,31 @@ export class NotificationSpaceAdapter { eventData.community.id ); - // Notify the user + // Notify the new member ("welcome to the Space"). Always fires, whatever + // produced the membership. await this.notificationUserAdapter.userSpaceCommunityJoined( eventData, space ); - // Notify the admins + // Notify the admins — but ONLY when this membership has no replacement + // notification telling them the same thing. An accepted invitation does: + // the dedicated "X accepted / declined the invitation" outcome reaches + // every admin of the invited Space (FR-020), so firing "a new member + // joined" as well would notify them twice for one event, which is what the + // product brief rules out. Every other origin — an approved application + // included — has no such replacement and keeps this notification + // (R40; see CommunityMembershipOrigin for why APPLICATION is not a member). + const membershipOrigin = + eventData.membershipOrigin ?? CommunityMembershipOrigin.DIRECT; + if (membershipOrigin !== CommunityMembershipOrigin.DIRECT) { + this.logger.verbose?.( + `Skipping admin new-member notification for actor ${eventData.actorID} in space ${space.id}: membership originated from ${membershipOrigin}`, + LogContext.NOTIFICATIONS + ); + return; + } + const adminRecipients = await this.getNotificationRecipientsSpace( adminEvent, eventData, @@ -919,6 +957,177 @@ export class NotificationSpaceAdapter { } } + /** + * "Someone responded to the invitation you sent" — accepted or declined, + * for any invited actor type. + * + * Recipients are EVERY admin of the Space, not `invitation.createdBy` + * alone: the sending admin is one of them while they still hold the role, + * but co-admins must be told too, and the event must still land when the + * inviter has since been deleted or demoted. Each recipient's own + * `space.admin.communityInvitationResponse` setting governs delivery. + * + * Do not re-scope this to the inviter. The generic "a new member joined" + * notification is deliberately suppressed for the same membership change + * (FR-020a, see `RoleSetEventsService.processCommunityNewMemberEvents`), + * so this event is the ONLY notification co-admins receive about it — + * narrowing the recipients here would silently tell them nothing at all. + * + * The one recipient removed is whoever answered the invitation, on EVERY + * channel. They are reachable here: an invitation may carry ADMIN as an + * extra role and `acceptInvitationToRoleSet` grants it BEFORE this dispatch, + * so the acceptor is already on the Space-admin credential set by the time + * recipients are resolved. Leaving them in tells a Space admin " accepted the invitation to join " about their own click. Same + * reasoning as the organization-side welcome (R33), and the filter is + * applied once to all three lists — doing it per channel is how push ended + * up filtered and email and in-app not. + */ + private async spaceAdminInvitationOutcome( + event: NotificationEvent, + eventData: NotificationInputSpaceCommunityInvitationOutcome, + space: ISpace, + actorType: ActorType, + push: { title: string; verb: string; fallbackName: string } + ): Promise { + // Recipients are every admin of the Space (see the recipients service): + // the inviter is one of them when they still hold the role, but the + // event is not addressed to them alone. + const recipients = await this.getNotificationRecipientsSpace( + event, + eventData, + space.id + ); + + // Applied once, to every channel — see the docblock. + const withoutAnswerer = (list: T[]): T[] => + list.filter(recipient => recipient.id !== eventData.triggeredBy); + const emailRecipients = withoutAnswerer(recipients.emailRecipients); + const inAppRecipients = withoutAnswerer(recipients.inAppRecipients); + const pushRecipients = withoutAnswerer(recipients.pushRecipients); + + if (emailRecipients.length > 0) { + const payload = + await this.notificationExternalAdapter.buildActorSpaceCommunityInvitationOutcomePayload( + event, + eventData.triggeredBy, + emailRecipients, + eventData.invitedActorID, + space + ); + + this.notificationExternalAdapter.sendExternalNotifications( + event, + payload + ); + } + + const inAppReceiverIDs = inAppRecipients.map(recipient => recipient.id); + if (inAppReceiverIDs.length > 0) { + const inAppPayload: InAppNotificationPayloadSpaceCommunityActor = { + type: NotificationEventPayload.SPACE_COMMUNITY_ACTOR, + spaceID: space.id, + actorID: eventData.invitedActorID, + actorType, + }; + + await this.notificationInAppAdapter.sendInAppNotifications( + event, + NotificationEventCategory.SPACE_ADMIN, + eventData.triggeredBy, + inAppReceiverIDs, + inAppPayload + ); + } + + if (pushRecipients.length > 0) { + const spaceName = space.about?.profile?.displayName ?? 'your Space'; + const actorName = await this.getActorDisplayName( + eventData.invitedActorID, + push.fallbackName + ); + await this.notificationPushAdapter.sendPushNotifications( + pushRecipients, + event, + { + title: push.title, + body: `${actorName} ${push.verb} the invitation to join ${spaceName}`, + url: await this.urlGeneratorService.createSpaceAdminCommunityURL( + space.id + ), + } + ); + } + } + + public async spaceAdminOrganizationInvitationAccepted( + eventData: NotificationInputSpaceCommunityInvitationOutcome, + space: ISpace + ): Promise { + await this.spaceAdminInvitationOutcome( + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED, + eventData, + space, + ActorType.ORGANIZATION, + { + title: 'Invitation accepted', + verb: 'accepted', + fallbackName: 'The organization', + } + ); + } + + public async spaceAdminOrganizationInvitationDeclined( + eventData: NotificationInputSpaceCommunityInvitationOutcome, + space: ISpace + ): Promise { + await this.spaceAdminInvitationOutcome( + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED, + eventData, + space, + ActorType.ORGANIZATION, + { + title: 'Invitation declined', + verb: 'declined', + fallbackName: 'The organization', + } + ); + } + + public async spaceAdminUserInvitationAccepted( + eventData: NotificationInputSpaceCommunityInvitationOutcome, + space: ISpace + ): Promise { + await this.spaceAdminInvitationOutcome( + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED, + eventData, + space, + ActorType.USER, + { + title: 'Invitation accepted', + verb: 'accepted', + fallbackName: 'Someone', + } + ); + } + + public async spaceAdminUserInvitationDeclined( + eventData: NotificationInputSpaceCommunityInvitationOutcome, + space: ISpace + ): Promise { + await this.spaceAdminInvitationOutcome( + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED, + eventData, + space, + ActorType.USER, + { + title: 'Invitation declined', + verb: 'declined', + fallbackName: 'Someone', + } + ); + } + public async spaceCommunityApplicationCreated( eventData: NotificationInputCommunityApplication ): Promise { diff --git a/src/services/adapters/notification-external-adapter/notification.external.adapter.spec.ts b/src/services/adapters/notification-external-adapter/notification.external.adapter.spec.ts index d2a5bdbb43..fa2d22076f 100644 --- a/src/services/adapters/notification-external-adapter/notification.external.adapter.spec.ts +++ b/src/services/adapters/notification-external-adapter/notification.external.adapter.spec.ts @@ -1161,4 +1161,182 @@ describe('NotificationExternalAdapter', () => { expect(result.message).toBe('Direct message'); }); }); + + describe('buildOrganizationSpaceCommunityInvitationPayload', () => { + const setUpCommonMocks = () => { + vi.mocked(userLookupService.getUserByIdOrFail).mockResolvedValue({ + id: 'inviter-1', + firstName: 'Test', + lastName: 'User', + email: 'test@test.com', + nameID: 'test-user', + profile: { displayName: 'Test User' }, + } as any); + vi.mocked(actorLookupService.getFullActorByIdOrFail).mockResolvedValue({ + id: 'org-1', + nameID: 'acme', + type: ActorType.ORGANIZATION, + profile: { displayName: 'Acme' }, + } as any); + vi.mocked(urlGeneratorService.generateUrlForProfile).mockResolvedValue( + '/space/root' + ); + vi.mocked( + urlGeneratorService.createSpaceAdminCommunityURL + ).mockResolvedValue('/admin/target'); + vi.mocked(urlGeneratorService.createUrlForContributor).mockReturnValue( + '/organization/acme' + ); + vi.mocked( + urlGeneratorService.createUrlForOrganizationSettingsInvitations + ).mockReturnValue( + 'https://platform.test/organization/acme/settings/invitations' + ); + vi.mocked(configService.get).mockReturnValue('https://platform.test'); + }; + + const targetSpace = { + id: 'space-target', + level: 2, + about: { profile: { displayName: 'Target Space' } }, + } as any; + const rootSpace = { + id: 'space-root', + level: 0, + about: { profile: { displayName: 'Root Space' } }, + } as any; + + it('builds the invitee (organization), the deep link, extraRoles and spacesToJoin', async () => { + setUpCommonMocks(); + + const result = + await adapter.buildOrganizationSpaceCommunityInvitationPayload( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + 'inviter-1', + [], + 'org-1', + targetSpace, + [rootSpace, targetSpace], + ['lead' as any], + 'Welcome!' + ); + + expect(result.invitee).toBeDefined(); + expect(result.welcomeMessage).toBe('Welcome!'); + expect(result.organizationInvitationsUrl).toBe( + 'https://platform.test/organization/acme/settings/invitations' + ); + expect(result.extraRoles).toEqual(['lead']); + expect(result.spacesToJoin).toEqual([ + { displayName: 'Root Space', url: '/space/root' }, + { displayName: 'Target Space', url: '/space/root' }, + ]); + expect(result.recipientEmail).toBeUndefined(); + }); + + it('carries recipientEmail only when explicitly given (zero-admin escalation)', async () => { + setUpCommonMocks(); + + const result = + await adapter.buildOrganizationSpaceCommunityInvitationPayload( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + 'inviter-1', + [], + 'org-1', + targetSpace, + [targetSpace], + [], + undefined, + 'support@alkem.io' + ); + + expect(result.recipientEmail).toBe('support@alkem.io'); + }); + + it('never puts the welcome message in the subject/title-bound fields (no email/title field carries it beyond welcomeMessage)', async () => { + setUpCommonMocks(); + + const result = + await adapter.buildOrganizationSpaceCommunityInvitationPayload( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + 'inviter-1', + [], + 'org-1', + targetSpace, + [targetSpace], + [], + 'Sensitive welcome text' + ); + + // welcomeMessage is the ONLY field carrying the message; every other + // string field is independent of it. + expect(result.organizationInvitationsUrl).not.toContain( + 'Sensitive welcome text' + ); + expect(result.spacesToJoin[0].displayName).not.toContain( + 'Sensitive welcome text' + ); + }); + + it('loads the organization profile relation, so a real (non-mocked) lookup does not throw "Unable to find Organization profile"', async () => { + setUpCommonMocks(); + + await adapter.buildOrganizationSpaceCommunityInvitationPayload( + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + 'inviter-1', + [], + 'org-1', + targetSpace, + [targetSpace], + [] + ); + + expect(actorLookupService.getFullActorByIdOrFail).toHaveBeenCalledWith( + 'org-1', + { relations: { profile: true } } + ); + }); + }); + + describe('buildActorSpaceCommunityInvitationOutcomePayload', () => { + it('builds the invitee (organization) with no welcomeMessage field populated', async () => { + vi.mocked(userLookupService.getUserByIdOrFail).mockResolvedValue({ + id: 'inviter-1', + firstName: 'Test', + lastName: 'User', + email: 'test@test.com', + nameID: 'test-user', + profile: { displayName: 'Test User' }, + } as any); + vi.mocked(actorLookupService.getFullActorByIdOrFail).mockResolvedValue({ + id: 'org-1', + nameID: 'acme', + type: ActorType.ORGANIZATION, + profile: { displayName: 'Acme' }, + } as any); + vi.mocked( + urlGeneratorService.createSpaceAdminCommunityURL + ).mockResolvedValue('/admin/target'); + vi.mocked(urlGeneratorService.createUrlForContributor).mockReturnValue( + '/organization/acme' + ); + vi.mocked(configService.get).mockReturnValue('https://platform.test'); + + const result = + await adapter.buildActorSpaceCommunityInvitationOutcomePayload( + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED, + 'inviter-1', + [], + 'org-1', + { + id: 'space-target', + level: 2, + about: { profile: { displayName: 'Target Space' } }, + } as any + ); + + expect(result.invitee).toBeDefined(); + expect((result as any).welcomeMessage).toBeUndefined(); + }); + }); }); diff --git a/src/services/adapters/notification-external-adapter/notification.external.adapter.ts b/src/services/adapters/notification-external-adapter/notification.external.adapter.ts index a3b067910d..29353bf02d 100644 --- a/src/services/adapters/notification-external-adapter/notification.external.adapter.ts +++ b/src/services/adapters/notification-external-adapter/notification.external.adapter.ts @@ -37,6 +37,7 @@ import { LogContext } from '@common/enums'; import { ActorType } from '@common/enums/actor.type'; import { CalloutContributionType } from '@common/enums/callout.contribution.type'; import { NotificationEvent } from '@common/enums/notification.event'; +import { RoleName } from '@common/enums/role.name'; import { EntityNotFoundException, RelationshipNotFoundException, @@ -88,6 +89,20 @@ interface CalloutContributionPayload { url: string; } +/** + * Temporary bridge until `@alkemio/notifications-lib` publishes this + * interface (merge gate — see the contract's rollout ordering). Mirrors + * the lib shape exactly so the swap to the published import is a pure + * type-only change. + */ +interface NotificationEventPayloadSpaceCommunityInvitationOrganization + extends NotificationEventPayloadSpaceCommunityInvitation { + organizationInvitationsUrl: string; + extraRoles: string[]; + spacesToJoin: { displayName: string; url: string }[]; + recipientEmail?: string; +} + @Injectable() export class NotificationExternalAdapter { constructor( @@ -278,6 +293,98 @@ export class NotificationExternalAdapter { return result; } + async buildOrganizationSpaceCommunityInvitationPayload( + eventType: NotificationEvent, + triggeredBy: string, + recipients: IUser[], + organizationID: string, + space: ISpace, + spacesToJoin: ISpace[], + extraRoles: RoleName[], + welcomeMessage?: string, + recipientEmail?: string + ): Promise { + const spacePayload = await this.buildSpacePayload( + eventType, + triggeredBy, + recipients, + space + ); + const organization = await this.actorLookupService.getFullActorByIdOrFail( + organizationID, + { + relations: { + profile: true, + }, + } + ); + if (!organization.profile) { + throw new EntityNotFoundException( + 'Unable to find Organization profile', + LogContext.COMMUNITY, + { organizationID } + ); + } + const organizationPayload: ContributorPayload = { + id: organization.id, + profile: { + displayName: organization.profile.displayName, + url: this.urlGeneratorService.createUrlForContributor(organization), + }, + type: getActorType(organization), + }; + const spacesToJoinPayload = await Promise.all( + spacesToJoin.map(async spaceToJoin => ({ + displayName: spaceToJoin.about.profile.displayName, + url: await this.urlGeneratorService.generateUrlForProfile( + spaceToJoin.about.profile + ), + })) + ); + + const result: NotificationEventPayloadSpaceCommunityInvitationOrganization = + { + invitee: organizationPayload, + welcomeMessage, + organizationInvitationsUrl: + this.urlGeneratorService.createUrlForOrganizationSettingsInvitations( + organization.nameID + ), + extraRoles: extraRoles.map(role => role.toString()), + spacesToJoin: spacesToJoinPayload, + ...(recipientEmail ? { recipientEmail } : {}), + ...spacePayload, + }; + return result; + } + + /** + * Invitation accept/decline outcome payload. Actor-agnostic — the + * `invitee` is resolved through the shared contributor lookup, so the + * same builder serves organization and user invitation responses. + */ + async buildActorSpaceCommunityInvitationOutcomePayload( + eventType: NotificationEvent, + triggeredBy: string, + recipients: IUser[], + invitedActorID: string, + space: ISpace + ): Promise { + const spacePayload = await this.buildSpacePayload( + eventType, + triggeredBy, + recipients, + space + ); + const invitedActorPayload = + await this.getContributorPayloadOrFail(invitedActorID); + const result: NotificationEventPayloadSpaceCommunityInvitation = { + invitee: invitedActorPayload, + ...spacePayload, + }; + return result; + } + async buildSpaceCommunityExternalInvitationCreatedNotificationPayload( eventType: NotificationEvent, triggeredBy: string, diff --git a/src/services/api/notification-recipients/notification.events.exhaustiveness.spec.ts b/src/services/api/notification-recipients/notification.events.exhaustiveness.spec.ts new file mode 100644 index 0000000000..e5e1a98221 --- /dev/null +++ b/src/services/api/notification-recipients/notification.events.exhaustiveness.spec.ts @@ -0,0 +1,384 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { NotificationEvent } from '@common/enums/notification.event'; +import { OrganizationLookupService } from '@domain/community/organization-lookup/organization.lookup.service'; +import { UserLookupService } from '@domain/community/user-lookup/user.lookup.service'; +import { SpaceLookupService } from '@domain/space/space.lookup/space.lookup.service'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotificationInAppAdapter } from '@services/adapters/notification-in-app-adapter/notification.in.app.adapter'; +import { MockWinstonProvider } from '@test/mocks/winston.provider.mock'; +import { defaultMockerFactory } from '@test/utils/default.mocker.factory'; +import { repositoryProviderMockFactory } from '@test/utils/repository.provider.mock.factory'; +import { InAppNotification } from '../../../platform/in-app-notification/in.app.notification.entity'; +import { InAppNotificationService } from '../../../platform/in-app-notification/in.app.notification.service'; +import { NotificationRecipientsService } from './notification.recipients.service'; + +/** + * Guards against the class of regression this feature is most exposed to: + * a new notification event that reaches one of the exhaustive mapping + * points (recipients criteria, channel settings, authorization policy, FK + * extraction, in-app support, resolveType) without a case, silently + * dropping the notification instead of failing loudly. + */ +describe('organization-invitation notification events — exhaustiveness (D14)', () => { + const NEW_EVENTS = [ + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED, + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED, + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED, + ]; + + describe('recipients service mapping points', () => { + let service: NotificationRecipientsService; + let organizationLookupService: OrganizationLookupService; + let userLookupService: UserLookupService; + let spaceLookupService: SpaceLookupService; + + const fullNotificationSettings = { + organization: { + adminMessageReceived: { email: true, inApp: true, push: true }, + adminMentioned: { email: true, inApp: true, push: true }, + adminSpaceCommunityInvitation: { + email: true, + inApp: true, + push: true, + }, + }, + space: { + admin: { + communityNewMember: { email: true, inApp: true, push: true }, + communityInvitationResponse: { email: true, inApp: true, push: true }, + }, + }, + } as any; + + beforeEach(async () => { + vi.restoreAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [NotificationRecipientsService], + }) + .useMocker(defaultMockerFactory) + .compile(); + + service = module.get(NotificationRecipientsService); + organizationLookupService = module.get(OrganizationLookupService); + userLookupService = module.get(UserLookupService); + spaceLookupService = module.get(SpaceLookupService); + + vi.mocked( + organizationLookupService.getOrganizationByIdOrFail + ).mockResolvedValue({ + id: 'org-1', + authorization: { id: 'auth-org-1' }, + } as any); + vi.mocked(userLookupService.getUserByIdOrFail).mockResolvedValue({ + id: 'user-1', + authorization: { id: 'auth-user-1' }, + } as any); + }); + + it('getChannelsSettingsForEvent resolves every new event without throwing', () => { + for (const event of NEW_EVENTS) { + expect(() => + (service as any).getChannelsSettingsForEvent( + event, + fullNotificationSettings + ) + ).not.toThrow(); + } + }); + + it('getPrivilegeRequiredCredentialCriteria resolves every new event without throwing', async () => { + for (const event of [ + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED, + ]) { + const orgScoped = await ( + service as any + ).getPrivilegeRequiredCredentialCriteria( + event, + undefined, + undefined, + 'org-1' + ); + expect(orgScoped.credentialCriteria.length).toBeGreaterThan(0); + } + + for (const event of [ + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED, + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED, + ]) { + const outcome = await ( + service as any + ).getPrivilegeRequiredCredentialCriteria(event, 'space-1', 'user-1'); + expect(outcome.credentialCriteria.length).toBeGreaterThan(0); + } + }); + + it('the authorization-policy switch resolves every new event without throwing', async () => { + vi.mocked(spaceLookupService.getSpaceOrFail).mockResolvedValue({ + id: 'space-1', + authorization: { id: 'auth-space-1' }, + } as any); + + for (const event of [ + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED, + ]) { + await expect( + (service as any).getAuthorizationPolicy( + event, + undefined, + undefined, + 'org-1' + ) + ).resolves.toEqual({ id: 'auth-org-1' }); + } + + for (const event of [ + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED, + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED, + ]) { + await expect( + (service as any).getAuthorizationPolicy(event, 'space-1') + ).resolves.toEqual({ id: 'auth-space-1' }); + } + }); + }); + + it('none of the three events is in NOT_SUPPORTED_IN_APP_EVENTS', () => { + const unsupported = (NotificationInAppAdapter as any) + .NOT_SUPPORTED_IN_APP_EVENTS as NotificationEvent[]; + for (const event of NEW_EVENTS) { + expect(unsupported).not.toContain(event); + } + }); + + describe('FK extraction', () => { + let service: InAppNotificationService; + let notificationRepo: { create: ReturnType }; + + beforeEach(async () => { + vi.restoreAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + InAppNotificationService, + repositoryProviderMockFactory(InAppNotification), + MockWinstonProvider, + ], + }) + .useMocker(defaultMockerFactory) + .compile(); + + service = module.get(InAppNotificationService); + notificationRepo = module.get(getRepositoryToken(InAppNotification)); + notificationRepo.create.mockImplementation((input: any) => input); + }); + + it('populates spaceID, invitationID and organizationID for the org-invited event', () => { + const result = service.createInAppNotification({ + type: NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + category: 'organization' as any, + triggeredByID: 'user-1', + triggeredAt: new Date(), + receiverID: 'user-2', + payload: { + spaceID: 'space-1', + invitationID: 'inv-1', + organizationID: 'org-1', + } as any, + }); + + expect(result.spaceID).toBe('space-1'); + expect(result.invitationID).toBe('inv-1'); + expect(result.organizationID).toBe('org-1'); + }); + + it('populates spaceID and organizationID (= actorID) for the org-joined event', () => { + // This one IS an organization-feed notification: it goes to the + // organization's own admins, so losing it on leaving the organization is + // correct. + const result = service.createInAppNotification({ + type: NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED, + category: 'organization' as any, + triggeredByID: 'user-1', + triggeredAt: new Date(), + receiverID: 'user-2', + payload: { spaceID: 'space-1', actorID: 'org-1' } as any, + }); + + expect(result.spaceID).toBe('space-1'); + expect(result.organizationID).toBe('org-1'); + }); + + it.each([ + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED, + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED, + NotificationEvent.SPACE_ADMIN_VIRTUAL_COMMUNITY_INVITATION_DECLINED, + ])('populates spaceID and contributorActorId — never organizationID — for %s', type => { + // Every Space-admin invitation-outcome event uses the Actor FK, + // whatever the invitee's type. `organizationID` is the organization's + // OWN feed and is what `deleteAllForReceiverInOrganization` wipes when + // a user stops being an associate; a Space-admin row keyed on it + // disappears on an unrelated membership change. + const result = service.createInAppNotification({ + type, + category: 'admin' as any, + triggeredByID: 'user-1', + triggeredAt: new Date(), + receiverID: 'user-2', + payload: { spaceID: 'space-1', actorID: 'actor-1' } as any, + }); + + expect(result.spaceID).toBe('space-1'); + expect(result.contributorActorId).toBe('actor-1'); + expect(result.organizationID).toBeUndefined(); + }); + }); + + describe('resolveType coverage (static source scan)', () => { + const payloadDtoDir = join( + __dirname, + '../../../platform/in-app-notification-payload/dto' + ); + const resolveTypeFile = join( + __dirname, + '../../../platform/in-app-notification-payload/in.app.notification.payload.interface.ts' + ); + + function listTsFiles(dir: string): string[] { + const entries = readdirSync(dir); + const files: string[] = []; + for (const entry of entries) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + files.push(...listTsFiles(full)); + } else if (entry.endsWith('.ts') && !entry.endsWith('.spec.ts')) { + files.push(full); + } + } + return files; + } + + it('every NotificationEventPayload value declared as `type` by a DTO is resolved by resolveType', () => { + const declaredTypes = new Set(); + const declareTypePattern = + /declare type:\s*NotificationEventPayload\.([A-Z0-9_]+)/g; + for (const file of listTsFiles(payloadDtoDir)) { + const content = readFileSync(file, 'utf-8'); + let match: RegExpExecArray | null; + while ((match = declareTypePattern.exec(content))) { + declaredTypes.add(match[1]); + } + } + // Sanity: the scan actually found DTOs (guards against a refactor that + // silently makes this assertion vacuously true). + expect(declaredTypes.size).toBeGreaterThan(10); + + const resolveTypeSource = readFileSync(resolveTypeFile, 'utf-8'); + const resolvedTypes = new Set(); + const casePattern = /case NotificationEventPayload\.([A-Z0-9_]+):/g; + let caseMatch: RegExpExecArray | null; + while ((caseMatch = casePattern.exec(resolveTypeSource))) { + resolvedTypes.add(caseMatch[1]); + } + + const missing = [...declaredTypes].filter(t => !resolvedTypes.has(t)); + expect(missing).toEqual([]); + }); + }); + + describe('extractCoreEntityIds covers EVERY notification event (static source scan)', () => { + // FR-021 promises that an unmapped event "MUST be caught by an automated + // exhaustiveness check rather than fail silently". The assertions above + // only ever asked about the six events this feature added, so a seventh + // would sail straight into `extractCoreEntityIds`'s default branch — which + // only `warn`s, then persists an in-app row with every core FK null, and + // no cascade ever reaps it. + // + // This partitions the WHOLE enum: an event is either handled by the + // switch, or listed below as one that provably never produces an in-app + // row. A new event that is neither fails here. + // + // Verified at the time of writing: none of the exemptions reaches + // `createInAppNotification`, so the current default branch is unreachable + // in production — nothing is broken today, and this keeps it that way. + const NEVER_IN_APP: Record = { + // Enforced at the platform boundary by + // NotificationInAppAdapter.NOT_SUPPORTED_IN_APP_EVENTS (034-messaging, + // FR-003/D-2): in-app is permanently OFF regardless of user settings. + USER_CONVERSATION_MESSAGE_DIRECT: 'NOT_SUPPORTED_IN_APP_EVENTS', + USER_CONVERSATION_MESSAGE_GROUP: 'NOT_SUPPORTED_IN_APP_EVENTS', + // Email-only security signals — dispatched solely through + // notificationExternalAdapter.sendExternalNotifications; no producer + // calls sendInAppNotifications for them. + USER_EMAIL_CHANGE_SECURITY_SIGNAL: 'email-only (external adapter)', + USER_EMAIL_CHANGE_NEW_ADDRESS_NOTIFICATION: + 'email-only (external adapter)', + USER_EMAIL_CHANGE_GLOBAL_ADMIN_NOTIFICATION: + 'email-only (notification.platform.adapter)', + USER_EMAIL_CHANGE_SPACE_ADMIN_NOTIFICATION: + 'email-only (notification.space.adapter)', + USER_PASSWORD_CHANGE_SECURITY_SIGNAL: 'email-only (external adapter)', + }; + + it('every NotificationEvent is either handled by the switch or explicitly exempt', () => { + const source = readFileSync( + join( + __dirname, + '../../../platform/in-app-notification/in.app.notification.service.ts' + ), + 'utf-8' + ); + const handled = new Set( + [...source.matchAll(/case NotificationEvent\.([A-Z0-9_]+)/g)].map( + m => m[1] + ) + ); + // Sanity: guards against a refactor that makes this vacuously true. + expect(handled.size).toBeGreaterThan(30); + + const allEvents = Object.keys(NotificationEvent); + expect(allEvents.length).toBeGreaterThan(handled.size); + + const unaccounted = allEvents.filter( + event => !handled.has(event) && !(event in NEVER_IN_APP) + ); + expect(unaccounted).toEqual([]); + }); + + it('no exemption is stale — every exempt event is genuinely absent from the switch', () => { + const source = readFileSync( + join( + __dirname, + '../../../platform/in-app-notification/in.app.notification.service.ts' + ), + 'utf-8' + ); + const handled = new Set( + [...source.matchAll(/case NotificationEvent\.([A-Z0-9_]+)/g)].map( + m => m[1] + ) + ); + const allEvents = new Set(Object.keys(NotificationEvent)); + + // An exemption that names a handled event, or an event that no longer + // exists, is dead weight that hides the next real gap. + expect(Object.keys(NEVER_IN_APP).filter(e => handled.has(e))).toEqual([]); + expect(Object.keys(NEVER_IN_APP).filter(e => !allEvents.has(e))).toEqual( + [] + ); + }); + }); +}); diff --git a/src/services/api/notification-recipients/notification.recipients.service.spec.ts b/src/services/api/notification-recipients/notification.recipients.service.spec.ts index 80810cfbb7..9a62930bc6 100644 --- a/src/services/api/notification-recipients/notification.recipients.service.spec.ts +++ b/src/services/api/notification-recipients/notification.recipients.service.spec.ts @@ -1,3 +1,4 @@ +import { ORGANIZATION_NOTIFICATION_CREDENTIAL_TYPES } from '@common/constants/authorization'; import { AuthorizationCredential } from '@common/enums'; import { NotificationEvent } from '@common/enums/notification.event'; import { ValidationException } from '@common/exceptions'; @@ -458,6 +459,83 @@ describe('NotificationRecipientsService', () => { ).rejects.toThrow(ValidationException); }); + it.each([ + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED, + ])('should use ADMIN credentials only for %s — never the associate criterion, and never OWNER', async eventType => { + // Product asked for "all organization admins" (server#4100 AC, + // notifications#356 AC, and the product email thread — none of them + // mentions owners). An owner who is not also an admin can still + // accept on the organization's behalf; they are simply not notified. + await service.getRecipients({ eventType, organizationID: 'org-1' }); + + expect(userLookupService.usersWithCredentials).toHaveBeenCalledWith( + [...ORGANIZATION_NOTIFICATION_CREDENTIAL_TYPES].map(type => ({ + type, + resourceID: 'org-1', + })), + undefined, + expect.any(Object) + ); + const [criteria] = vi.mocked(userLookupService.usersWithCredentials).mock + .calls[0]; + for (const excluded of [ + AuthorizationCredential.ORGANIZATION_ASSOCIATE, + AuthorizationCredential.ORGANIZATION_OWNER, + ]) { + expect(criteria).not.toContainEqual( + expect.objectContaining({ type: excluded }) + ); + } + }); + + it('should throw ValidationException for ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION without organizationID', async () => { + await expect( + service.getRecipients({ + eventType: + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + }) + ).rejects.toThrow(ValidationException); + }); + + it.each([ + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED, + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED, + ])('should resolve every Space admin — not only the inviter — for %s', async eventType => { + // Product email: "Space admin(s) gets notification that the + // organization has accepted or rejected their invitation". This event + // replaces the generic "new member joined" that R26 suppresses, so + // scoping it to invitation.createdBy would leave co-admins with + // nothing. + await service.getRecipients({ + eventType, + spaceID: 'space-1', + userID: 'inviter-1', + }); + + expect(userLookupService.usersWithCredentials).toHaveBeenCalledWith( + [ + { + type: AuthorizationCredential.SPACE_ADMIN, + resourceID: 'space-1', + }, + ], + undefined, + expect.any(Object) + ); + }); + + it.each([ + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED, + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED, + ])('should throw ValidationException without a spaceID for %s', async eventType => { + await expect( + service.getRecipients({ eventType, userID: 'inviter-1' }) + ).rejects.toThrow(ValidationException); + }); + it('should throw NotificationEventException for unknown event type', async () => { await expect( service.getRecipients({ @@ -663,6 +741,149 @@ describe('NotificationRecipientsService', () => { }); }); + describe('organization space-invitation notification (ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION)', () => { + it('an admin with all channels on is an email + in-app + push recipient', async () => { + const admin = { + id: 'admin-1', + email: 'admin@example.com', + settings: { + notification: { + organization: { + adminSpaceCommunityInvitation: { + email: true, + inApp: true, + push: true, + }, + }, + }, + }, + credentials: [], + } as unknown as IUser; + + vi.mocked(userLookupService.usersWithCredentials).mockResolvedValue([ + admin, + ]); + vi.mocked(userLookupService.getUsersByIds).mockImplementation( + async (ids: string[]) => (ids.length > 0 ? [admin] : []) + ); + + const result = await service.getRecipients({ + eventType: + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + organizationID: 'org-1', + }); + + expect(result.emailRecipients).toHaveLength(1); + expect(result.inAppRecipients).toHaveLength(1); + expect(result.pushRecipients).toHaveLength(1); + }); + + it('an admin who muted every channel receives nothing', async () => { + const mutedAdmin = { + id: 'admin-muted', + email: 'muted@example.com', + settings: { + notification: { + organization: { + adminSpaceCommunityInvitation: { + email: false, + inApp: false, + push: false, + }, + }, + }, + }, + credentials: [], + } as unknown as IUser; + + vi.mocked(userLookupService.usersWithCredentials).mockResolvedValue([ + mutedAdmin, + ]); + vi.mocked(userLookupService.getUsersByIds).mockImplementation( + async (ids: string[]) => (ids.length > 0 ? [mutedAdmin] : []) + ); + + const result = await service.getRecipients({ + eventType: + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + organizationID: 'org-1', + }); + + expect(result.emailRecipients).toHaveLength(0); + expect(result.inAppRecipients).toHaveLength(0); + expect(result.pushRecipients).toHaveLength(0); + }); + + it('an admin who muted only email is filtered per channel: no email, still in-app + push', async () => { + const partiallyMutedAdmin = { + id: 'admin-partial-mute', + email: 'partial-mute@example.com', + settings: { + notification: { + organization: { + adminSpaceCommunityInvitation: { + email: false, + inApp: true, + push: true, + }, + }, + }, + }, + credentials: [], + } as unknown as IUser; + + vi.mocked(userLookupService.usersWithCredentials).mockResolvedValue([ + partiallyMutedAdmin, + ]); + vi.mocked(userLookupService.getUsersByIds).mockImplementation( + async (ids: string[]) => (ids.length > 0 ? [partiallyMutedAdmin] : []) + ); + + const result = await service.getRecipients({ + eventType: + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + organizationID: 'org-1', + }); + + expect(result.emailRecipients).toHaveLength(0); + expect(result.inAppRecipients).toHaveLength(1); + expect(result.pushRecipients).toHaveLength(1); + }); + + it('defend-on-read: a row without the adminSpaceCommunityInvitation key resolves the default (all-on) without throwing', async () => { + const legacyAdmin = { + id: 'admin-legacy', + email: 'legacy-admin@example.com', + settings: { + notification: { + organization: { + // adminSpaceCommunityInvitation key absent (pre-backfill row) + adminMentioned: { email: true, inApp: true, push: true }, + }, + }, + }, + credentials: [], + } as unknown as IUser; + + vi.mocked(userLookupService.usersWithCredentials).mockResolvedValue([ + legacyAdmin, + ]); + vi.mocked(userLookupService.getUsersByIds).mockImplementation( + async (ids: string[]) => (ids.length > 0 ? [legacyAdmin] : []) + ); + + const result = await service.getRecipients({ + eventType: + NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION, + organizationID: 'org-1', + }); + + expect(result.emailRecipients).toHaveLength(1); + expect(result.inAppRecipients).toHaveLength(1); + expect(result.pushRecipients).toHaveLength(1); + }); + }); + describe('poll notification events (T065)', () => { it('(a) POLL_VOTE_CAST_ON_OWN_POLL uses USER_SELF_MANAGEMENT credential for the poll creator', async () => { await service.getRecipients({ @@ -947,6 +1168,80 @@ describe('NotificationRecipientsService', () => { expect(groupResult.emailRecipients).toHaveLength(0); expect(groupResult.pushRecipients).toHaveLength(1); }); + + it('061: falls back to the PREDECESSOR (communityNewMember) — not a flat all-on — for a row that predates communityInvitationResponse', async () => { + // `communityInvitationResponse` was split out of `communityNewMember` + // by migration 1788600000000, which seeds it from + // `COALESCE(notification #> '{space,admin,communityNewMember}', default)` + // precisely so a Space admin who muted "a new member joined" stays + // muted for the event carved out of it. The read path must agree with + // the migration, or a row it has not reached (rolling deploy, old-pod + // insert, pre-migration restore) is silently un-muted on all three + // channels. + const mutedAdmin = { + id: 'admin-muted', + email: 'muted@example.com', + settings: { + notification: { + space: { + admin: { + communityNewMember: { + email: false, + inApp: false, + push: false, + }, + // communityInvitationResponse absent + }, + }, + }, + }, + credentials: [], + } as unknown as IUser; + + vi.mocked(userLookupService.usersWithCredentials).mockResolvedValue([ + mutedAdmin, + ]); + vi.mocked(userLookupService.getUsersByIds).mockImplementation( + async (ids: string[]) => (ids.length > 0 ? [mutedAdmin] : []) + ); + + const result = await service.getRecipients({ + eventType: + NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED, + spaceID: 'space-1', + userID: 'inviter-1', + }); + + expect(result.emailRecipients).toHaveLength(0); + expect(result.inAppRecipients).toHaveLength(0); + expect(result.pushRecipients).toHaveLength(0); + }); + + it('061: falls back to the mandated all-on default when neither communityInvitationResponse nor its predecessor is present', async () => { + const legacyAdmin = { + id: 'admin-legacy', + email: 'legacy-admin@example.com', + settings: { notification: { space: { admin: {} } } }, + credentials: [], + } as unknown as IUser; + + vi.mocked(userLookupService.usersWithCredentials).mockResolvedValue([ + legacyAdmin, + ]); + vi.mocked(userLookupService.getUsersByIds).mockImplementation( + async (ids: string[]) => (ids.length > 0 ? [legacyAdmin] : []) + ); + + const result = await service.getRecipients({ + eventType: + NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED, + spaceID: 'space-1', + userID: 'inviter-1', + }); + + expect(result.emailRecipients).toHaveLength(1); + expect(result.pushRecipients).toHaveLength(1); + }); }); describe('getRecipients - authorization policy retrieval', () => { diff --git a/src/services/api/notification-recipients/notification.recipients.service.ts b/src/services/api/notification-recipients/notification.recipients.service.ts index 6571c82fa1..eee4988659 100644 --- a/src/services/api/notification-recipients/notification.recipients.service.ts +++ b/src/services/api/notification-recipients/notification.recipients.service.ts @@ -1,3 +1,4 @@ +import { ORGANIZATION_NOTIFICATION_CREDENTIAL_TYPES } from '@common/constants/authorization'; import { AuthorizationCredential, AuthorizationPrivilege, @@ -16,6 +17,10 @@ import { OrganizationLookupService } from '@domain/community/organization-lookup import { IUser } from '@domain/community/user/user.interface'; import { UserLookupService } from '@domain/community/user-lookup/user.lookup.service'; import { IUserSettingsNotificationChannels } from '@domain/community/user-settings/user.settings.notification.channels.interface'; +import { + DEFAULT_INVITATION_RESPONSE_CHANNELS, + DEFAULT_ORGANIZATION_SPACE_INVITATION_CHANNELS, +} from '@domain/community/user-settings/user.settings.notification.defaults.constants'; import { IUserSettingsNotification } from '@domain/community/user-settings/user.settings.notification.interface'; import { VirtualActorLookupService } from '@domain/community/virtual-contributor-lookup/virtual.contributor.lookup.service'; import { SpaceLookupService } from '@domain/space/space.lookup/space.lookup.service'; @@ -363,11 +368,52 @@ export class NotificationRecipientsService { notificationSettings.space?.collaborationCalloutReaction ?? DEFAULT_CALLOUT_REACTION_CHANNELS ); - case NotificationEvent.SPACE_ADMIN_VIRTUAL_COMMUNITY_INVITATION_DECLINED: - return notificationSettings.space.admin.communityNewMember; case NotificationEvent.VIRTUAL_ADMIN_SPACE_COMMUNITY_INVITATION: return notificationSettings.virtualContributor .adminSpaceCommunityInvitation; + case NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION: + // Defend on read against a row that predates the backfill migration + // or was inserted by an old pod during a rolling deploy. + // `UserSettings.applyOrganizationSpaceInvitationDefaults` + // (@AfterLoad) already heals entity-loaded rows; this covers other + // load paths. + return ( + notificationSettings.organization?.adminSpaceCommunityInvitation ?? + DEFAULT_ORGANIZATION_SPACE_INVITATION_CHANNELS + ); + case NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED: + // Same control as the invitation itself: the "your organization has + // joined" notice is the closing half of that lifecycle, so it is not + // given a separate settings row. + return ( + notificationSettings.organization?.adminSpaceCommunityInvitation ?? + DEFAULT_ORGANIZATION_SPACE_INVITATION_CHANNELS + ); + // Every "someone responded to an invitation you sent" event shares one + // control, distinct from "a new member joined" — accepting an + // invitation is a response to the recipient's own action, not an + // unprompted join. + case NotificationEvent.SPACE_ADMIN_VIRTUAL_COMMUNITY_INVITATION_DECLINED: + case NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED: + case NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED: + case NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED: + case NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED: + // Defend on read against a row that predates the backfill migration + // (1788600000000) or was inserted by an old pod during a rolling + // deploy. `UserSettings.applyInvitationResponseDefaults` (@AfterLoad) + // already heals entity-loaded rows; this covers other load paths. + // + // The fallback is the row's PREDECESSOR (`communityNewMember`) before + // the mandated default, mirroring the migration's `COALESCE`: an + // admin who deliberately muted "a new member joined" must not be + // silently re-enabled on all three channels for the event that was + // split out of it. Only a row with neither key gets the all-on + // default, and such a user was already all-on. + return ( + notificationSettings.space?.admin?.communityInvitationResponse ?? + notificationSettings.space?.admin?.communityNewMember ?? + DEFAULT_INVITATION_RESPONSE_CHANNELS + ); // Fixed values case NotificationEvent.USER_SIGN_UP_WELCOME: @@ -501,7 +547,17 @@ export class NotificationRecipientsService { break; } case NotificationEvent.SPACE_ADMIN_VIRTUAL_COMMUNITY_INVITATION_DECLINED: { - // Notify the space admin who sent the VC invitation + // Notify the space admin who sent the VC invitation. + // + // ASYMMETRY, deliberate and out of scope to change: this event now + // shares the `space.admin.communityInvitationResponse` setting with + // the four organization/user outcome events (R27), but keeps its + // pre-existing inviter-only audience, while those four fan out to + // every Space admin (R28/FR-020). One toggle, two audiences. + // Widening this one would change Virtual-Contributor behaviour that + // `server#4100` does not otherwise touch, and it is unchanged from + // `develop` — including that an inviter who has since been deleted + // resolves to nobody. Recorded rather than silently inherited. privilegeRequired = AuthorizationPrivilege.RECEIVE_NOTIFICATIONS_ADMIN; credentialCriteria = this.getUserSelfCriteria(userID); break; @@ -512,6 +568,44 @@ export class NotificationRecipientsService { await this.getVirtualContributorCriteria(virtualContributorID); break; } + case NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION: { + // Resolved by ADMIN standing, not the associate sweep the two + // shipped organization events use — an admin who is not an + // associate is still notified. + privilegeRequired = AuthorizationPrivilege.RECEIVE_NOTIFICATIONS_ADMIN; + credentialCriteria = + this.getOrganizationAdminCredentialCriteria(organizationID); + break; + } + case NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED: + case NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED: + case NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED: + case NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED: { + // EVERY admin of the Space, not only the one who sent the invitation + // (product email: "Space admin(s) gets notification that the + // organization has accepted or rejected their invitation"). This + // event is the replacement for the generic "a new member joined" + // notification, which R26 suppresses for invitation-sourced + // memberships; sending it only to the inviter would leave every + // co-admin — and a Space whose inviter has since been deleted or + // demoted — with no notification at all. + privilegeRequired = AuthorizationPrivilege.RECEIVE_NOTIFICATIONS_ADMIN; + credentialCriteria = this.getSpaceAdminCredentialCriteria(spaceID); + break; + } + case NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED: { + // Every ADMIN of the organization that just joined. Resolved by ADMIN + // standing, not associate membership, exactly as the invitation event + // is. This resolves the raw ADMIN set; the admin who accepted is + // filtered out of it downstream, on all three channels, by + // `notification.organization.adapter.ts` (R33) — the welcome exists to + // tell the OTHER admins no action is needed, so if the acceptor is the + // only admin the event ends with no recipients and is not sent. + privilegeRequired = AuthorizationPrivilege.RECEIVE_NOTIFICATIONS_ADMIN; + credentialCriteria = + this.getOrganizationAdminCredentialCriteria(organizationID); + break; + } case NotificationEvent.USER_CONVERSATION_MESSAGE_DIRECT: case NotificationEvent.USER_CONVERSATION_MESSAGE_GROUP: { // 034-messaging-notifications (FR-005/FR-020, D-13): recipients are @@ -548,7 +642,9 @@ export class NotificationRecipientsService { return await this.platformAuthorizationService.getPlatformAuthorizationPolicy(); } case NotificationEvent.ORGANIZATION_ADMIN_MESSAGE: - case NotificationEvent.ORGANIZATION_ADMIN_MENTIONED: { + case NotificationEvent.ORGANIZATION_ADMIN_MENTIONED: + case NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_INVITATION: + case NotificationEvent.ORGANIZATION_ADMIN_SPACE_COMMUNITY_JOINED: { // get the organization authorization policy if (!organizationID) { throw new ValidationException( @@ -574,6 +670,10 @@ export class NotificationRecipientsService { case NotificationEvent.SPACE_ADMIN_COMMUNITY_NEW_MEMBER: case NotificationEvent.SPACE_ADMIN_COLLABORATION_CALLOUT_CONTRIBUTION: case NotificationEvent.SPACE_ADMIN_VIRTUAL_COMMUNITY_INVITATION_DECLINED: + case NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_ACCEPTED: + case NotificationEvent.SPACE_ADMIN_ORGANIZATION_COMMUNITY_INVITATION_DECLINED: + case NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_ACCEPTED: + case NotificationEvent.SPACE_ADMIN_USER_COMMUNITY_INVITATION_DECLINED: case NotificationEvent.SPACE_COLLABORATION_CALLOUT_POST_CONTRIBUTION_COMMENT: case NotificationEvent.SPACE_COLLABORATION_CALLOUT_CONTRIBUTION: case NotificationEvent.SPACE_COLLABORATION_CALLOUT_COMMENT: @@ -682,6 +782,35 @@ export class NotificationRecipientsService { ]; } + /** + * The organization's ADMINS — resolved by admin standing, not by associate + * membership and not by ownership. R17b: product ruled that these + * notifications go to admins only, so this deliberately uses + * `ORGANIZATION_NOTIFICATION_CREDENTIAL_TYPES` rather than the broader + * `ORGANIZATION_MANAGER_CREDENTIAL_TYPES` that `getActorsManagedByUser` + * uses. Do not add the owner credential back: an owner who is not an admin + * has no settings row governing these events and so could not mute them. + * One criterion per credential type; the recipients query OR-combines them. + */ + private getOrganizationAdminCredentialCriteria( + organizationID: string | undefined + ): CredentialsSearchInput[] { + if (!organizationID) { + throw new ValidationException( + 'Organization ID is required for notification recipients', + LogContext.NOTIFICATIONS + ); + } + // ADMIN only — not ORGANIZATION_MANAGER_CREDENTIAL_TYPES. Product asked + // for "all organization admins" (server#4100 / notifications#356 / the + // product email thread); an owner who is not also an admin can still + // accept on the organization's behalf but is not notified. + return ORGANIZATION_NOTIFICATION_CREDENTIAL_TYPES.map(type => ({ + type, + resourceID: organizationID, + })); + } + private getSpaceCredentialCriteria( spaceID: string | undefined ): CredentialsSearchInput[] { diff --git a/src/services/infrastructure/url-generator/url.generator.service.spec.ts b/src/services/infrastructure/url-generator/url.generator.service.spec.ts index 19f9642297..688f8e7f7f 100644 --- a/src/services/infrastructure/url-generator/url.generator.service.spec.ts +++ b/src/services/infrastructure/url-generator/url.generator.service.spec.ts @@ -572,6 +572,23 @@ describe('UrlGeneratorService', () => { }); }); + describe('getOrganizationSettingsInvitationsUrlPath', () => { + it('should generate the relative organization Invitations tab path', () => { + const result = service.getOrganizationSettingsInvitationsUrlPath('acme'); + expect(result).toBe('/organization/acme/settings/invitations'); + }); + }); + + describe('createUrlForOrganizationSettingsInvitations', () => { + it('should generate the absolute organization Invitations tab URL', () => { + const result = + service.createUrlForOrganizationSettingsInvitations('acme'); + expect(result).toBe( + `${ENDPOINT}/${UrlPathBase.ORGANIZATION}/acme/settings/invitations` + ); + }); + }); + describe('getCalendarEventIcsRestUrl', () => { it('should generate the correct ICS REST URL', () => { const configService = { diff --git a/src/services/infrastructure/url-generator/url.generator.service.ts b/src/services/infrastructure/url-generator/url.generator.service.ts index d4d1cef639..eb722f9996 100644 --- a/src/services/infrastructure/url-generator/url.generator.service.ts +++ b/src/services/infrastructure/url-generator/url.generator.service.ts @@ -265,6 +265,24 @@ export class UrlGeneratorService { return `${this.endpoint_cluster}/${UrlPathBase.ORGANIZATION}/${organizationNameID}`; } + /** + * Relative deep-link path to an organization's Invitations settings tab — + * used for the push notification `url` (client-side navigation) and as + * the in-app override the client builds from `organization.profile.url`. + */ + public getOrganizationSettingsInvitationsUrlPath( + organizationNameID: string + ): string { + return `/${UrlPathBase.ORGANIZATION}/${organizationNameID}/${UrlPathElementSpace.SETTINGS}/invitations`; + } + + /** Absolute URL to an organization's Invitations settings tab — the email call-to-action. */ + public createUrlForOrganizationSettingsInvitations( + organizationNameID: string + ): string { + return `${this.createUrlForOrganizationNameID(organizationNameID)}/${UrlPathElementSpace.SETTINGS}/invitations`; + } + public createUrlForUserNameID(userNameID: string): string { return `${this.endpoint_cluster}/${UrlPathBase.USER}/${userNameID}`; } diff --git a/src/types/alkemio.config.ts b/src/types/alkemio.config.ts index b8f837fffa..18766764c3 100644 --- a/src/types/alkemio.config.ts +++ b/src/types/alkemio.config.ts @@ -296,6 +296,10 @@ export type AlkemioConfig = { /** Leading-edge email suppression window per (recipient, callout) in seconds. */ email_suppression_window_seconds: number; }; + organization_invitations: { + /** Destination for the zero-admin escalation email; never required to boot. */ + support_email: string; + }; messaging: { enabled: boolean; /** diff --git a/test/data/organization.mock.ts b/test/data/organization.mock.ts index 4b39f07112..5fd915db25 100644 --- a/test/data/organization.mock.ts +++ b/test/data/organization.mock.ts @@ -195,7 +195,10 @@ export const organizationData: { organization: IOrganization } = { rowId: 1, settings: { privacy: { contributionRolesPubliclyVisible: true }, - membership: { allowUsersMatchingDomainToJoin: false }, + membership: { + allowUsersMatchingDomainToJoin: false, + allowSpaceInvitations: true, + }, }, }, }; diff --git a/test/data/user.settings.mock.ts b/test/data/user.settings.mock.ts index 0bd86cd6c1..dabcdea348 100644 --- a/test/data/user.settings.mock.ts +++ b/test/data/user.settings.mock.ts @@ -26,6 +26,11 @@ export const userSettingsData: { userSettings: IUserSettings } = { inApp: true, push: true, }, + communityInvitationResponse: { + email: true, + inApp: true, + push: true, + }, communicationMessageReceived: { email: true, inApp: true, @@ -151,6 +156,11 @@ export const userSettingsData: { userSettings: IUserSettings } = { inApp: true, push: true, }, + adminSpaceCommunityInvitation: { + email: true, + inApp: true, + push: true, + }, }, virtualContributor: { adminSpaceCommunityInvitation: { diff --git a/test/integration/email-change/user-email-change-space-fanout.spec.ts b/test/integration/email-change/user-email-change-space-fanout.spec.ts index 5dcea845a6..5b9ceef641 100644 --- a/test/integration/email-change/user-email-change-space-fanout.spec.ts +++ b/test/integration/email-change/user-email-change-space-fanout.spec.ts @@ -213,7 +213,8 @@ function makeHarness({ userLookupService, undefined as any, // calloutLookupService — unused undefined as any, // calloutReactionEmailSuppressionService — unused - undefined as any // configService — unused + undefined as any, // configService — unused + undefined as any // actorLookupService — unused ); const notificationPlatformAdapter = { diff --git a/test/schema-contract/governance/override.spec.ts b/test/schema-contract/governance/override.spec.ts index e9e4d91469..d66c66f2ac 100644 --- a/test/schema-contract/governance/override.spec.ts +++ b/test/schema-contract/governance/override.spec.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { applyOverrides } from '../../../src/schema-contract/governance/apply-overrides'; import { ChangeEntry, diff --git a/test/schema/override-fetch.spec.ts b/test/schema/override-fetch.spec.ts index e17aedeb7a..d8a73c3e06 100644 --- a/test/schema/override-fetch.spec.ts +++ b/test/schema/override-fetch.spec.ts @@ -1,6 +1,6 @@ -import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { vi } from 'vitest'; import { performOverrideEvaluationAsync } from '../../src/tools/schema/override';