Skip to content

migrate-to-teams

Access: /template migrate-to-teams or /t migrate-to-teams

Transforms a single-user MakerKit application into a multi-tenant team-based application, enabling collaboration features and team management.

What It Does

The migrate-to-teams command helps you:

  • Convert user-scoped features to team-scoped
  • Add team creation and management UI
  • Implement team member invitations
  • Set up role-based permissions
  • Migrate existing user data to team structure

Usage

bash
/template migrate-to-teams "Description"
# or
/t migrate-to-teams "Description"

When prompted, provide:

  • Default team name pattern (e.g., "{userName}'s Team")
  • Whether to migrate existing data
  • Team size limits
  • Default team roles

Prerequisites

  • A MakerKit project with user authentication
  • Understanding of current data model
  • Commercial MakerKit license from MakerKit

What Gets Created

1. Team Database Structure

Creates team-related tables:

sql
-- Teams table (if not exists)
create table if not exists public.accounts (
  id uuid default gen_random_uuid() primary key,
  name text not null,
  slug text unique not null,
  picture_url text,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- Team members junction table
create table if not exists public.accounts_account_members (
  account_id uuid not null references public.accounts(id) on delete cascade,
  user_id uuid not null references auth.users(id) on delete cascade,
  role account_role not null default 'member',
  joined_at timestamptz default now(),
  primary key (account_id, user_id)
);

-- Team invitations
create table if not exists public.invitations (
  id uuid default gen_random_uuid() primary key,
  account_id uuid not null references public.accounts(id) on delete cascade,
  email text not null,
  role account_role not null default 'member',
  invited_by uuid references auth.users(id),
  token text unique not null,
  expires_at timestamptz not null,
  accepted_at timestamptz,
  created_at timestamptz default now()
);

-- Enable RLS
alter table public.accounts enable row level security;
alter table public.accounts_account_members enable row level security;
alter table public.invitations enable row level security;

2. Data Migration Script

Migrates existing user data to teams:

typescript
// migrations/migrate-to-teams.ts
export async function migrateUsersToTeams() {
  const supabase = createClient();
  
  // Get all users
  const { data: users } = await supabase
    .from('users')
    .select('*');
  
  for (const user of users) {
    // Create a personal team for each user
    const { data: team } = await supabase
      .from('accounts')
      .insert({
        name: `${user.display_name}'s Team`,
        slug: generateSlug(user.display_name),
      })
      .select()
      .single();
    
    // Add user as team owner
    await supabase
      .from('accounts_account_members')
      .insert({
        account_id: team.id,
        user_id: user.id,
        role: 'owner',
      });
    
    // Migrate user's data to team
    await migrateUserDataToTeam(user.id, team.id);
  }
}

async function migrateUserDataToTeam(userId: string, teamId: string) {
  // Update user-scoped tables to team-scoped
  await supabase
    .from('projects')
    .update({ account_id: teamId })
    .eq('user_id', userId);
  
  await supabase
    .from('documents')
    .update({ account_id: teamId })
    .eq('user_id', userId);
  
  // Add other tables as needed
}

3. Team Management UI

Team creation component:

typescript
// components/teams/create-team-form.tsx
export function CreateTeamForm() {
  const router = useRouter();
  const [isCreating, setIsCreating] = useState(false);
  
  const form = useForm({
    resolver: zodResolver(CreateTeamSchema),
    defaultValues: {
      name: '',
      slug: '',
    },
  });
  
  const onSubmit = async (data: CreateTeamInput) => {
    setIsCreating(true);
    
    try {
      const team = await createTeamAction(data);
      toast.success('Team created successfully');
      router.push(`/home/${team.slug}`);
    } catch (error) {
      toast.error('Failed to create team');
    } finally {
      setIsCreating(false);
    }
  };
  
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Team Name</FormLabel>
              <FormControl>
                <Input {...field} placeholder="Acme Inc" />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="slug"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Team URL</FormLabel>
              <FormControl>
                <div className="flex items-center">
                  <span className="text-muted-foreground">
                    app.com/teams/
                  </span>
                  <Input {...field} placeholder="acme-inc" />
                </div>
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <Button type="submit" disabled={isCreating}>
          {isCreating ? 'Creating...' : 'Create Team'}
        </Button>
      </form>
    </Form>
  );
}

4. Team Switcher Component

UI for switching between teams:

