Developer Docs

Organizations Subgraph

The Organizations subgraph powers collaborative Malet ownership on Ngwenya. It provides team-based access control, invitation workflows, vertical-specific role assignment, and comprehensive audit logging โ€” enabling the transformation from single-owner Malets to enterprise-scale collaborative storefronts.

Port: 3006 ยท Federation: Apollo v2 ยท Database: MongoDB (organizations, memberships, invitations, org_audit_events collections)


Architecture

The subgraph uses a domain-partitioned directory structure with a single flat NestJS module for dependency injection stability:

apps/organizations/src/
โ”œโ”€โ”€ organization/   โ€” Core entity, resolvers, types, loader, malet federation
โ”œโ”€โ”€ membership/     โ€” Membership entity, resolver, permissions service, vertical roles
โ”œโ”€โ”€ invitation/     โ€” Invitation entity, resolver, token-based acceptance
โ”œโ”€โ”€ audit/          โ€” OrgAuditEvent entity, service, resolver
โ”œโ”€โ”€ webhook/        โ€” WebhookSubscription, WebhookDelivery, dispatch service for platform events
โ”œโ”€โ”€ guards/         โ€” PermissionsGuard (RBAC enforcement)
โ””โ”€โ”€ decorators/     โ€” @RequirePermission (shared via @app/common)

All providers are registered in a single OrganizationModule โ€” the directory separation is purely organizational. Cross-subgraph shared types (Permission enum, RequirePermission decorator) are exported from @app/common.


Core Entities

Organization

The top-level container for team collaboration. A Malet Owner creates an Organization, then invites team members.

type Organization @key(fields: "id") {
	id: ID!
	orgID: String! # Human-readable identifier (e.g. "ORG-A1B2C3")
	name: String!
	slug: String! # URL-safe identifier
	description: String
	category: String # Vertical type (tour, restaurant, photographer, author)
	membersCount: Int!
	maletsCount: Int!
	settings: OrgSettings # Billing + notification preferences
	members: [Membership!]! # Relay connection
	activityFeeds: OrgAuditEventConnection! # Cursor-paginated audit events
	createdAt: DateTime!
	updatedAt: DateTime!
}

Membership

Represents a user's membership in an organization with role and optional vertical-specific role.

type Membership @key(fields: "id") {
	id: ID!
	organizationId: String!
	userId: String!
	role: OrgRole! # OWNER | ADMIN | MEMBER | VIEWER
	verticalRole: VerticalRoleType # GUIDE | KITCHEN | PHOTOGRAPHER | etc.
	permissions: [Permission!]! # Computed from role + verticalRole
	organization: Organization!
	joinedAt: DateTime!
}

Invitation

Token-based invitation for joining an organization. Invitations expire after 7 days and support revocation.

type Invitation @key(fields: "id") {
	id: ID!
	token: String! # 32-char secure token
	organizationId: String!
	email: String!
	role: OrgRole!
	status: InvitationStatus! # PENDING | ACCEPTED | REVOKED | EXPIRED
	invitedBy: String!
	expiresAt: DateTime!
	acceptedAt: DateTime
}

Developer Platform Webhooks

Webhooks allow organizations to subscribe to general platform events (commerce, product lifecycle). This uses a fan-out dispatch system with HMAC-SHA256 signature verification and exponential backoff retry.

type WebhookSubscription {
  id: ID!
  organizationId: ID!
  maletId: ID
  name: String!
  url: String!
  events: [PlatformEventType!]!
  isActive: Boolean!
}

type WebhookDelivery {
  id: ID!
  subscriptionId: ID!
  eventType: PlatformEventType!
  status: WebhookDeliveryStatus!
  httpStatus: Int
  attempts: Int!
  error: String
  createdAt: DateTime!
}

Role-Based Access Control (RBAC)

Role Hierarchy

OWNER โ†’ ADMIN โ†’ MEMBER โ†’ VIEWER
Role Capabilities
OWNER Full control โ€” all permissions granted. Can delete the Organization.
ADMIN All permissions. Can manage members, roles, and settings.
MEMBER Base analytics + vertical-specific permissions from verticalRole.
VIEWER Read-only analytics access.

Vertical Roles

Members can be assigned a vertical-specific role that grants additional fine-grained permissions beyond their base OrgRole. These are matched to the Organization's category.

