---
name: organization-team-management
description: Manage multi-tenant organization teams including invitations, role assignments, seat management, and member operations for B2B SaaS. Use when implementing team features, invitation flows, role-based access control, or seat-based billing for clinic organizations.
version: 1.0.0
tags: [organizations, teams, invitations, roles, rbac, multi-tenant, seats]
allowed-tools: [Read, Write, Edit, Bash, Grep]
related-skills:
  [api-development, subscription-management, email-composer, database-migration]
last-updated: 2025-12-09
---

# Organization & Team Management

Multi-tenant team operations for Mawidi's B2B SaaS platform.

**Token Savings**: ~10,000 tokens per team feature implementation
**Stack**: Next.js API routes, Prisma + Supabase, NextAuth sessions, email invitations

## Overview

This skill provides patterns for:

- Creating and managing organizations (clinics)
- Sending and accepting team invitations
- Role-based access control (Owner, Admin, Member, Viewer)
- Seat management and billing
- Member removal and cleanup
- Multi-tenant data isolation

## When to Use This Skill

Use this skill when:

- Building organization/team creation flows
- Implementing invitation system (email → token → acceptance)
- Adding role-based permissions
- Managing seat-based billing (purchasing additional seats)
- Removing team members and cleaning up access
- Enforcing multi-tenant data boundaries

## Quick Start Examples

### Example 1: Send Team Invitation

**Scenario**: Organization owner invites new team member

```typescript
// app/api/organization/invitations/route.ts
import { getServerSession } from "next-auth";
import { db } from "@/lib/db";
import { sendInvitationEmail } from "@/lib/emails";
import crypto from "crypto";

export async function POST(request: Request) {
  const session = await getServerSession();
  if (!session?.user?.email) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { email, role = "member" } = await request.json();

  // Verify user is admin/owner
  const member = await db.organizationMember.findFirst({
    where: {
      userId: session.user.id,
      role: { in: ["owner", "admin"] },
    },
    include: { organization: true },
  });

  if (!member) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  // Check seat availability
  const activeMembers = await db.organizationMember.count({
    where: { organizationId: member.organizationId, status: "active" },
  });

  if (activeMembers >= member.organization.maxSeats) {
    return Response.json(
      {
        error: "No available seats. Please purchase additional seats.",
        maxSeats: member.organization.maxSeats,
        currentMembers: activeMembers,
      },
      { status: 400 },
    );
  }

  // Generate secure invitation token
  const token = crypto.randomBytes(32).toString("hex");
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days

  // Create invitation
  const invitation = await saveToBothDatabases("invitation", {
    organizationId: member.organizationId,
    email,
    role,
    token,
    expiresAt,
    invitedBy: session.user.id,
  });

  // Send invitation email
  await sendInvitationEmail({
    to: email,
    inviterName: session.user.name,
    organizationName: member.organization.name,
    role,
    acceptUrl: `${process.env.NEXT_PUBLIC_APP_URL}/invite/${token}`,
  });

  return Response.json({ success: true, invitation });
}
```

### Example 2: Accept Invitation

**Scenario**: Invited user clicks email link and accepts invitation

```typescript
// app/api/auth/accept-invitation/route.ts
export async function POST(request: Request) {
  const session = await getServerSession();
  if (!session?.user?.email) {
    return Response.json({ error: "Must be logged in" }, { status: 401 });
  }

  const { token } = await request.json();

  // Find valid invitation
  const invitation = await db.invitation.findFirst({
    where: {
      token,
      email: session.user.email,
      status: "pending",
      expiresAt: { gt: new Date() },
    },
    include: { organization: true },
  });

  if (!invitation) {
    return Response.json(
      {
        error: "Invalid or expired invitation",
      },
      { status: 404 },
    );
  }

  // Create organization member
  await saveToBothDatabases("organizationMember", {
    organizationId: invitation.organizationId,
    userId: session.user.id,
    role: invitation.role,
    status: "active",
    joinedAt: new Date(),
  });

  // Mark invitation as accepted
  await saveToBothDatabases("invitation", {
    where: { id: invitation.id },
    data: {
      status: "accepted",
      acceptedAt: new Date(),
      acceptedBy: session.user.id,
    },
  });

  // Send welcome email
  await sendTeamWelcomeEmail({
    to: session.user.email,
    organizationName: invitation.organization.name,
    role: invitation.role,
  });

  return Response.json({
    success: true,
    organizationId: invitation.organizationId,
    redirectTo: `/dashboard?org=${invitation.organizationId}`,
  });
}
```

