Invite & Notification Pipeline โ Developer Guide
Overview
When a Malet Owner invites a team member to their Organization, the platform must:
- Create a Pending Invitation record with a unique token
- Resolve the invitee's identity (email โ userId)
- Emit an event to the Alerts service
- Deliver a rich HTML email and an IN_APP notification with inline Accept/Decline actions
- Accept or decline via token-based
acceptInvitationmutation
This document covers the full cross-subgraph architecture that powers this flow, spanning the organizations, nodes, and alerts services.
Architecture
sequenceDiagram
participant UI as Frontend (Manage Page)
participant GW as Gateway
participant ORG as Organizations Subgraph
participant NODES as Nodes Subgraph (TCP:3011)
participant ALERTS as Alerts Subgraph (TCP:3012)
participant DB as MongoDB
UI->>GW: sendInvitation(orgId, email, role)
GW->>ORG: GraphQL mutation
ORG->>DB: invitations.create({ token, status: PENDING })
ORG->>NODES: check_user_exists({ email })
NODES->>DB: users.findOne({ email })
NODES-->>ORG: { exists: true, userId: "5575a..." }
ORG->>ALERTS: emit('org_invite_created', { token, userId, ... })
ALERTS->>DB: alertLogs.create({ channel: EMAIL })
ALERTS->>DB: alertLogs.create({ channel: IN_APP, payload: { token } })
ALERTS-->>UI: Rich HTML email + IN_APP notification
UI->>ALERTS: myNotifications (30s poll)
ALERTS-->>UI: Notification with Accept/Decline buttons ๐
UI->>GW: acceptInvitation(token)
GW->>ORG: Creates Membership, marks invitation ACCEPTED
Email โ userId Resolution
The inviteMember mutation accepts a targetUserId field that can contain either an email address or a UUID. The resolver detects which format was provided and resolves accordingly:
const isEmail = targetId.includes('@');
if (isEmail) {
// Cross-subgraph lookup via TCP
const result = await firstValueFrom(
this.nodesClient.send('check_user_exists', { email: targetId }).pipe(
timeout(3000),
catchError(() => of({ exists: false }))
)
);
if (result?.exists && result?.userId) {
memberUserId = result.userId; // Use UUID for membership
}
} else {
// targetId is already a UUID โ look up email for notifications
const result = await firstValueFrom(
this.nodesClient.send('get_user_preferences', { userId: targetId }).pipe(
timeout(3000),
catchError(() => of(null))
)
);
resolvedEmail = result?.email;
}
Resolution Matrix
| Input Type | Membership userId |
Notification email |
Notification userId |
|---|---|---|---|
| Email (account exists) | Resolved UUID | Original email | Resolved UUID |
| Email (no account) | Email as fallback | Original email | undefined |
| UUID | UUID as-is | Resolved from preferences | UUID |
IMPORTANT
The emailโuserId resolution happens before the Membership document is created. This ensures the Membership record always stores the real UUID (not the email), which is critical for the Visitor seeing the Organization in their dashboard.
Nodes Service: `check_user_exists`
The nodes subgraph exposes a TCP @MessagePattern that searches the users collection:
@MessagePattern('check_user_exists')
async handleCheckUserExists(@Payload() data: { email: string }) {
return this.appService.checkUserExists(data.email);
}
The service performs a case-insensitive regex search and returns both the existence flag and the userId:
async checkUserExists(email: string): Promise<{ exists: boolean; userId?: string }> {
const user = await this.userModel
.findOne({ email: { $regex: new RegExp(`^${escaped}$`, 'i') } })
.select('userId')
.lean()
.exec();
return user ? { exists: true, userId: user.userId } : { exists: false };
}
Event Payload: `org_invite_created`
The inviteMember resolver emits this event to the Alerts service via TCP:
this.alertsClient.emit('org_invite_created', {
organizationName: org.name, // "Mallnline"
organizationId: org.id, // "gkmxf5qaddhztuvy85cdu"
email: resolvedEmail, // "invitee@example.com"
role: input.role, // "MEMBER" | "ADMIN"
invitedByName: actor.displayName,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
userId: resolvedUserId // UUID or undefined
});
DTO Validation (`OrgInviteDto`)
The Alerts service validates the payload with class-validator:
export class OrgInviteDto {
@IsString() @IsNotEmpty() organizationName: string;
@IsString() @IsNotEmpty() organizationId: string;
@IsEmail() email: string;
@IsString() @IsOptional() token?: string; // Optional for direct-add flow
@IsString() @IsNotEmpty() role: string;
@IsString() @IsNotEmpty() invitedByName: string;
@IsString() @IsOptional() expiresAt?: string;
@IsString() @IsOptional() userId?: string; // Required for IN_APP channel
}
WARNING
The token field is @IsOptional() because the inviteMember flow creates a direct Membership (no invitation token). The sendInvitation flow in invitation.resolver.ts does generate a token. Both flows emit the same event shape.
Alerts Service: Dual-Channel Delivery
The GmailAlertsController handles the event and branches into two channels:
@EventPattern('org_invite_created')
@UsePipes(new ValidationPipe())
async orgInviteCreated(@Payload() data: OrgInviteDto) {
// 1. Send rich HTML invitation email
await this.gmailAlertsService.sendOrgInvitation(data);
// 2. Send IN_APP only if userId is present (account exists)
if (data.userId) {
await this.deliveryService.sendInApp({
userId: data.userId,
title: 'Organization Invitation',
message: `${data.invitedByName} invited you to join ${data.organizationName} as ${data.role}`,
eventType: 'org_invite_created',
href: '/orgs',
payload: { ...data }, // token included for frontend Accept action
});
}
}
The email uses a rich Handlebars template (org-invitation.hbs) with gradient header, role badge, and a prominent "Accept Invitation" CTA button.
Frontend: Inline Accept/Decline Actions
The notificationStore.svelte.ts extracts the inviteToken from the notification payload:
inviteToken: raw.payload?.token || null,
orgName: raw.payload?.organizationName || null,
Both the NotificationCenter.svelte bell dropdown and the full /notifications page render Accept/Decline buttons:
{#if n.inviteToken}
<div class="invite-actions">
<button onclick={(e) => handleAcceptInvite(e, n)}>โ Accept</button>
<button onclick={(e) => handleDeclineInvite(e, n.id)}>Decline</button>
</div>
{/if}
Acceptance calls the ACCEPT_INVITATION mutation:
mutation AcceptInvitation($token: String!) {
acceptInvitation(token: $token) {
id
status
role
organizationId
}
}
Channel Decision Logic
| Scenario | IN_APP | Rationale | |
|---|---|---|---|
| Existing Mallnline account | โ | โ | Visitor can see notification in-app |
| No Mallnline account | โ | โ | No userId to associate the notification with |
Frontend: Notification Polling
The notificationStore.svelte.ts polls the Alerts subgraph every 30 seconds:
const POLL_INTERVAL = 30_000;
async function fetchNotifications() {
const data = await client.request(MY_ALERT_LOGS, {
filter: { first: 50 }
});
// Map server AlertLogs โ frontend notification format
}
The NotificationCenter.svelte component displays a bell icon with an unread badge. Clicking reveals a dropdown with recent notifications, category filters, and a "View All" link to /notifications.
GraphQL Queries
query MyAlertLogs($filter: AlertLogFilterInput) {
myAlertLogs(filter: $filter) {
id
channel
status
subject
eventType
payload
createdAt
}
}
mutation MarkAlertRead($id: ID!) {
markAlertRead(id: $id) {
id
status
}
}
mutation MarkAllAlertsRead {
markAllAlertsRead
}
E2E Contract Test
The invite-notification.e2e-spec.ts test validates the full provider contract:
npx jest --config apps/organizations/test/jest-e2e.json \
--testPathPattern invite-notification --forceExit
Test Cases (5 passing)
| Test | Validates |
|---|---|
| Email invite โ resolved userId | check_user_exists called, UUID used in event |
| UUID invite โ email lookup | get_user_preferences called, email in event |
| Nodes lookup failure | Event still emits with userId: undefined |
| Duplicate rejection | "already a member" error on second invite |
| DTO schema contract | Payload matches OrgInviteDto field types |
The tests mock both ALERTS_SERVICE (spy on .emit()) and NODES_SERVICE (rxjs of() observables) to validate the contract without requiring live TCP connections.
Environment Variables
| Variable | Default | Service | Description |
|---|---|---|---|
ALERTS_SERVICE_PORT |
3025 |
alerts | HTTP/GraphQL port |
ALERTS_SERVICE_PORT_TCP |
3012 |
alerts | TCP microservice port |
NODES_SERVICE_PORT_TCP |
3011 |
nodes | TCP microservice port |
ALERTS_SERVICE_URL |
โ | gateway | Federation endpoint URL |
Module Wiring
The OrganizationModule registers both TCP clients:
ClientsModule.register([
{
name: 'ALERTS_SERVICE',
transport: Transport.TCP,
options: { port: configService.get('ALERTS_SERVICE_PORT_TCP') },
},
{
name: NODES_SERVICE,
transport: Transport.TCP,
options: { port: configService.get('NODES_SERVICE_PORT_TCP') },
},
]),
Member Handle Resolution
The org member list and audit log resolve raw user IDs into human-readable handles via a GET_USER_BY_ID query:
query GetUserById($id: ID!) {
user(id: $id) {
id
displayName
handle
avatarUrl
}
}
The resolution follows a priority order: @handle โ displayName โ truncated UUID. Results are cached per page load to avoid redundant queries.
Known Issues
| Issue | Status | Tracking |
|---|---|---|
| WebSocket subscription (replace 30s polling) | ๐ P1 | BACKEND_TODO |
| Raw ID exposure audit (full frontend scan) | ๐ P0 | FRONTEND_TODO |
| uChat โ Alerts integration | ๐ P4 | BACKEND_TODO |
Related
- Alerts Resilience & Delivery Tracking โ DLQ retry logic, AlertLog entity, and delivery orchestration
- Alerts & Notifications โ Frontend toast system, NotificationCenter component, and session warnings
- Teams & Sub-Groups โ Team membership management within Organizations
- Custom RBAC โ Permission matrix that gates the
MANAGE_MEMBERSpermission used bysendInvitation - Handle System โ Sigil taxonomy and the
v|handleclaim lifecycle - uMail Domain Verification โ DNS setup for
mallnline.comoutbound email authentication