🌌 Multi-Space Architecture Plan

Proper multi-tenant space architecture with memberships

🌌 Multi-Space Architecture Plan

Date: October 31, 2025
Status: 🎯 DESIGN PHASE
Purpose: Proper multi-tenant space architecture with memberships


🎯 Current Problem

Your logs show:

spaceSlug: 'editor'  // ❌ From URL (wrong)
name: 'My space'     // ✅ Actual name

Root Cause: Space data cached with old wrong slug before our fix was applied.

Immediate Fix: Clear browser cache and IndexedDB, then refresh.


🏗️ Proper Multi-Space Architecture

Current (Wrong) ❌

User
  └─ Space (ONE, implicitly owned)
      ├─ id
      ├─ name
      ├─ spaceSlug (from URL - WRONG!)
      └─ ownerId

Correct (New) ✅

User
  ├─ id
  ├─ email
  └─ spaces: SpaceReference[]  // ✅ Array of spaces user has access to
      ├─ spaceId
      ├─ role (owner | admin | member | viewer)
      └─ joinedAt

Space
  ├─ id
  ├─ name
  ├─ spaceSlug (generated from name - CORRECT!)
  ├─ ownerId
  ├─ members: SpaceMember[]  // ✅ Who has access
  │   ├─ userId
  │   ├─ role (owner | admin | member | viewer)
  │   ├─ permissions []
  │   └─ joinedAt
  └─ universes: Universe[]

📊 Data Models

1. User Model (Backend)

// apis/backend-api/src/app/users/user.schema.ts
interface User {
  id: string;
  email: string;
  name: string;
  
  // 🔥 NEW: Array of spaces user has access to
  spaces: SpaceReference[];
  
  // Default space (for quick access)
  defaultSpaceId?: string;
  
  createdAt: string;
  updatedAt: string;
}

interface SpaceReference {
  spaceId: string;
  role: 'owner' | 'admin' | 'member' | 'viewer';
  joinedAt: string;
}

2. Space Model (Backend)

// apis/backend-api/src/app/spaces/spaces.schema.ts
interface Space {
  id: string;
  name: string;
  
  // 🔥 CRITICAL: Always generated from name, NEVER from URL
  spaceSlug: string;  // Generated: name.toLowerCase().replace(/\s+/g, '-')
  
  ownerId: string;
  
  // 🔥 NEW: Array of members with permissions
  members: SpaceMember[];
  
  universeIds: string[];
  settings: SpaceSettings;
  
  createdAt: string;
  updatedAt: string;
}

interface SpaceMember {
  userId: string;
  role: 'owner' | 'admin' | 'member' | 'viewer';
  permissions: string[];  // ['read', 'write', 'delete', 'invite', 'deploy']
  invitedBy: string;      // userId of inviter
  joinedAt: string;
}

🔄 Slug Generation Rules

CRITICAL: Slug ALWAYS Generated from Name

class SpaceService {
  
  // ✅ CORRECT: Generate slug from name
  private generateSlug(name: string): string {
    return name
      .toLowerCase()
      .replace(/[^a-z0-9\s-]/g, '')
      .trim()
      .replace(/\s+/g, '-')
      .replace(/-+/g, '-');
  }
  
  // ✅ When creating space
  async createSpace(name: string) {
    const space = {
      name: name,
      spaceSlug: this.generateSlug(name)  // ✅ From name, NOT URL
    };
  }
  
  // ✅ When updating space name
  async updateSpace(spaceId: string, updates: { name?: string }) {
    if (updates.name) {
      // Regenerate slug when name changes
      const newSlug = this.generateSlug(updates.name);
      
      // Check if slug is already taken
      const existing = await this.getSpaceBySlug(newSlug);
      if (existing && existing.id !== spaceId) {
        throw new Error(`Space slug '${newSlug}' is already taken`);
      }
      
      space.spaceSlug = newSlug;
    }
  }
}

❌ NEVER Do This:

// ❌ WRONG: Don't use URL slug
const spaceSlug = route.params['spaceSlug'];  // ❌
space.spaceSlug = spaceSlug;  // ❌

