Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
320259e
feat(061): add organization-invite result enums and support-email config
ccanos Sep 3, 2026
7b8e913
refactor(061): share the organization-manager credential-type constant
ccanos Sep 3, 2026
01491bb
feat(061): add organization opt-out setting for Space invitations
ccanos Sep 3, 2026
e46a6d2
feat(061): add user notification setting for organization Space invit…
ccanos Sep 3, 2026
1afac8d
feat(061): guard organization invitations at invite time
ccanos Sep 3, 2026
0983afb
feat(061): wire organization-invitation events into the recipients se…
ccanos Sep 3, 2026
1d98b8c
feat(061): compute the Spaces a pending invitation joins on acceptance
ccanos Sep 3, 2026
e26b362
feat(061): add organization-invitation URL helpers and email payload …
ccanos Sep 3, 2026
5f70619
feat(061): dispatch the organization-invited notification
ccanos Sep 3, 2026
879e976
feat(061): zero-admin notice and exhaustive invitation notification d…
ccanos Sep 3, 2026
5765cd1
feat(061): resolve organization and invitation on the community-invit…
ccanos Sep 3, 2026
da0171f
feat(061): notify the inviter when an organization accepts or declines
ccanos Sep 3, 2026
86998e9
feat(061): hook the organization accept/decline outcome into eventOnI…
ccanos Sep 3, 2026
d1e7774
test(061): add an exhaustiveness spec for the three organization-invi…
ccanos Sep 3, 2026
49ae0de
fix(061): require the invite-accept privilege, not generic UPDATE, to…
ccanos Sep 4, 2026
240ef13
fix(061): load the profile relation when resolving an organization fo…
ccanos Sep 4, 2026
381d7dd
fix(061): count only genuinely pending Lead invitations for the org s…
ccanos Sep 4, 2026
a145d2a
fix(061): hoist the Lead-slot pending count out of the per-invitee loop
ccanos Sep 4, 2026
0dad278
fix(061): stop leaking private ancestor-Space About via spacesToJoinO…
ccanos Sep 4, 2026
840a2f6
fix(061): stop dropping Spaces the consenting org admin cannot person…
ccanos Sep 4, 2026
64bf8cf
perf(061): parallelize the invite-accept ancestor-chain lookups
ccanos Sep 4, 2026
bb7a808
test(061): drive the muted-admin negative test from the filter, not a…
ccanos Sep 4, 2026
f45bfca
test(061): pin the bulk-invite ArrayMaxSize boundary
ccanos Sep 4, 2026
7d5dae8
fix(061): cap extraRoles and validate organization roles with a singl…
ccanos Sep 4, 2026
7ffe349
Merge remote-tracking branch 'origin/develop' into server-4100
ccanos Sep 7, 2026
31d34c5
feat(061): split invitation-response notifications and stop the doubl…
ccanos Sep 7, 2026
5b4dfde
fix(061): reconcile the org-invitation notifications with the product…
ccanos Sep 7, 2026
a6ba4ca
Merge branch 'develop' into server-4100
ccanos Sep 7, 2026
8e9d891
fix(061): notify organization ADMINS only, never owners
ccanos Sep 7, 2026
f614355
Merge remote-tracking branch 'origin/server-4100' into server-4100
ccanos Sep 7, 2026
981f39a
fix(061): two crash paths on legacy settings rows, plus docs that con…
ccanos Sep 7, 2026
e15e9b0
fix(061): stop silencing approved applications, guard spacesToJoinOnA…
ccanos Sep 8, 2026
51f4ccd
Merge remote-tracking branch 'origin/develop' into server-4100
ccanos Sep 8, 2026
cb1d23b
fix(061): review follow-ups — FK scope, migrations, exhaustiveness, d…
ccanos Sep 8, 2026
df4e4c3
revert(061): drop the stray Synapse homeserver rename
ccanos Sep 8, 2026
3698200
fix(061): close the REINVITE opt-out bypass, narrow the consent previ…
ccanos Sep 8, 2026
2fdcd57
fix(061): a Space admin can manage an organization already in their S…
ccanos Sep 8, 2026
ed91d7f
fix(061): the Space-side outcome self-notified on email and in-app (R33)
ccanos Sep 8, 2026
13bfdf1
fix(061): R34/R35/R36 — invite-notification symmetry, application sup…
ccanos Sep 9, 2026
2befb77
fix(061): R34 was applied to two flows it has nothing to do with
ccanos Sep 9, 2026
40d6f0d
Merge remote-tracking branch 'origin/develop' into server-4100
ccanos Sep 9, 2026
604471a
Merge remote-tracking branch 'origin/develop' into server-4100
ccanos Sep 9, 2026
9bfaf4b
fix(061): stop concurrent creations sharing one role-credential object
ccanos Sep 9, 2026
714a80e
Merge remote-tracking branch 'origin/develop' into server-4100
ccanos Sep 9, 2026
e9c0552
Merge remote-tracking branch 'origin/develop' into server-4100
ccanos Sep 10, 2026
53979f6
fix(061): approved applications keep the "new member joined" notifica…
ccanos Sep 10, 2026
e9848a7
little refactor
ccanos Sep 10, 2026
a66afdf
Merge branch 'develop' into server-4100
ccanos Sep 11, 2026
4415727
chore: bump notifications image to v0.39.0 in the dev stacks
ccanos Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 6 additions & 0 deletions alkemio.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion quickstart-services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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!
"""
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -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!
}
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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!
"""
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
"""
Expand Down
1 change: 1 addition & 0 deletions src/common/constants/authorization/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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];
9 changes: 9 additions & 0 deletions src/common/constants/entity.field.length.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions src/common/enums/community.membership.origin.ts
Original file line number Diff line number Diff line change
@@ -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',
}
6 changes: 6 additions & 0 deletions src/common/enums/notification.event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/common/enums/role.set.invitation.result.notice.ts
Original file line number Diff line number Diff line change
@@ -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',
});
2 changes: 2 additions & 0 deletions src/common/enums/role.set.invitation.result.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
54 changes: 54 additions & 0 deletions src/config/organization-invitation-notifications.config.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined> = {};

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'
);
});
});
Loading
Loading