Unclassified
Work Router - Migration & Enhancement Guide
This guide explains how to evolve the Work Router system from development (InMemory) to production (Drizzle + Neon), and how to add real data/features.
Phase 1: Auth Integration (Required First)
Current State
- Demo user hardcoded in pages
- No real Descope integration
Migration Steps
Step 1: Create Auth Helper
File: lib/auth.ts
export async function getAuthenticatedUser() {
try {
const user = await getSessionUser();
if (!user) return null;
return {
userId: user.loginIds?.[0] || user.userId,
email: user.email,
tenantId: user.customAttributes?.tenantId || "default-tenant",
name: user.name || "User",
};
} catch (error) {
console.error("[v0] Auth error:", error);
return null;
}
}
Step 2: Update Onboarding Page
File: app/onboarding/page.tsx
const user = await getAuthenticatedUser();
if (!user) {
redirect('/auth/login');
}
return (
<OnboardingContainer
userId={user.userId}
tenantId={user.tenantId}
initialName={user.name}
initialEmail={user.email}
/>
);
}
Step 3: Update Work Page
File: app/work/page.tsx
const user = await getAuthenticatedUser();
if (!user) {
redirect('/auth/login');
}
// Convert to server component
return <WorkViewContainer userId={user.userId} tenantId={user.tenantId} />;
}
Test: Visit /onboarding → Descope login → Complete flow → Redirects to /work
Phase 2: Real Sections (Medium Priority)
Current State
- 50+ placeholder section components
- All sections show "coming soon" text
Migration Strategy
Step 1: Identify Section Templates
Find existing dashboard/section implementations in the codebase to use as reference:
- Sales dashboard template
- Customer success section
- Financial dashboard
- Operations queue
Step 2: Create Section Components
File structure:
components/work/sections/
├── company-overview.tsx
├── revenue.tsx
├── pipeline.tsx
├── accounts.tsx
├── opportunities.tsx
├── campaigns.tsx
├── operations/
│ ├── tasks.tsx
│ ├── queues.tsx
│ └── approvals.tsx
└── admin/
├── users.tsx
├── roles.tsx
└── audit.tsx
Step 3: Update Section Map
File: app/work/page.tsx
// ... import all sections
const SECTION_COMPONENTS: Record<string, React.ReactNode> = {
'company-overview': <CompanyOverviewSection />,
'revenue': <RevenueSection />,
'pipeline': <PipelineSection />,
// ... all sections
};
Example Section Component
// components/work/sections/revenue.tsx
const [data, setData] = useState(null);
useEffect(() => {
async function loadData() {
// Call your API
const response = await fetch('/api/metrics/revenue');
setData(await response.json());
}
loadData();
}, []);
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-secondary rounded">
<div className="text-xs text-muted-foreground">MRR</div>
<div className="text-lg font-semibold">${data?.mrr || '—'}</div>
</div>
<div className="p-3 bg-secondary rounded">
<div className="text-xs text-muted-foreground">ARR</div>
<div className="text-lg font-semibold">${data?.arr || '—'}</div>
</div>
</div>
</div>
);
}
Phase 3: Database Migration (Production)
Current State
- InMemoryDataProvider (ephemeral)
- No persistence across sessions
Goal
- DrizzleDataProvider with Neon Postgres
- Persistent user profiles and audit logs
Migration Steps
Step 1: Setup Neon
Already configured in project (env vars present)
Step 2: Create Drizzle Schema
File: lib/data/schema.ts
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull(),
descopeUserId: text("descope_user_id").notNull(),
name: text("name"),
email: text("email"),
avatarUrl: text("avatar_url"),
onboardedAt: timestamp("onboarded_at"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id").notNull(),
tenantId: uuid("tenant_id").notNull(),
name: text("name").notNull(),
email: text("email").notNull(),
role: varchar("role", { length: 50 }).notNull(),
department: varchar("department", { length: 50 }).notNull(),
industry: varchar("industry", { length: 50 }).notNull(),
onboardedAt: timestamp("onboarded_at").notNull(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull(),
actorId: uuid("actor_id").notNull(),
action: text("action").notNull(),
entity: text("entity").notNull(),
entityId: text("entity_id"),
metadata: jsonb("metadata"),
createdAt: timestamp("created_at").defaultNow(),
});
// ... other tables
Step 3: Create DrizzleDataProvider
File: lib/data/drizzle-provider.ts
import { db } from "@/lib/db"; // Your Drizzle instance
export class DrizzleDataProvider implements DataProvider {
profiles = {
async get(userId: string): Promise<UserProfile | null> {
const result = await db
.select()
.from(userProfiles)
.where(eq(userProfiles.userId, userId))
.limit(1);
return result[0] || null;
},
async create(profile: Omit<UserProfile, "id" | "createdAt" | "updatedAt">) {
const result = await db.insert(userProfiles).values(profile).returning();
return result[0];
},
// ... other methods
};
// ... other repos
}
Step 4: Update Provider Factory
File: lib/data/index.ts
let provider: DataProvider | null = null;
if (!provider) {
const isProd = getEnv("NODE_ENV") === "production";
provider = isProd ? new DrizzleDataProvider() : new InMemoryDataProvider();
}
return provider;
}
Step 5: Create Migrations
File: migrations/0001_initial.sql
-- Create tenants table
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
industry VARCHAR(50),
environment VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
descope_user_id TEXT UNIQUE NOT NULL,
name TEXT,
email TEXT,
avatar_url TEXT,
onboarded_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create user_profiles table
CREATE TABLE IF NOT EXISTS user_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
email TEXT NOT NULL,
role VARCHAR(50) NOT NULL,
department VARCHAR(50) NOT NULL,
industry VARCHAR(50) NOT NULL,
onboarded_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create indexes
CREATE INDEX idx_user_profiles_user_id ON user_profiles(user_id);
CREATE INDEX idx_user_profiles_tenant_id ON user_profiles(tenant_id);
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
Step 6: Run Migrations
# Using Drizzle CLI
pnpm drizzle-kit push
# Or manually via Neon console
Step 7: Test
- Switch to production mode
- Test onboarding flow
- Verify data persists in Neon
- Verify profile loads on subsequent visits
Phase 4: Permissions Engine
Current State
- Basic section gating by permissions
- No fine-grained action control
Enhancement
Step 1: Expand Permission Model
File: lib/data/types.ts
export interface Permission {
id: string;
key: string;
resource: string; // 'section', 'action', 'feature'
scope: "global" | "tenant" | "user";
description: string;
createdAt: string;
}
export interface RolePermissionGrant {
id: string;
roleId: string;
permissionId: string;
deniedAt?: string; // Soft revoke
createdAt: string;
}
Step 2: Update Router
File: lib/work/router.ts
permissions: Permission[],
resource: string,
action: string
): boolean {
const key = `${resource}:${action}`;
return permissions.some((p) => p.key === key);
}
// In resolveWorkView:
export async function resolveWorkView(context: WorkContext): Promise<ResolvedWorkView> {
// ... existing code ...
// For each section, check if user can access
let filteredSections = roleDefinition.sections.filter((section) => {
// Existing permission checks...
// New fine-grained checks
if (!canUserAction(permissions, section.id, "view")) {
return false;
}
return true;
});
return {
/* ... */
};
}
Step 3: Gate Actions in Components
// components/work/sections/revenue.tsx
const canExport = permissions.some(p => p.key === 'revenue:export');
const canEdit = permissions.some(p => p.key === 'revenue:edit');
return (
<div>
{canExport && <button>Export</button>}
{canEdit && <button>Edit Targets</button>}
</div>
);
}
Phase 5: Settings & Preferences
Add Settings Page
File: app/settings/page.tsx
const user = await getAuthenticatedUser();
const provider = getDataProvider();
const profile = await provider.profiles.get(user.userId);
return (
<div>
<h1>Workspace Settings</h1>
<UserProfileForm profile={profile} />
<WorkspacePreferencesForm profile={profile} />
<ActivityLog userId={user.userId} />
</div>
);
}
Implementation Roadmap
| Phase | Priority | Effort | Dependencies | Blocker |
|---|---|---|---|---|
| Auth Integration | CRITICAL | 2-4h | Descope SDK | YES |
| Real Sections | HIGH | 3-5 days | Section templates | NO |
| Database | HIGH | 2-3 days | Neon setup | NO |
| Permissions | MEDIUM | 1-2 days | DB schema | NO |
| Settings | MEDIUM | 1 day | Permissions | NO |
| Audit Logging | LOW | 1 day | DB schema | NO |
Testing Checklist for Each Phase
Phase 1: Auth
- Descope login redirects to onboarding
- Auth token passed to backend
- TenantId correctly set
- Logout works and redirects to login
Phase 2: Sections
- Each section renders without errors
- Section data loads from API
- Section state updates correctly
- Error states display gracefully
- All 50+ sections work
Phase 3: Database
- Profile persists across sessions
- Multiple users have separate profiles
- Audit logs record all actions
- Data is tenant-scoped
- No data leaks between tenants
Phase 4: Permissions
- Sections hidden when lacking permissions
- Actions hidden when lacking permissions
- Permission inheritance from role works
- Permission revocation works
- Upselling shows for restricted features
Phase 5: Settings
- User can update profile
- User can change preferences
- Activity feed shows history
- Changes audit logged
- Permissions respected
Troubleshooting
"Profile not found" error
- Check Descope user is in tenant
- Verify onboarding was completed
- Check DataProvider is initialized
- In Drizzle phase: verify DB migrations ran
"Sections not rendering"
- Check section component imports
- Verify SECTION_COMPONENTS map is complete
- Check for TypeScript errors
- Verify data API is responding
"Permission denied" errors
- Check role-permission mappings in seed data
- Verify permission keys match
- In Drizzle phase: check DB seeding
Database connection errors
- Verify Neon credentials in env
- Check network connectivity
- Verify migrations ran successfully
- Check SQL syntax in schema
Performance Optimization Tips
- Caching - Cache resolved views in Redis
- Lazy Loading - Load sections on-demand not all at once
- Image Optimization - Use Next.js Image for section icons
- Data Fetching - Use SWR for section data
- Query Optimization - Add indexes for frequently queried fields
Deployment Checklist
- All sections implemented
- Auth integrated and tested
- Database migrations deployed
- Permissions configured
- Audit logging enabled
- Error monitoring set up
- Performance tested
- Security review passed
- User documentation complete
- Staged rollout plan created
For questions or issues, refer to:
WORK_ROUTER_IMPLEMENTATION.md- Technical detailsWORK_ROUTER_QUICKSTART.md- Quick referenceWORK_ROUTER_DELIVERY.md- What's delivered