🌌 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 ✅)
- SpaceService generates slug from name
- Backend validates slug uniqueness
- Never use URL slug for space.spaceSlug
- Clear cached data (USER ACTION REQUIRED)
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)
- User registers/logs in
- System creates default space:
{ name: "My Space", spaceSlug: "my-space", // ✅ Generated from name ownerId: userId, members: [ { userId, role: 'owner', permissions: ['all'] } ] } - User added to space:
user.spaces = [ { spaceId: space.id, role: 'owner', joinedAt: now } ]; user.defaultSpaceId = space.id; - Redirect to:
/my-space/dashboard
Flow 2: Existing User (One Space)
- User logs in
- System loads user's default space
- Redirect to:
/{space.spaceSlug}/dashboard- Example:
/my-space/dashboard✅ (not/editor/dashboard❌)
- Example:
Flow 3: User with Multiple Spaces
- User logs in
- Check:
user.spaces.length > 1 - Show space selector component
- User selects space → Redirect to
/{space.spaceSlug}/dashboard
Flow 4: User Invited to Space
- Owner invites user:
POST /api/spaces/:spaceId/invite - System sends invite email
- User clicks invite link
- 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 }); - 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:
Clear your browser cache and IndexedDB:
// Run in console indexedDB.deleteDatabase('gograb-space'); localStorage.clear(); location.reload();Verify slug generation:
- Create new space with name "Amazing Platform"
- Verify slug is "amazing-platform" (not from URL)
Architecture Changes Needed:
- ✅ Slug generation - DONE (but cached data has old slugs)
- 🔄 User.spaces[] - Add array of spaces to User model
- 🔄 Space.members[] - Add array of members to Space model
- 🔄 Space selector - UI for multi-space users
- 🔄 Invite system - Allow space owners to invite members
Next Step: Clear cached data and test the corrected slug generation! 🚀