Skip to content

Organization Sync Use Cases

Common patterns for leveraging organization sync in production applications.


Provisioning Workflows

Automatically create resources when new tenants are created.

Database Per Tenant

Create isolated databases or schemas for each tenant:

import { OrganizationSyncHandler } from '@authvital/sdk/server';

class ProvisioningHandler extends OrganizationSyncHandler {
  async onTenantCreated(event: TenantCreatedEvent): Promise<void> {
    // First, run the default sync
    await super.onTenantCreated(event);

    const { tenant_id, slug } = event.data;

    // Provision tenant-specific database
    await this.provisionTenantDatabase(slug);

    // Create default resources
    await this.createDefaultResources(tenant_id);

    console.log(`Provisioned resources for tenant: ${slug}`);
  }

  private async provisionTenantDatabase(slug: string): Promise<void> {
    // Example: Create PostgreSQL schema
    await prisma.$executeRawUnsafe(
      `CREATE SCHEMA IF NOT EXISTS "tenant_${slug}"`
    );

    // Run tenant-specific migrations
    await runMigrationsForSchema(`tenant_${slug}`);
  }

  private async createDefaultResources(tenantId: string): Promise<void> {
    // Create default workspace
    await prisma.workspace.create({
      data: {
        organizationId: tenantId,
        name: 'Default Workspace',
        isDefault: true,
      },
    });

    // Create default settings
    await prisma.organizationSettings.create({
      data: {
        organizationId: tenantId,
        theme: 'light',
        timezone: 'UTC',
        notificationsEnabled: true,
      },
    });
  }
}

Cloud Resource Provisioning

Create cloud resources (S3 buckets, namespaces, etc.) for new tenants:

import { S3Client, CreateBucketCommand } from '@aws-sdk/client-s3';
import { KubernetesClient } from './k8s';

class CloudProvisioningHandler extends OrganizationSyncHandler {
  private s3 = new S3Client({ region: 'us-east-1' });
  private k8s = new KubernetesClient();

  async onTenantCreated(event: TenantCreatedEvent): Promise<void> {
    await super.onTenantCreated(event);

    const { tenant_id, slug, plan } = event.data;

    // Create S3 bucket for tenant files
    await this.s3.send(new CreateBucketCommand({
      Bucket: `myapp-${slug}-files`,
      ObjectOwnership: 'BucketOwnerEnforced',
    }));

    // Create Kubernetes namespace (for enterprise tenants)
    if (plan === 'enterprise') {
      await this.k8s.createNamespace({
        name: `tenant-${slug}`,
        labels: {
          'app.kubernetes.io/tenant': tenant_id,
          'app.kubernetes.io/plan': plan,
        },
      });
    }

    // Update organization with resource references
    await prisma.organization.update({
      where: { id: tenant_id },
      data: {
        metadata: {
          s3Bucket: `myapp-${slug}-files`,
          k8sNamespace: plan === 'enterprise' ? `tenant-${slug}` : null,
        },
      },
    });
  }
}

Idempotency

Always design provisioning handlers to be idempotent. Webhooks may be delivered multiple times, so your code should handle "resource already exists" gracefully.


Billing Integration

Sync tenant plan changes to your billing system.

Stripe Integration

import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

class BillingHandler extends OrganizationSyncHandler {
  // Map AuthVital plans to Stripe price IDs
  private readonly planToPriceId: Record<string, string> = {
    free: null,  // No subscription needed
    starter: 'price_starter_monthly',
    pro: 'price_pro_monthly',
    enterprise: 'price_enterprise_monthly',
  };

  async onTenantCreated(event: TenantCreatedEvent): Promise<void> {
    await super.onTenantCreated(event);

    const { tenant_id, name, plan, created_by_sub } = event.data;

    // Create Stripe customer
    const customer = await stripe.customers.create({
      name,
      metadata: {
        authvital_tenant_id: tenant_id,
        created_by: created_by_sub,
      },
    });

    // Store Stripe customer ID
    await prisma.organization.update({
      where: { id: tenant_id },
      data: { stripeCustomerId: customer.id },
    });

    // Create subscription if not on free plan
    if (plan !== 'free') {
      await stripe.subscriptions.create({
        customer: customer.id,
        items: [{ price: this.planToPriceId[plan] }],
        metadata: { authvital_tenant_id: tenant_id },
      });
    }
  }