### Example 3: Remove Team Member

**Scenario**: Admin removes a team member from organization

```typescript
// app/api/organization/members/[id]/route.ts
export async function DELETE(
  request: Request,
  { params }: { params: { id: string } },
) {
  const session = await getServerSession();
  if (!session?.user?.email) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const memberId = params.id;

  // Get member to remove
  const memberToRemove = await db.organizationMember.findUnique({
    where: { id: memberId },
    include: { organization: true },
  });

  if (!memberToRemove) {
    return Response.json({ error: "Member not found" }, { status: 404 });
  }

  // Verify requester is admin/owner
  const requester = await db.organizationMember.findFirst({
    where: {
      organizationId: memberToRemove.organizationId,
      userId: session.user.id,
      role: { in: ["owner", "admin"] },
    },
  });

  if (!requester) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  // Prevent removing last owner
  if (memberToRemove.role === "owner") {
    const ownerCount = await db.organizationMember.count({
      where: {
        organizationId: memberToRemove.organizationId,
        role: "owner",
        status: "active",
      },
    });

    if (ownerCount <= 1) {
      return Response.json(
        {
          error: "Cannot remove last owner. Transfer ownership first.",
        },
        { status: 400 },
      );
    }
  }

  // Soft delete member
  await saveToBothDatabases("organizationMember", {
    where: { id: memberId },
    data: {
      status: "removed",
      removedAt: new Date(),
      removedBy: session.user.id,
    },
  });

  // Revoke active sessions (if applicable)
  // Clean up member-specific resources

  // Send removal notification
  await sendMemberRemovedEmail({
    to: memberToRemove.user.email,
    organizationName: memberToRemove.organization.name,
    removedBy: session.user.name,
  });

  return Response.json({ success: true });
}
```

## Database Schema

### Organization Model

```prisma
model Organization {
  id              String   @id @default(cuid())
  name            String
  slug            String   @unique

  // Billing
  maxSeats        Int      @default(5)
  usedSeats       Int      @default(1)

  // Metadata
  industry        String?
  size            String?  // 'small' | 'medium' | 'large'

  // Relationships
  members         OrganizationMember[]
  invitations     Invitation[]
  subscription    Subscription?

  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@index([slug])
}

model OrganizationMember {
  id              String   @id @default(cuid())
  organizationId  String
  organization    Organization @relation(fields: [organizationId], references: [id])
  userId          String
  user            User     @relation(fields: [userId], references: [id])

  // Role & Status
  role            String   // 'owner' | 'admin' | 'member' | 'viewer'
  status          String   @default("active") // 'active' | 'removed'

  // Lifecycle
  joinedAt        DateTime @default(now())
  removedAt       DateTime?
  removedBy       String?

  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@unique([organizationId, userId])
  @@index([userId])
  @@index([organizationId, status])
}

model Invitation {
  id              String   @id @default(cuid())
  organizationId  String
  organization    Organization @relation(fields: [organizationId], references: [id])

  // Invitation details
  email           String
  role            String   // 'admin' | 'member' | 'viewer'
  token           String   @unique

  // Status
  status          String   @default("pending") // 'pending' | 'accepted' | 'expired' | 'revoked'
  expiresAt       DateTime

  // Tracking
  invitedBy       String
  acceptedAt      DateTime?
  acceptedBy      String?

  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt

  @@index([email])
  @@index([token])
  @@index([status])
}
```

## Role Hierarchy & Permissions