typescript
// components/teams/team-switcher.tsx
export function TeamSwitcher() {
  const pathname = usePathname();
  const router = useRouter();
  const { data: teams } = useUserTeams();
  const currentTeam = getCurrentTeam(pathname);
  
  const switchTeam = (teamSlug: string) => {
    const newPath = pathname.replace(
      `/home/${currentTeam.slug}`,
      `/home/${teamSlug}`
    );
    router.push(newPath);
  };
  
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" className="gap-2">
          <Avatar className="h-5 w-5">
            <AvatarImage src={currentTeam.picture_url} />
            <AvatarFallback>
              {currentTeam.name[0]}
            </AvatarFallback>
          </Avatar>
          {currentTeam.name}
          <ChevronDown className="h-4 w-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuLabel>Switch Team</DropdownMenuLabel>
        <DropdownMenuSeparator />
        {teams?.map((team) => (
          <DropdownMenuItem
            key={team.id}
            onClick={() => switchTeam(team.slug)}
          >
            <Avatar className="h-5 w-5 mr-2">
              <AvatarImage src={team.picture_url} />
              <AvatarFallback>{team.name[0]}</AvatarFallback>
            </Avatar>
            {team.name}
            {team.id === currentTeam.id && (
              <Check className="h-4 w-4 ml-auto" />
            )}
          </DropdownMenuItem>
        ))}
        <DropdownMenuSeparator />
        <DropdownMenuItem onClick={() => router.push('/teams/new')}>
          <Plus className="h-4 w-4 mr-2" />
          Create Team
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

5. Team Invitation System

Invite team members:

typescript
// components/teams/invite-member-form.tsx
export function InviteMemberForm({ teamId }: { teamId: string }) {
  const [isInviting, setIsInviting] = useState(false);
  
  const form = useForm({
    resolver: zodResolver(InviteMemberSchema),
    defaultValues: {
      email: '',
      role: 'member' as const,
    },
  });
  
  const onSubmit = async (data: InviteMemberInput) => {
    setIsInviting(true);
    
    try {
      await inviteTeamMemberAction({
        ...data,
        accountId: teamId,
      });
      
      toast.success('Invitation sent successfully');
      form.reset();
    } catch (error) {
      toast.error('Failed to send invitation');
    } finally {
      setIsInviting(false);
    }
  };
  
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
        <div className="flex gap-2">
          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem className="flex-1">
                <FormControl>
                  <Input
                    {...field}
                    type="email"
                    placeholder="colleague@company.com"
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          
          <FormField
            control={form.control}
            name="role"
            render={({ field }) => (
              <FormItem>
                <Select
                  onValueChange={field.onChange}
                  defaultValue={field.value}
                >
                  <FormControl>
                    <SelectTrigger className="w-32">
                      <SelectValue />
                    </SelectTrigger>
                  </FormControl>
                  <SelectContent>
                    <SelectItem value="member">Member</SelectItem>
                    <SelectItem value="admin">Admin</SelectItem>
                  </SelectContent>
                </Select>
                <FormMessage />
              </FormItem>
            )}
          />
          
          <Button type="submit" disabled={isInviting}>
            {isInviting ? 'Sending...' : 'Send Invite'}
          </Button>
        </div>
      </form>
    </Form>
  );
}

6. Updated Navigation

Modified to include team context:

typescript
// config/team-navigation.config.tsx
export const teamNavigationConfig = [
  {
    label: 'Dashboard',
    path: '/home/[account]',
    Icon: <Home className="w-4" />,
  },
  {
    label: 'Projects',
    path: '/home/[account]/projects',
    Icon: <FolderOpen className="w-4" />,
  },
  {
    label: 'Team',
    path: '/home/[account]/members',
    Icon: <Users className="w-4" />,
  },
  {
    label: 'Settings',
    path: '/home/[account]/settings',
    Icon: <Settings className="w-4" />,
  },
];

Route Updates

Update routing to include team context:

typescript
// Before: /dashboard/projects
// After: /home/[account]/projects

// app/home/[account]/layout.tsx
export default async function TeamLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: { account: string };
}) {
  const team = await getTeamBySlug(params.account);
  
  if (!team) {
    notFound();
  }
  
  return (
    <TeamProvider team={team}>
      <div className="flex">
        <TeamSidebar />
        <main className="flex-1">{children}</main>
      </div>
    </TeamProvider>
  );
}

Server Action Updates

Update server actions to be team-aware:

typescript
// Before
export const createProjectAction = enhanceAction(
  async (data, user) => {
    return createProject({ ...data, userId: user.id });
  },
  { auth: true, schema: CreateProjectSchema }
);

// After
export const createProjectAction = enhanceAction(
  async (data, user) => {
    // Verify team membership
    const hasAccess = await verifyTeamMembership(
      user.id,
      data.accountId
    );
    
    if (!hasAccess) {
      throw new Error('Access denied');
    }
    
    return createProject({ ...data, accountId: data.accountId });
  },
  { auth: true, schema: CreateProjectSchema }
);

Testing the Migration

Verify Data Migration

typescript
describe('Team Migration', () => {
  it('creates personal teams for existing users', async () => {
    await migrateUsersToTeams();
    
    const { data: teams } = await supabase
      .from('accounts')
      .select('*, members:accounts_account_members(*)');
    
    expect(teams).toHaveLength(existingUsers.length);
    teams.forEach((team) => {
      expect(team.members).toHaveLength(1);
      expect(team.members[0].role).toBe('owner');
    });
  });
  
  it('migrates user data to teams', async () => {
    await migrateUsersToTeams();
    
    const { data: projects } = await supabase
      .from('projects')
      .select('account_id')
      .not('account_id', 'is', null);
    
    expect(projects).toHaveLength(existingProjects.length);
  });
});

Post-Migration Steps

  1. Update environment variables:

    bash
    NEXT_PUBLIC_ENABLE_TEAMS=true
    NEXT_PUBLIC_DEFAULT_TEAM_ROLE=member
  2. Run migration:

    bash
    pnpm run migrate:teams
  3. Update documentation:

    • Team management guides
    • Permission documentation
    • API updates
  4. Test thoroughly:

    • Team creation flow
    • Member invitation
    • Data access with teams
    • Permission enforcement

License Requirement

Important: This command requires a commercial MakerKit license from https://makerkit.dev?atp=MqaGgc MakerKit is a premium SaaS starter kit and requires proper licensing for commercial use.

Built with ❤️ for the AI Coding community, by Praney Behl