Vertical Roles Key Permissions
Tour Operator GUIDE, BOOKING_MANAGER VIEW_MANIFESTS, MANAGE_BOOKINGS, CONTACT_GUESTS
Restaurant KITCHEN, SERVER ACCESS_KDS, UPDATE_ORDER_STATUS, CREATE_ORDERS
Photography PHOTOGRAPHER, PHOTOGRAPHY_EDITOR UPLOAD_IMAGES, MANAGE_GALLERIES, ACCESS_PROOFING
Author COAUTHOR, AUTHOR_EDITOR EDIT_BLOGS, EDIT_PRODUCTS

Permission Computation

Effective permissions are computed at runtime by combining base role permissions with vertical role permissions:

// PermissionsService.computePermissions()
const effective = [
	...BASE_ROLE_PERMISSIONS[membership.role], // From OrgRole
	...VERTICAL_ROLE_PERMISSIONS[membership.verticalRole] // From VerticalRoleType
];
// โ†’ Deduplicated Permission[] array

Using `@RequirePermission`

The @RequirePermission decorator and Permission enum are available from @app/common for any subgraph:

import { RequirePermission, Permission } from '@app/common';

@UseGuards(PermissionsGuard)
@RequirePermission(Permission.ACCESS_KDS)
@Query(() => [Order])
async kitchenQueue(@Args('orgId') orgId: string) {
  // Only members with ACCESS_KDS permission can reach this
}

The PermissionsGuard automatically:

  1. Extracts x-user-id from the request header
  2. Extracts x-org-id from headers or arguments
  3. Looks up the user's Membership in the Organization
  4. Computes permissions from role + verticalRole
  5. Checks against the required Permission

Invitation Flow

1. Admin calls sendInvitation(orgId, email, role)
     โ†“
2. System creates Invitation with 32-char token (7-day expiry)
     โ†“
3. Event emitted โ†’ Alerts service sends email with invitation link
     โ†“
4. Invitee calls acceptInvitation(token)
     โ†“
5. Membership created, Organization.membersCount incremented
     โ†“
6. Invitation status โ†’ ACCEPTED

Security

  • Tokens are single-use and expire after 7 days
  • Only OWNER or ADMIN roles can send/revoke invitations
  • Duplicate pending invitations for the same email are rejected
  • Already-accepted invitations cannot be reused

Audit System

All organization actions are logged as OrgAuditEvent records with cursor-based pagination.

Event Types

Event Trigger Metadata
ORG_CREATED Organization created { name }
ORG_UPDATED Settings or profile changed { settings: { billing, notifications } }
ORG_DELETED Organization deleted โ€”
MEMBER_INVITED Invitation sent { email, role }
MEMBER_JOINED Invitation accepted { role }
MEMBER_REMOVED Member removed or left { userId }
ROLE_CHANGED Role updated { oldRole, newRole }
VERTICAL_ROLE_CHANGED Vertical role assigned { verticalRole }

Querying the Audit Log

query OrgAuditEvents($orgId: String!, $limit: Int) {
	organizationAuditEvents(orgId: $orgId, limit: $limit) {
		id
		eventType
		actorId
		actor {
			id
			displayName
		}
		targetUserId
		targetUser {
			id
			displayName
		}
		metadata
		createdAt
	}
}

Access requires VIEW_AUDIT_LOGS permission (granted to OWNER and ADMIN roles).


Organization Lifecycle

Creation

When an organization is created:

  1. createOrganization mutation validates name/slug uniqueness
  2. Organization record created with membersCount: 1, maletsCount: 0
  3. Owner membership auto-created with role: OWNER
  4. Events emitted:
    • bootstrap_org_subscription โ†’ payments service creates free Starter subscription
    • org_created โ†’ alerts service sends welcome notification

Deletion (Safety-Guarded)

Deleting an organization requires all safety checks to pass:

Check Guard Error if failed
No active paid subscription TCP query to get_active_tier({ orgId }) "Cannot delete organization with active PRO subscription. Cancel your subscription from the Billing page first."
No owned Malets org.maletsCount === 0 "Cannot delete organization that owns 3 Malet(s). Transfer or delete all Malets before deleting the organization."
Owner role required @RequirePermission(Permission.DELETE_ORG) "Insufficient permissions"

Once all checks pass, the cascade is:

1. Delete all Memberships (organizationId)
2. Delete all Invitations (organizationId)
3. Delete Organization record
4. Create ORG_DELETED audit log
5. Emit organization_deleted โ†’ alerts (notification)

Important: This follows GitHub's organization deletion model. Users must explicitly cancel their subscription and transfer/delete all Malets before the organization can be removed.

Malet Ownership Transfer