// ❌ WRONG: Don't let user set slug directly
space.spaceSlug = userInput;  // ❌

🚀 Implementation Plan

Phase 1: Fix Slug Generation (DONE ✅)

Phase 2: Add Members to Space

// Backend
POST /api/spaces/:spaceId/members
{
  "email": "[email protected]",
  "role": "member",
  "permissions": ["read", "write"]
}

GET /api/spaces/:spaceId/members
DELETE /api/spaces/:spaceId/members/:userId
PUT /api/spaces/:spaceId/members/:userId (update role/permissions)

Phase 3: Add Spaces to User

// Backend
GET /api/users/me/spaces  // Get all spaces user has access to
POST /api/users/me/spaces/:spaceId/set-default  // Set default space

// Frontend
class UserService {
  getUserSpaces(): Observable<Space[]> {
    // Returns all spaces user owns or is member of
  }
  
  getDefaultSpace(): Observable<Space> {
    // Returns user's default space
  }
}

Phase 4: Space Selector (Development Mode)

When user has multiple spaces, show selector:

// apps/gograb-space/src/app/components/space-selector/
@Component({
  selector: 'app-space-selector',
  template: `
    <div class="space-selector">
      <h3>Select a Space</h3>
      @for (space of userSpaces(); track space.id) {
        <button (click)="selectSpace(space)">
          <span>{{ space.name }}</span>
          <span>{{ space.spaceSlug }}</span>
          <span>{{ getUserRole(space) }}</span>
        </button>
      }
      
      <button (click)="createNewSpace()">
        + Create New Space
      </button>
    </div>
  `
})
export class SpaceSelectorComponent {
  private readonly userService = inject(UserService);
  private readonly spaceService = inject(SpaceService);
  
  readonly userSpaces = signal<Space[]>([]);
  
  async ngOnInit() {
    const spaces = await firstValueFrom(this.userService.getUserSpaces());
    this.userSpaces.set(spaces);
  }
  
  selectSpace(space: Space) {
    this.spaceService.setActiveSpace(space);
    this.router.navigate([`/${space.spaceSlug}/dashboard`]);
  }
  
  getUserRole(space: Space): string {
    const userId = this.authService.currentUser()?.id;
    const member = space.members.find(m => m.userId === userId);
    return member?.role || 'unknown';
  }
}

Phase 5: Routing Strategy

// Development Mode (localhost)
// URL: http://localhost:5200/{spaceSlug}/dashboard
// Space determined by: User's selected space from selector

// Production Mode (subdomain)
// URL: https://{spaceSlug}.gograb.space/dashboard
// Space determined by: Subdomain

// Implementation
@Injectable()
export class SpaceRoutingService {
  
  getSpaceSlugSource(): 'url' | 'subdomain' {
    const hostname = window.location.hostname;
    
    if (hostname === 'localhost' || hostname.startsWith('127.0.0.1')) {
      return 'url';  // Development: use URL slug
    } else {
      return 'subdomain';  // Production: use subdomain
    }
  }
  
  extractSpaceSlug(): string {
    const source = this.getSpaceSlugSource();
    
    if (source === 'subdomain') {
      // Extract from subdomain: my-space.gograb.space → my-space
      const hostname = window.location.hostname;
      return hostname.split('.')[0];
    } else {
      // Extract from URL: /my-space/dashboard → my-space
      const segments = this.router.url.split('/').filter(s => s);
      return segments[0] || 'default';
    }
  }
}

🎯 User Flows

Flow 1: New User (No Spaces)

  1. User registers/logs in
  2. System creates default space:
    {
      name: "My Space",
      spaceSlug: "my-space",  // ✅ Generated from name
      ownerId: userId,
      members: [
        { userId, role: 'owner', permissions: ['all'] }
      ]
    }
    
  3. User added to space:
    user.spaces = [
      { spaceId: space.id, role: 'owner', joinedAt: now }
    ];
    user.defaultSpaceId = space.id;
    
  4. Redirect to: /my-space/dashboard