  async onTenantUpdated(event: TenantUpdatedEvent): Promise<void> {
    await super.onTenantUpdated(event);

    const { tenant_id, plan, changed_fields, previous_values } = event.data;

    // Only handle plan changes
    if (!changed_fields.includes('plan')) return;

    const previousPlan = previous_values.plan as string;

    // Get organization with Stripe customer ID
    const org = await prisma.organization.findUnique({
      where: { id: tenant_id },
      select: { stripeCustomerId: true },
    });

    if (!org?.stripeCustomerId) {
      console.error(`No Stripe customer for tenant ${tenant_id}`);
      return;
    }

    // Get current subscription
    const subscriptions = await stripe.subscriptions.list({
      customer: org.stripeCustomerId,
      status: 'active',
      limit: 1,
    });

    if (plan === 'free') {
      // Downgrade to free: cancel subscription
      if (subscriptions.data.length > 0) {
        await stripe.subscriptions.cancel(subscriptions.data[0].id);
      }
    } else if (previousPlan === 'free') {
      // Upgrade from free: create subscription
      await stripe.subscriptions.create({
        customer: org.stripeCustomerId,
        items: [{ price: this.planToPriceId[plan] }],
        metadata: { authvital_tenant_id: tenant_id },
      });
    } else {
      // Plan change: update subscription
      const subscription = subscriptions.data[0];
      await stripe.subscriptions.update(subscription.id, {
        items: [{
          id: subscription.items.data[0].id,
          price: this.planToPriceId[plan],
        }],
        proration_behavior: 'create_prorations',
      });
    }

    console.log(`Updated billing for ${tenant_id}: ${previousPlan} → ${plan}`);
  }

  async onTenantDeleted(event: TenantDeletedEvent): Promise<void> {
    // Get Stripe customer before deleting
    const org = await prisma.organization.findUnique({
      where: { id: event.data.tenant_id },
      select: { stripeCustomerId: true },
    });

    // Run default delete
    await super.onTenantDeleted(event);

    // Cancel all subscriptions and delete Stripe customer
    if (org?.stripeCustomerId) {
      const subscriptions = await stripe.subscriptions.list({
        customer: org.stripeCustomerId,
        status: 'active',
      });

      for (const sub of subscriptions.data) {
        await stripe.subscriptions.cancel(sub.id);
      }

      await stripe.customers.del(org.stripeCustomerId);
    }
  }
}

Handle Failures Gracefully

If Stripe operations fail, consider: - Logging the failure for manual resolution - Using a queue to retry billing operations - Implementing a reconciliation job to catch missed updates


Audit Logging

Maintain immutable audit trails for compliance.

Comprehensive Audit Handler

class AuditHandler extends OrganizationSyncHandler {
  async onTenantCreated(event: TenantCreatedEvent): Promise<void> {
    await super.onTenantCreated(event);
    await this.logAuditEvent(event, 'organization', event.data.tenant_id);
  }

  async onTenantUpdated(event: TenantUpdatedEvent): Promise<void> {
    await super.onTenantUpdated(event);
    await this.logAuditEvent(event, 'organization', event.data.tenant_id);
  }

  async onTenantDeleted(event: TenantDeletedEvent): Promise<void> {
    await this.logAuditEvent(event, 'organization', event.data.tenant_id);
    await super.onTenantDeleted(event);
  }

  async onTenantSuspended(event: TenantSuspendedEvent): Promise<void> {
    await super.onTenantSuspended(event);
    await this.logAuditEvent(event, 'organization', event.data.tenant_id);
  }

  async onApplicationCreated(event: ApplicationCreatedEvent): Promise<void> {
    await super.onApplicationCreated(event);
    await this.logAuditEvent(event, 'application', event.data.application_id);
  }

  async onApplicationUpdated(event: ApplicationUpdatedEvent): Promise<void> {
    await super.onApplicationUpdated(event);
    await this.logAuditEvent(event, 'application', event.data.application_id);
  }

  async onApplicationDeleted(event: ApplicationDeletedEvent): Promise<void> {
    await this.logAuditEvent(event, 'application', event.data.application_id);
    await super.onApplicationDeleted(event);
  }

  async onSsoProviderAdded(event: SsoProviderAddedEvent): Promise<void> {
    await super.onSsoProviderAdded(event);
    await this.logAuditEvent(event, 'sso_provider', event.data.provider_id);
  }

  async onSsoProviderUpdated(event: SsoProviderUpdatedEvent): Promise<void> {
    await super.onSsoProviderUpdated(event);
    await this.logAuditEvent(event, 'sso_provider', event.data.provider_id);
  }

  async onSsoProviderRemoved(event: SsoProviderRemovedEvent): Promise<void> {
    await this.logAuditEvent(event, 'sso_provider', event.data.provider_id);
    await super.onSsoProviderRemoved(event);
  }