Two mutations enable bidirectional Malet ownership changes:

# Personal โ†’ Organization
transferMaletToOrganization(maletId: String!, targetOrgId: String!): Malet!

# Organization โ†’ Personal
transferMaletToUser(maletId: String!): Malet!

Transfer rules:

  • Personal โ†’ Org: Only the current owner can transfer. Sets ownerId = targetOrgId, ownerType = ORGANIZATION
  • Org โ†’ Personal: Sets ownerId = actor.id, ownerType = USER, clears orgId
  • Transfer is instant โ€” all products, orders, and history move with the Malet
  • Transfer is reversible (transfer back at any time)

Known limitation: transferMaletToOrganization does not verify the actor's org membership role. A TODO exists to add admin-level org membership verification via cross-service call.


GraphQL API Reference

Mutations

# Organization Lifecycle
createOrganization(input: CreateOrganizationInput!): Organization!
updateOrganization(input: UpdateOrganizationInput!): Organization!
deleteOrganization(id: ID!): Organization!        # Safety-guarded (see Lifecycle)

# Malet Ownership Transfer
transferMaletToOrganization(maletId: String!, targetOrgId: String!): Malet!
transferMaletToUser(maletId: String!): Malet!

# Membership Management
inviteMember(input: InviteMemberInput!): Organization!
removeMember(orgId: ID!, userId: ID!): Organization!
updateMemberRole(input: UpdateMemberRoleInput!): Organization!
updateMemberVerticalRole(input: UpdateMemberVerticalRoleInput!): Organization!

# Invitation Workflow
sendInvitation(orgId: ID!, email: String!, role: String): Invitation!
acceptInvitation(token: String!): Membership!
revokeInvitation(invitationId: String!): Invitation!

# Webhook Management
createWebhookSubscription(input: CreateWebhookSubscriptionInput!): WebhookSubscriptionWithSecret!
updateWebhookSubscription(input: UpdateWebhookSubscriptionInput!): WebhookSubscription!
deleteWebhookSubscription(orgId: ID!, webhookId: ID!): WebhookSubscription!
rotateWebhookSecret(orgId: ID!, webhookId: ID!): WebhookSubscriptionWithSecret!
testWebhookSubscription(orgId: ID!, webhookId: ID!): WebhookTestResult!

# Notification Testing
sendTestSms(phone: String!, message: String!): Boolean!

Queries

# Organization Lookup
organization(id: ID!): Organization
organizations(filter: OrganizationFilter): OrganizationConnection!
userOrganizations(userId: ID!): [Membership!]!

# Audit & Activity
organizationAuditEvents(orgId: String!, limit: Int, eventType: OrgAuditEventType): [OrgAuditEvent!]!
organizationActivityFeed(orgId: ID!, limit: Int): [OrgAuditEvent!]!

# Invitation
pendingInvitations(orgId: String!): [Invitation!]!

# Webhooks
webhookSubscriptions(orgId: ID!): [WebhookSubscription!]!
webhookDeliveries(orgId: ID!, webhookId: ID!, limit: Int, status: WebhookDeliveryStatus): [WebhookDelivery!]!

Federation Integration

Malet Entity Resolution

The Malet entity is resolved via federation โ€” when a Malet has an orgId, the Organizations subgraph resolves the associated organization field using a DataLoader-backed batch query:

extend type Malet @key(fields: "id") {
	id: ID! @external
	orgId: String @external
	organization: Organization @requires(fields: "orgId")
}

Context Propagation

All Organization-scoped operations use header-based context:

POST /graphql
x-user-id: user_123
x-org-id: org_456
Cookie: __session=...

The PermissionsGuard also supports argument-based org context (orgId or input.orgId) for mutations that include the org ID in their input payload.


Cross-Service Dependencies

Service Integration
auth User identity via x-user-id header
malets Malet federation (Malet.orgId โ†’ Organization)
nodes Actor resolution for member display names
alerts Invitation email dispatch (org_invite_created event)
payments Subscription tier check via TCP (get_active_tier) for deletion guards
gateway Context propagation via x-org-id header

Testing

Suite Tests Coverage
Organization Resolver 12 Create, invite, update, remove, delete
Invitation Resolver 10 Send, accept, revoke, error handling
Permissions Service 10 Role computation, vertical roles, dedup
Permissions Guard 7 Auth, org context, role enforcement
Audit Service 5 Event creation, filtering, user events
Organization Entity 3 Entity instantiation
E2E 5 Audit creation, role changes, access control, settings, activity feed
Total 57 โ€”