Flow 2: Existing User (One Space)

  1. User logs in
  2. System loads user's default space
  3. Redirect to: /{space.spaceSlug}/dashboard
    • Example: /my-space/dashboard ✅ (not /editor/dashboard ❌)

Flow 3: User with Multiple Spaces

  1. User logs in
  2. Check: user.spaces.length > 1
  3. Show space selector component
  4. User selects space → Redirect to /{space.spaceSlug}/dashboard

Flow 4: User Invited to Space

  1. Owner invites user: POST /api/spaces/:spaceId/invite
  2. System sends invite email
  3. User clicks invite link
  4. System adds user to space:
    space.members.push({
      userId: invitedUser.id,
      role: 'member',
      permissions: ['read', 'write']
    });
    
    user.spaces.push({
      spaceId: space.id,
      role: 'member',
      joinedAt: now
    });
    
  5. User can now access space

🚨 Migration Plan (Existing Users)

For users with spaces created before the fix:

// Migration script (run once)
async function migrateSpaceSlugs() {
  const spaces = await Space.find({});
  
  for (const space of spaces) {
    // Regenerate slug from name
    const correctSlug = generateSlug(space.name);
    
    if (space.spaceSlug !== correctSlug) {
      console.log(`Migrating space: ${space.name}`);
      console.log(`  Old slug: ${space.spaceSlug}`);
      console.log(`  New slug: ${correctSlug}`);
      
      space.spaceSlug = correctSlug;
      await space.save();
    }
  }
  
  console.log('✅ Migration complete');
}

📊 Database Schema Updates

MongoDB Collections

// Collection: users
{
  _id: ObjectId("..."),
  email: "[email protected]",
  name: "User Name",
  
  // NEW: Array of spaces
  spaces: [
    {
      spaceId: "space_123",
      role: "owner",
      joinedAt: ISODate("2025-10-31...")
    },
    {
      spaceId: "space_456",
      role: "member",
      joinedAt: ISODate("2025-10-31...")
    }
  ],
  
  defaultSpaceId: "space_123",
  
  createdAt: ISODate("..."),
  updatedAt: ISODate("...")
}

// Collection: spaces
{
  _id: ObjectId("..."),
  name: "My Awesome Space",
  spaceSlug: "my-awesome-space",  // ✅ ALWAYS from name
  ownerId: "user_123",
  
  // NEW: Array of members
  members: [
    {
      userId: "user_123",
      role: "owner",
      permissions: ["all"],
      invitedBy: null,
      joinedAt: ISODate("2025-10-31...")
    },
    {
      userId: "user_456",
      role: "member",
      permissions: ["read", "write"],
      invitedBy: "user_123",
      joinedAt: ISODate("2025-11-01...")
    }
  ],
  
  universeIds: ["universe_1", "universe_2"],
  settings: { ... },
  
  createdAt: ISODate("..."),
  updatedAt: ISODate("...")
}

Indexes

// users collection
db.users.createIndex({ email: 1 }, { unique: true });
db.users.createIndex({ "spaces.spaceId": 1 });

// spaces collection
db.spaces.createIndex({ spaceSlug: 1 }, { unique: true });
db.spaces.createIndex({ ownerId: 1 });
db.spaces.createIndex({ "members.userId": 1 });

✅ Summary

Immediate Action Required:

  1. Clear your browser cache and IndexedDB:

    // Run in console
    indexedDB.deleteDatabase('gograb-space');
    localStorage.clear();
    location.reload();
    
  2. Verify slug generation:

    • Create new space with name "Amazing Platform"
    • Verify slug is "amazing-platform" (not from URL)

Architecture Changes Needed:

  1. ✅ Slug generation - DONE (but cached data has old slugs)
  2. 🔄 User.spaces[] - Add array of spaces to User model
  3. 🔄 Space.members[] - Add array of members to Space model
  4. 🔄 Space selector - UI for multi-space users
  5. 🔄 Invite system - Allow space owners to invite members

Next Step: Clear cached data and test the corrected slug generation! 🚀