Developer Docs

Invite & Notification Pipeline โ€” Developer Guide

Overview

When a Malet Owner invites a team member to their Organization, the platform must:

  1. Create a Pending Invitation record with a unique token
  2. Resolve the invitee's identity (email โ†’ userId)
  3. Emit an event to the Alerts service
  4. Deliver a rich HTML email and an IN_APP notification with inline Accept/Decline actions
  5. Accept or decline via token-based acceptInvitation mutation

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 Email 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