```typescript
const ROLE_HIERARCHY = {
  owner: 4, // Full control
  admin: 3, // Manage team, settings
  member: 2, // Standard access
  viewer: 1, // Read-only
} as const;

const PERMISSIONS = {
  owner: [
    "manage_billing",
    "delete_organization",
    "transfer_ownership",
    "manage_members",
    "manage_settings",
    "view_analytics",
    "manage_integrations",
  ],
  admin: [
    "manage_members",
    "manage_settings",
    "view_analytics",
    "manage_integrations",
  ],
  member: ["view_analytics", "manage_own_profile"],
  viewer: ["view_analytics"],
} as const;

// Check if user has permission
function hasPermission(userRole: string, permission: string): boolean {
  const rolePerms = PERMISSIONS[userRole as keyof typeof PERMISSIONS] || [];
  return rolePerms.includes(permission);
}

// Check if requester can modify target
function canModifyMember(requesterRole: string, targetRole: string): boolean {
  return (
    ROLE_HIERARCHY[requesterRole as keyof typeof ROLE_HIERARCHY] >
    ROLE_HIERARCHY[targetRole as keyof typeof ROLE_HIERARCHY]
  );
}
```

## Team Invitation Flow

### Step 1: Create Invitation

```typescript
// Generate secure token
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days

await saveToBothDatabases("invitation", {
  organizationId,
  email,
  role,
  token,
  expiresAt,
  invitedBy: session.user.id,
});
```

### Step 2: Send Email

```typescript
await sendBilingualEmail({
  to: email,
  lang: "en", // Detect from user preferences if known
  template: "team-invitation",
  data: {
    inviterName: inviter.name,
    organizationName: org.name,
    role,
    acceptUrl: `${APP_URL}/invite/${token}`,
    expiresAt,
  },
});
```

### Step 3: Validate Token

```typescript
// app/[lang]/invite/[token]/page.tsx
const invitation = await db.invitation.findFirst({
  where: {
    token: params.token,
    status: 'pending',
    expiresAt: { gt: new Date() }
  },
  include: { organization: true }
});

if (!invitation) {
  return <ExpiredInvitationPage />;
}
```

### Step 4: Accept Invitation

```typescript
// User must be logged in (or create account first)
await saveToBothDatabases("organizationMember", {
  organizationId: invitation.organizationId,
  userId: session.user.id,
  role: invitation.role,
  status: "active",
  joinedAt: new Date(),
});

await saveToBothDatabases("invitation", {
  where: { id: invitation.id },
  data: {
    status: "accepted",
    acceptedAt: new Date(),
    acceptedBy: session.user.id,
  },
});
```

## Seat Management

### Check Available Seats

```typescript
async function checkSeatAvailability(organizationId: string): Promise<{
  available: boolean;
  used: number;
  max: number;
}> {
  const org = await db.organization.findUnique({
    where: { id: organizationId },
    select: { maxSeats: true },
  });

  const usedSeats = await db.organizationMember.count({
    where: { organizationId, status: "active" },
  });

  return {
    available: usedSeats < org!.maxSeats,
    used: usedSeats,
    max: org!.maxSeats,
  };
}
```

### Purchase Additional Seats

```typescript
// app/api/organization/seats/purchase/route.ts
export async function POST(request: Request) {
  const session = await getServerSession();
  const { additionalSeats } = await request.json();

  // Verify owner/admin
  const member = await db.organizationMember.findFirst({
    where: {
      userId: session.user.id,
      role: { in: ["owner", "admin"] },
    },
    include: { organization: true },
  });

  if (!member) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  // Create Stripe checkout for additional seats
  const PRICE_PER_SEAT = 29.99; // Per month
  const checkoutSession = await stripe.checkout.sessions.create({
    customer: member.organization.stripeCustomerId,
    mode: "subscription",
    line_items: [
      {
        price: SEAT_PRICE_ID,
        quantity: additionalSeats,
      },
    ],
    metadata: {
      organizationId: member.organizationId,
      additionalSeats: additionalSeats.toString(),
    },
    success_url: `${APP_URL}/dashboard/billing?success=true`,
    cancel_url: `${APP_URL}/dashboard/billing?canceled=true`,
  });

  return Response.json({ url: checkoutSession.url });
}
```

### Auto-Update Seat Count

```typescript
// Webhook handler for seat purchase
case 'checkout.session.completed': {
  const session = event.data.object;

  if (session.metadata?.additionalSeats) {
    const additionalSeats = parseInt(session.metadata.additionalSeats);

    await saveToBothDatabases('organization', {
      where: { id: session.metadata.organizationId },
      data: {
        maxSeats: { increment: additionalSeats }
      }
    });
  }
  break;
}
```

## Role Management

### Change Member Role