  private async logAuditEvent(
    event: OrganizationEvent,
    entityType: string,
    entityId: string,
  ): Promise<void> {
    await prisma.organizationAuditLog.create({
      data: {
        organizationId: event.tenant_id,
        eventType: event.type,
        entityType,
        entityId,
        changedFields: event.data.changed_fields ?? [],
        previousValues: event.data.previous_values ?? {},
        newValues: this.extractNewValues(event),
        changedBySub: this.extractActor(event),
      },
    });
  }

  private extractActor(event: OrganizationEvent): string | null {
    const data = event.data as Record<string, unknown>;
    return (
      data.created_by_sub ??
      data.updated_by_sub ??
      data.deleted_by_sub ??
      data.suspended_by_sub ??
      data.removed_by_sub ??
      null
    ) as string | null;
  }

  private extractNewValues(event: OrganizationEvent): Record<string, unknown> {
    const { changed_fields, previous_values, ...rest } = event.data as any;
    return rest;
  }
}

Querying Audit Logs

// Get all changes to a specific organization
const orgHistory = await prisma.organizationAuditLog.findMany({
  where: { organizationId: 'tnt_acme123' },
  orderBy: { createdAt: 'desc' },
});

// Get all changes made by a specific admin
const adminActions = await prisma.organizationAuditLog.findMany({
  where: { changedBySub: 'usr_admin001' },
  orderBy: { createdAt: 'desc' },
});

// Get all plan changes in the last month
const planChanges = await prisma.organizationAuditLog.findMany({
  where: {
    eventType: 'tenant.updated',
    changedFields: { has: 'plan' },
    createdAt: { gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) },
  },
  orderBy: { createdAt: 'desc' },
});

Multi-Region Sync

Replicate organization config across multiple regions for low-latency access.

Regional Replication Handler

import { Redis } from 'ioredis';

class MultiRegionHandler extends OrganizationSyncHandler {
  private redis = new Redis(process.env.REDIS_URL!);
  private currentRegion = process.env.AWS_REGION ?? 'us-east-1';
  private regions = ['us-east-1', 'us-west-2', 'eu-west-1', 'ap-southeast-1'];

  async onTenantCreated(event: TenantCreatedEvent): Promise<void> {
    await super.onTenantCreated(event);
    await this.replicateToOtherRegions(event);
    await this.invalidateCache(event.data.tenant_id);
  }

  async onTenantUpdated(event: TenantUpdatedEvent): Promise<void> {
    await super.onTenantUpdated(event);
    await this.replicateToOtherRegions(event);
    await this.invalidateCache(event.data.tenant_id);
  }

  async onTenantDeleted(event: TenantDeletedEvent): Promise<void> {
    await super.onTenantDeleted(event);
    await this.replicateToOtherRegions(event);
    await this.invalidateCache(event.data.tenant_id);
  }

  private async replicateToOtherRegions(event: OrganizationEvent): Promise<void> {
    const otherRegions = this.regions.filter(r => r !== this.currentRegion);

    // Publish to Redis pub/sub for other regions
    await this.redis.publish(
      'org-sync:replicate',
      JSON.stringify({
        sourceRegion: this.currentRegion,
        targetRegions: otherRegions,
        event,
      })
    );
  }

  private async invalidateCache(tenantId: string): Promise<void> {
    // Invalidate organization cache across all regions
    const cacheKeys = [
      `org:${tenantId}`,
      `org:${tenantId}:apps`,
      `org:${tenantId}:sso`,
    ];

    for (const key of cacheKeys) {
      await this.redis.del(key);
    }
  }
}

Read Replica Pattern

For high-read scenarios, cache organization data in Redis:

class CachedOrganizationService {
  private redis = new Redis(process.env.REDIS_URL!);
  private cacheTtlSeconds = 300; // 5 minutes

  async getOrganization(tenantId: string): Promise<Organization | null> {
    // Try cache first
    const cached = await this.redis.get(`org:${tenantId}`);
    if (cached) {
      return JSON.parse(cached);
    }

    // Fall back to database
    const org = await prisma.organization.findUnique({
      where: { id: tenantId },
      include: {
        applications: { where: { isActive: true } },
        ssoProviders: { where: { isEnabled: true } },
      },
    });

    if (org) {
      await this.redis.setex(
        `org:${tenantId}`,
        this.cacheTtlSeconds,
        JSON.stringify(org)
      );
    }

    return org;
  }