```typescript
// app/api/organization/members/[id]/role/route.ts
export async function PATCH(
  request: Request,
  { params }: { params: { id: string } },
) {
  const session = await getServerSession();
  const { newRole } = await request.json();

  // Get target member
  const targetMember = await db.organizationMember.findUnique({
    where: { id: params.id },
  });

  if (!targetMember) {
    return Response.json({ error: "Member not found" }, { status: 404 });
  }

  // Get requester
  const requester = await db.organizationMember.findFirst({
    where: {
      organizationId: targetMember.organizationId,
      userId: session.user.id,
    },
  });

  if (!requester) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  // Verify requester can modify target
  if (!canModifyMember(requester.role, targetMember.role)) {
    return Response.json(
      {
        error: "Cannot modify member with equal or higher role",
      },
      { status: 403 },
    );
  }

  // Prevent last owner demotion
  if (targetMember.role === "owner" && newRole !== "owner") {
    const ownerCount = await db.organizationMember.count({
      where: {
        organizationId: targetMember.organizationId,
        role: "owner",
        status: "active",
      },
    });

    if (ownerCount <= 1) {
      return Response.json(
        {
          error: "Cannot demote last owner",
        },
        { status: 400 },
      );
    }
  }

  // Update role
  await saveToBothDatabases("organizationMember", {
    where: { id: params.id },
    data: { role: newRole },
  });

  return Response.json({ success: true });
}
```

## Multi-Tenant Data Isolation

### Middleware Pattern

```typescript
// middleware/require-organization-access.ts
export async function requireOrganizationAccess(
  userId: string,
  organizationId: string,
  minRole: "viewer" | "member" | "admin" | "owner" = "viewer",
): Promise<OrganizationMember | null> {
  const member = await db.organizationMember.findFirst({
    where: {
      userId,
      organizationId,
      status: "active",
    },
  });

  if (!member) return null;

  // Check role hierarchy
  const hasAccess =
    ROLE_HIERARCHY[member.role as keyof typeof ROLE_HIERARCHY] >=
    ROLE_HIERARCHY[minRole];

  return hasAccess ? member : null;
}
```

### Usage in API Routes

```typescript
// app/api/organization/[id]/settings/route.ts
export async function PATCH(
  request: Request,
  { params }: { params: { id: string } },
) {
  const session = await getServerSession();

  // Require admin access
  const member = await requireOrganizationAccess(
    session.user.id,
    params.id,
    "admin",
  );

  if (!member) {
    return Response.json({ error: "Forbidden" }, { status: 403 });
  }

  // Update organization settings...
}
```

### Query Scoping

```typescript
// Always scope queries by organization
const appointments = await db.appointment.findMany({
  where: {
    organizationId: member.organizationId, // ✅ Scoped to user's org
    // ... other filters
  },
});

// ❌ NEVER query without organization scope (data leak risk)
const appointments = await db.appointment.findMany({
  where: { status: "pending" }, // WRONG: Cross-org data leak
});
```

## Testing Patterns

### Test Invitation Flow

```typescript
describe("POST /api/organization/invitations", () => {
  it("creates invitation and sends email", async () => {
    const mockSession = {
      user: { id: "owner_123", email: "owner@clinic.com" },
    };

    const response = await POST(mockRequest);
    expect(response.status).toBe(200);

    const invitation = await db.invitation.findFirst({
      where: { email: "newmember@example.com" },
    });

    expect(invitation).toBeTruthy();
    expect(invitation?.status).toBe("pending");
    expect(invitation?.token).toHaveLength(64); // crypto.randomBytes(32).toString('hex')

    // Verify email sent
    expect(sendInvitationEmail).toHaveBeenCalledWith(
      expect.objectContaining({
        to: "newmember@example.com",
        role: "member",
      }),
    );
  });

  it("rejects invitation when no seats available", async () => {
    // Set org to max capacity
    await db.organization.update({
      where: { id: orgId },
      data: { maxSeats: 5, usedSeats: 5 },
    });

    const response = await POST(mockRequest);
    expect(response.status).toBe(400);
    expect(await response.json()).toMatchObject({
      error: expect.stringContaining("No available seats"),
    });
  });
});
```

## Team Invitation Checklist

- [ ] Verify inviter is admin or owner
- [ ] Check seat availability before creating invitation
- [ ] Generate secure random token (crypto.randomBytes(32))
- [ ] Set expiration (7 days recommended)
- [ ] Save invitation to dual databases
- [ ] Send bilingual invitation email
- [ ] Include role in invitation metadata
- [ ] Handle duplicate invitations (same email, pending)
- [ ] Provide invitation management UI (pending, accepted, expired)
- [ ] Allow revocation of pending invitations

## Member Removal Checklist

- [ ] Verify requester is admin or owner
- [ ] Verify requester can modify target (role hierarchy)
- [ ] Prevent removing last owner
- [ ] Soft delete (set status='removed') not hard delete
- [ ] Log removal action and who performed it
- [ ] Revoke active sessions (if applicable)
- [ ] Clean up member-specific resources
- [ ] Send removal notification email
- [ ] Update seat count (free up seat)
- [ ] Handle data ownership transfer (if member owned resources)

## Common Issues & Solutions

### Issue 1: Invitation Token Collision

**Problem**: Rare chance of duplicate tokens

**Solution**:

```typescript
let token: string;
let attempts = 0;
do {
  token = crypto.randomBytes(32).toString("hex");
  const existing = await db.invitation.findUnique({ where: { token } });
  if (!existing) break;
  attempts++;
} while (attempts < 3);

if (attempts >= 3) {
  throw new Error("Failed to generate unique token");
}
```

### Issue 2: Race Condition on Seat Limit

**Problem**: Two invitations sent simultaneously might exceed seat limit

**Solution**: Use database transaction

```typescript
await db.$transaction(async (tx) => {
  // Lock organization row
  const org = await tx.organization.findUnique({
    where: { id: organizationId }
  });

  const usedSeats = await tx.organizationMember.count({
    where: { organizationId, status: 'active' }
  });

  if (usedSeats >= org!.maxSeats) {
    throw new Error('No available seats');
  }

  // Create invitation within transaction
  await tx.invitation.create({ data: { ... } });
});
```

### Issue 3: User Accepts Multiple Invitations

**Problem**: User invited to multiple organizations

**Solution**: Store organization context in session

```typescript
// Allow user to switch between organizations
const userOrganizations = await db.organizationMember.findMany({
  where: { userId: session.user.id, status: "active" },
  include: { organization: true },
});

// Store active organization in session or cookie
const activeOrgId = cookies().get("active_organization")?.value;
```

## Integration with Other Skills

### With subscription-management

```typescript
// When purchasing seats, update subscription
await stripe.subscriptions.update(subscriptionId, {
  items: [
    {
      id: seatItemId,
      quantity: newSeatCount,
    },
  ],
  proration_behavior: "create_prorations",
});
```

### With email-composer

```typescript
// Use bilingual templates
await sendBilingualEmail({
  to: invitation.email,
  lang: org.preferredLanguage,
  template: "team-invitation",
  data: { organizationName, role, acceptUrl },
});
```

## Security Best Practices

### 1. Validate Email Domain (Optional)

```typescript
// Restrict to company domain
const ALLOWED_DOMAINS = ["clinic.sa", "health.qa"];

function isAllowedDomain(email: string): boolean {
  const domain = email.split("@")[1];
  return ALLOWED_DOMAINS.includes(domain);
}
```

### 2. Rate Limit Invitations

```typescript
// Prevent invitation spam
import { rateLimit } from "@/lib/rate-limit";

const limiter = rateLimit({
  interval: 60 * 60 * 1000, // 1 hour
  uniqueTokenPerInterval: 500,
});

try {
  await limiter.check(session.user.id, 10); // Max 10 invitations per hour
} catch {
  return Response.json({ error: "Too many invitations" }, { status: 429 });
}
```

### 3. Token Expiration Cleanup

```typescript
// Cron job: Expire old invitations
async function expireOldInvitations() {
  await db.invitation.updateMany({
    where: {
      status: "pending",
      expiresAt: { lt: new Date() },
    },
    data: { status: "expired" },
  });
}
```

## See Also

- [api-development](../api-development/SKILL.md) - API endpoint patterns
- [subscription-management](../subscription-management/SKILL.md) - Seat-based billing
- [email-composer](../email-composer/SKILL.md) - Invitation email templates
- [database-migration](../database-migration/SKILL.md) - Schema for organizations/members
- [error-handling](../error-handling/SKILL.md) - Error handling patterns