  async getOrganizationBySlug(slug: string): Promise<Organization | null> {
    // Slug lookup with cache
    const tenantId = await this.redis.get(`org:slug:${slug}`);
    if (tenantId) {
      return this.getOrganization(tenantId);
    }

    const org = await prisma.organization.findUnique({
      where: { slug },
    });

    if (org) {
      await this.redis.setex(`org:slug:${slug}`, this.cacheTtlSeconds, org.id);
      return this.getOrganization(org.id);
    }

    return null;
  }
}

Access Control Based on Org Config

Use synced organization data for access control decisions.

Middleware Example

import { NextRequest, NextResponse } from 'next/server';

export async function organizationMiddleware(request: NextRequest) {
  const tenantSlug = request.headers.get('x-tenant-slug');

  if (!tenantSlug) {
    return NextResponse.json(
      { error: 'Missing tenant header' },
      { status: 400 }
    );
  }

  // Get organization from local database (fast!)
  const org = await prisma.organization.findUnique({
    where: { slug: tenantSlug },
  });

  if (!org) {
    return NextResponse.json(
      { error: 'Tenant not found' },
      { status: 404 }
    );
  }

  // Check organization status
  if (org.status === 'suspended') {
    return NextResponse.json(
      { error: 'This organization has been suspended', reason: org.suspendedReason },
      { status: 403 }
    );
  }

  // Attach organization to request context
  const response = NextResponse.next();
  response.headers.set('x-organization-id', org.id);
  response.headers.set('x-organization-plan', org.plan);

  return response;
}

Feature Flags Based on Plan

class FeatureService {
  private planFeatures: Record<string, Set<string>> = {
    free: new Set(['basic_dashboard', 'single_user']),
    starter: new Set(['basic_dashboard', 'team_members', 'api_access']),
    pro: new Set(['basic_dashboard', 'team_members', 'api_access', 'custom_domains', 'webhooks']),
    enterprise: new Set(['basic_dashboard', 'team_members', 'api_access', 'custom_domains', 'webhooks', 'sso', 'audit_logs', 'sla']),
  };

  async hasFeature(organizationId: string, feature: string): Promise<boolean> {
    const org = await prisma.organization.findUnique({
      where: { id: organizationId },
      select: { plan: true, status: true },
    });

    if (!org || org.status !== 'active') {
      return false;
    }

    return this.planFeatures[org.plan]?.has(feature) ?? false;
  }

  async requireFeature(organizationId: string, feature: string): Promise<void> {
    const hasAccess = await this.hasFeature(organizationId, feature);

    if (!hasAccess) {
      const org = await prisma.organization.findUnique({
        where: { id: organizationId },
        select: { plan: true },
      });

      throw new FeatureNotAvailableError(
        `The '${feature}' feature is not available on the ${org?.plan ?? 'free'} plan. ` +
        `Please upgrade to access this feature.`
      );
    }
  }
}

Testing Handlers

Test your custom handlers with mock events:

import { describe, it, expect, beforeEach } from 'vitest';
import { BillingHandler } from './billing-handler';
import { mockDeep } from 'vitest-mock-extended';

describe('BillingHandler', () => {
  let handler: BillingHandler;
  let mockStripe: ReturnType<typeof mockDeep<Stripe>>;

  beforeEach(() => {
    mockStripe = mockDeep<Stripe>();
    handler = new BillingHandler(prisma, mockStripe);
  });

  it('creates Stripe customer on tenant creation', async () => {
    const event: TenantCreatedEvent = {
      id: 'evt_test001',
      type: 'tenant.created',
      timestamp: new Date().toISOString(),
      tenant_id: 'tnt_test123',
      application_id: null,
      data: {
        tenant_id: 'tnt_test123',
        name: 'Test Corp',
        slug: 'test-corp',
        plan: 'pro',
        created_by_sub: 'usr_admin001',
        created_at: new Date().toISOString(),
        settings: {
          allow_signups: true,
          require_mfa: false,
          allowed_email_domains: [],
          session_lifetime_minutes: 480,
          password_policy: 'standard',
        },
      },
    };

    mockStripe.customers.create.mockResolvedValue({ id: 'cus_test123' } as any);
    mockStripe.subscriptions.create.mockResolvedValue({ id: 'sub_test123' } as any);

    await handler.onTenantCreated(event);

    expect(mockStripe.customers.create).toHaveBeenCalledWith({
      name: 'Test Corp',
      metadata: {
        authvital_tenant_id: 'tnt_test123',
        created_by: 'usr_admin001',
      },
    });

    expect(mockStripe.subscriptions.create).toHaveBeenCalledWith({
      customer: 'cus_test123',
      items: [{ price: 'price_pro_monthly' }],
      metadata: { authvital_tenant_id: 'tnt_test123' },
    });
  });
});