# Hive Switch Migration Plan
## Standalone Next.js Application for Customer Owner Reallocation
---
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [Architecture Overview](#2-architecture-overview)
3. [Repository Structure](#3-repository-structure)
4. [Technology Stack](#4-technology-stack)
5. [Database Integration](#5-database-integration)
6. [Authentication & Security](#6-authentication--security)
7. [Feature Implementation](#7-feature-implementation)
8. [Reallocation Business Logic Options](#8-reallocation-business-logic-options)
9. [UI/UX Migration](#9-uiux-migration)
10. [Deployment Strategy](#10-deployment-strategy)
11. [Testing Strategy](#11-testing-strategy)
12. [Migration Checklist](#12-migration-checklist)
13. [Alternative Architecture: Microservice (Option C)](#13-alternative-architecture-microservice-option-c)
14. [Risk Assessment & Mitigations](#14-risk-assessment--mitigations)
15. [Future Considerations](#15-future-considerations)
---
## 1. Executive Summary
### Objective
Create a standalone Next.js application (`hive-switch`) that provides internal operations teams with the ability to manage customer ownership ("beekeeper") reallocations, while maintaining compatibility with the existing Postgres database shared with `web-app-alpha`.
### Key Deliverables
- Standalone Next.js 14+ application with App Router
- Direct Postgres integration via Sequelize (matching legacy versions)
- Three operational tabs: Account Distributions, Lead Distributions, Reallocations
- VPN-restricted access via Cloudflare WARP Zero Trust
- Tailwind CSS styling system
- Extensible architecture for future hive management features
---
## 2. Architecture Overview
### Option A: Direct Database Connection (Primary Approach)
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Cloudflare WARP Zero Trust │
│ (VPN Gateway) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ hive-switch (New App) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Next.js 14 (App Router) │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │
│ │ │ Account │ │ Lead │ │ Reallocations │ │ │
│ │ │ Distributions│ │ Distributions│ │ Tab │ │ │
│ │ └──────────────┘ └──────────────┘ └────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Next.js API Routes │ │
│ │ ┌──────────────────────┐ ┌──────────────────────────────────┐ │ │
│ │ │ GET /api/workload │ │ POST /api/reallocate │ │ │
│ │ └──────────────────────┘ └──────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Data Access Layer │ │
│ │ ┌──────────────────────────────────────────────────────────┐ │ │
│ │ │ Sequelize v5.22.5 + pg v8.11.0 │ │ │
│ │ │ (Version-matched with web-app-alpha for compatibility) │ │ │
│ │ └──────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Shared Postgres Database │
│ ┌─────────────────────────┐ ┌─────────────────────────────────┐ │
│ │ account table │ │ lead table │ │
│ │ - owner_email__c │ │ - owner_email__c │ │
│ │ - ownerid │ │ - ownerid │ │
│ │ - account_status__c │ │ - selected_contribution_... │ │
│ └─────────────────────────┘ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
▲
│
┌─────────────────────────────────────────────────────────────────────────┐
│ web-app-alpha (Existing App) │
│ (Continues normal operations) │
└─────────────────────────────────────────────────────────────────────────┘
```
### Data Flow
```
User (VPN) → Cloudflare WARP → hive-switch UI → API Route → Sequelize → Postgres
↑
web-app-alpha (concurrent access)
```
---
## 3. Repository Structure
```
hive-switch/
├── .github/
│ └── workflows/
│ ├── ci.yml # Linting, type-checking, tests
│ └── deploy.yml # Heroku deployment
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── layout.tsx # Root layout with providers
│ │ ├── page.tsx # Dashboard redirect
│ │ ├── globals.css # Tailwind imports
│ │ ├── (auth)/ # Auth-related routes (future)
│ │ │ └── login/
│ │ │ └── page.tsx
│ │ └── (dashboard)/ # Protected dashboard routes
│ │ ├── layout.tsx # Dashboard layout with nav
│ │ └── operations/
│ │ ├── page.tsx # Main operations page with tabs
│ │ └── loading.tsx # Loading state
│ ├── api/ # API Routes
│ │ ├── workload/
│ │ │ └── route.ts # GET beekeeper workload
│ │ ├── reallocate/
│ │ │ └── route.ts # POST reallocation
│ │ └── health/
│ │ └── route.ts # Health check endpoint
│ ├── components/
│ │ ├── ui/ # Reusable UI components
│ │ │ ├── button.tsx
│ │ │ ├── select.tsx
│ │ │ ├── table.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── card.tsx
│ │ │ └── alert.tsx
│ │ ├── operations/ # Feature-specific components
│ │ │ ├── account-distributions-tab.tsx
│ │ │ ├── lead-distributions-tab.tsx
│ │ │ ├── reallocations-tab.tsx
│ │ │ └── workload-summary.tsx
│ │ └── layout/ # Layout components
│ │ ├── header.tsx
│ │ ├── sidebar.tsx
│ │ └── footer.tsx
│ ├── lib/
│ │ ├── db/ # Database layer
│ │ │ ├── sequelize.ts # Sequelize instance
│ │ │ ├── models/
│ │ │ │ ├── index.ts # Model exports
│ │ │ │ ├── account.model.ts # Account model (ported)
│ │ │ │ └── lead.model.ts # Lead model (ported)
│ │ │ └── repositories/
│ │ │ ├── account.repository.ts
│ │ │ └── lead.repository.ts
│ │ ├── services/ # Business logic
│ │ │ ├── workload.service.ts
│ │ │ └── reallocation.service.ts
│ │ ├── validations/ # Zod schemas
│ │ │ ├── workload.schema.ts
│ │ │ └── reallocation.schema.ts
│ │ └── utils/ # Utility functions
│ │ ├── date.ts
│ │ └── formatting.ts
│ ├── hooks/ # React hooks
│ │ ├── use-workload.ts
│ │ └── use-reallocation.ts
│ ├── types/ # TypeScript types
│ │ ├── api.ts
│ │ ├── models.ts
│ │ └── index.ts
│ └── middleware.ts # Next.js middleware (auth/VPN checks)
├── public/
│ └── images/
├── tests/
│ ├── unit/
│ ├── integration/
│ └── e2e/
├── .env.example # Environment variables template
├── .env.local # Local development (gitignored)
├── .eslintrc.json
├── .prettierrc
├── .gitignore
├── next.config.js
├── package.json
├── postcss.config.js
├── tailwind.config.ts
├── tsconfig.json
└── README.md
```
---
## 4. Technology Stack
### Core Dependencies
```json
{
"name": "hive-switch",
"version": "1.0.0",
"private": true,
"engines": {
"node": "18",
"npm": "9"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit",
"test": "jest",
"test:e2e": "playwright test"
},
"dependencies": {
"next": "^14.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"pg": "^8.11.0",
"sequelize": "^5.22.5",
"@tanstack/react-query": "^5.0.0",
"zod": "^3.23.0",
"tailwind-merge": "^2.2.0",
"clsx": "^2.1.0",
"@radix-ui/react-tabs": "^1.0.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-alert-dialog": "^1.0.0",
"date-fns": "^3.6.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"typescript": "^5.4.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"prettier": "^3.2.0",
"prettier-plugin-tailwindcss": "^0.5.0",
"jest": "^29.7.0",
"@testing-library/react": "^15.0.0",
"@testing-library/jest-dom": "^6.4.0",
"@playwright/test": "^1.43.0"
}
}
```
### Version Compatibility Analysis
| Package | web-app-alpha | hive-switch | Compatibility Notes |
|---------|---------------|-------------|---------------------|
| `pg` | ^8.11.0 | ^8.11.0 | ✅ Exact match recommended |
| `sequelize` | ^5.22.5 | ^5.22.5 | ✅ **Critical**: Must match for schema compatibility |
| `node` | 16 | 18 | ⚠️ Sequelize 5.x works with Node 18, but test thoroughly |
| `zod` | ^3.22.4 | ^3.23.0 | ✅ Minor version, backwards compatible |
### Why Sequelize v5.22.5 Matters
```typescript
// Sequelize v5 vs v6+ has breaking changes in:
// 1. Model definition syntax
// 2. Transaction handling
// 3. Association definitions
// 4. TypeScript types
// v5 syntax (current):
const Account = sequelize.define('account', { /* ... */ }, { /* options */ });
// v6+ syntax (incompatible):
class Account extends Model {
declare sfid: string;
}
Account.init({ /* ... */ }, { sequelize });
// RECOMMENDATION: Lock to exact versions
"pg": "8.11.0", // Not ^8.11.0
"sequelize": "5.22.5", // Not ^5.22.5
```
### Potential Upgrade Path (Future Consideration)
If you need to upgrade Sequelize in the future:
1. **Sequelize v6**: Requires model class syntax changes
2. **Sequelize v7**: TypeScript-first, significant refactor
3. **Alternative**: Consider Drizzle ORM or Prisma for new projects
> **Recommendation**: For this project, maintain Sequelize v5.22.5 for guaranteed compatibility. Plan a coordinated upgrade of both applications in the future.
---
## 5. Database Integration
### Sequelize Configuration
```typescript
// src/lib/db/sequelize.ts
import { Sequelize } from 'sequelize';
function getDatabaseUrl(): string {
const { DATABASE_URL, CI, PG_USER, PG_PASSWORD } = process.env;
if (CI) {
return `postgres://${PG_USER}:${PG_PASSWORD}@127.0.0.1:5433/development`;
}
if (!DATABASE_URL) {
throw new Error('DATABASE_URL environment variable is required');
}
return DATABASE_URL;
}
const sequelize = new Sequelize(getDatabaseUrl(), {
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false,
},
},
logging: process.env.NODE_ENV === 'development' ? console.log : false,
pool: {
max: 5, // Lower than web-app-alpha to avoid connection exhaustion
min: 0,
acquire: 30000,
idle: 10000,
},
});
export default sequelize;
```
### Account Model (Ported)
```typescript
// src/lib/db/models/account.model.ts
import { DataTypes, Model, Op } from 'sequelize';
import sequelize from '../sequelize';
interface AccountAttributes {
id?: number;
sfid?: string;
personemail?: string;
owner_email__c?: string;
ownerid?: string;
owner_first_name__c?: string;
account_status__c?: string;
firstname?: string;
lastname?: string;
// Add other fields as needed
}
interface AccountInstance extends Model<AccountAttributes>, AccountAttributes {}
const Account = sequelize.define<AccountInstance>(
'account',
{
sfid: {
type: DataTypes.STRING(18),
unique: true,
},
personemail: {
type: DataTypes.STRING(75),
},
owner_email__c: {
type: DataTypes.STRING(255),
},
ownerid: {
type: DataTypes.STRING(18),
},
owner_first_name__c: {
type: DataTypes.STRING(255),
},
account_status__c: {
type: DataTypes.STRING(255),
},
firstname: {
type: DataTypes.STRING(30),
},
lastname: {
type: DataTypes.STRING(50),
},
is_duplicate_account__c: {
type: DataTypes.BOOLEAN,
},
// Add other required fields...
},
{
freezeTableName: true,
timestamps: false,
}
);
export default Account;
export { AccountAttributes, AccountInstance };
```
### Account Repository
```typescript
// src/lib/db/repositories/account.repository.ts
import { Op, fn, col, literal } from 'sequelize';
import Account from '../models/account.model';
import sequelize from '../sequelize';
export interface AccountCountByOwner {
[email: string]: {
pensionLive: number;
other: number;
};
}
export const accountRepository = {
/**
* Get distinct beekeeper emails from accounts
*/
async getBeekeepers(): Promise<string[]> {
const records = await Account.findAll({
attributes: [[fn('DISTINCT', col('owner_email__c')), 'owner_email__c']],
where: {
ownerid: { [Op.ne]: null },
owner_email__c: { [Op.ne]: null },
},
order: [['owner_email__c', 'ASC']],
});
return records.map((r) => r.owner_email__c as string);
},
/**
* Get account counts grouped by owner email
*/
async getCountByOwnerEmail(): Promise<AccountCountByOwner> {
const records = await Account.findAll({
attributes: [
'owner_email__c',
[
fn('COUNT', literal("CASE WHEN account_status__c = 'Pension Live' THEN 1 END")),
'pensionLiveCount',
],
[
fn('COUNT', literal("CASE WHEN account_status__c != 'Pension Live' OR account_status__c IS NULL THEN 1 END")),
'otherCount',
],
],
where: {
owner_email__c: { [Op.ne]: null },
},
group: ['owner_email__c'],
order: [['owner_email__c', 'ASC']],
raw: true,
});
const result: AccountCountByOwner = {};
records.forEach((record: any) => {
if (record.owner_email__c) {
result[record.owner_email__c] = {
pensionLive: parseInt(record.pensionLiveCount, 10),
other: parseInt(record.otherCount, 10),
};
}
});
return result;
},
/**
* Get total account count
*/
async getTotalCount(): Promise<number> {
return Account.count();
},
/**
* Update owner for accounts matching criteria
*/
async updateOwner(
currentOwnerEmail: string,
newOwnerEmail: string,
newOwnerId: string,
options?: {
customerType?: 'pensionLive' | 'other';
limit?: number;
}
): Promise<[number, AccountInstance[]]> {
const where: any = {
owner_email__c: currentOwnerEmail,
};
if (options?.customerType === 'pensionLive') {
where.account_status__c = 'Pension Live';
} else if (options?.customerType === 'other') {
where.account_status__c = { [Op.or]: [{ [Op.ne]: 'Pension Live' }, null] };
}
// Use transaction for data integrity
return sequelize.transaction(async (transaction) => {
// If limit is specified, we need to select specific records first
if (options?.limit) {
const accountsToUpdate = await Account.findAll({
where,
limit: options.limit,
attributes: ['id'],
transaction,
});
const ids = accountsToUpdate.map((a) => a.id);
return Account.update(
{
owner_email__c: newOwnerEmail,
ownerid: newOwnerId,
},
{
where: { id: { [Op.in]: ids } },
transaction,
returning: true,
}
);
}
return Account.update(
{
owner_email__c: newOwnerEmail,
ownerid: newOwnerId,
},
{
where,
transaction,
returning: true,
}
);
});
},
};
```
### Connection Pooling Strategy
Since both applications will access the same database:
```
┌─────────────────────────────────────────────────────────────────┐
│ Heroku Postgres │
│ │
│ Connection Limit: ~500 (varies by plan) │
│ │
│ ┌─────────────────────┐ ┌─────────────────────┐ │
│ │ web-app-alpha │ │ hive-switch │ │
│ │ Pool: max 20 │ │ Pool: max 5 │ │
│ │ (production) │ │ (internal tool) │ │
│ └─────────────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
> **Recommendation**: Keep `hive-switch` pool small (max 5) since it's an internal tool with limited concurrent users. Monitor connection usage via Heroku metrics.
---
## 6. Authentication & Security
### Cloudflare WARP Zero Trust Integration
```typescript
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Cloudflare Access headers
const cfAccessJwt = request.headers.get('cf-access-jwt-assertion');
const cfAccessAuthenticated = request.headers.get('cf-access-authenticated-user-email');
// Health check bypass
if (request.nextUrl.pathname === '/api/health') {
return NextResponse.next();
}
// Development bypass (remove in production)
if (process.env.NODE_ENV === 'development' && process.env.BYPASS_AUTH === 'true') {
return NextResponse.next();
}
// Verify Cloudflare Access headers
if (!cfAccessJwt || !cfAccessAuthenticated) {
console.warn('Access denied: Missing Cloudflare Access headers', {
path: request.nextUrl.pathname,
ip: request.ip,
});
return new NextResponse(
JSON.stringify({ error: 'Unauthorized', message: 'VPN access required' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
// Optional: Verify JWT signature with Cloudflare's public key
// This requires fetching keys from: https://<team-domain>.cloudflareaccess.com/cdn-cgi/access/certs
// Add user email to request headers for downstream use
const response = NextResponse.next();
response.headers.set('x-user-email', cfAccessAuthenticated);
return response;
}
export const config = {
matcher: [
// Match all paths except static files and images
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};
```
### Cloudflare Access Setup Requirements
1. **Create Access Application** in Cloudflare Zero Trust dashboard:
- Application type: Self-hosted
- Application domain: `hive-switch.herokuapp.com` (or custom domain)
- Session duration: 24 hours (recommended for internal tools)
2. **Create Access Policy**:
```
Policy Name: PensionBee Internal Team
Action: Allow
Include:
- Emails ending in: @pensionbee.com
- OR: Specific user emails
```
3. **Configure WARP Client**:
- Users must have WARP installed and connected
- Application appears in their Access launcher
### Future Role-Based Access Control (RBAC) Consideration
```typescript
// src/types/auth.ts
export type UserRole = 'viewer' | 'operator' | 'admin';
export interface User {
email: string;
role: UserRole;
permissions: Permission[];
}
export type Permission =
| 'workload:read'
| 'reallocation:create'
| 'reallocation:approve'
| 'settings:manage';
// Future: Store roles in database or integrate with identity provider
export const rolePermissions: Record<UserRole, Permission[]> = {
viewer: ['workload:read'],
operator: ['workload:read', 'reallocation:create'],
admin: ['workload:read', 'reallocation:create', 'reallocation:approve', 'settings:manage'],
};
```
### Audit Logging
```typescript
// src/lib/services/audit.service.ts
import sequelize from '../db/sequelize';
export interface AuditLogEntry {
action: 'REALLOCATION_CREATED' | 'REALLOCATION_EXECUTED' | 'REALLOCATION_FAILED';
performedBy: string;
details: Record<string, any>;
timestamp: Date;
ipAddress?: string;
}
export const auditService = {
async log(entry: AuditLogEntry): Promise<void> {
// Option 1: Log to database table
await sequelize.query(
`INSERT INTO audit_log (action, performed_by, details, timestamp, ip_address)
VALUES (:action, :performedBy, :details, :timestamp, :ipAddress)`,
{
replacements: {
action: entry.action,
performedBy: entry.performedBy,
details: JSON.stringify(entry.details),
timestamp: entry.timestamp,
ipAddress: entry.ipAddress || null,
},
}
);
// Option 2: Also send to external logging service (Datadog, etc.)
console.info('[AUDIT]', JSON.stringify(entry));
},
};
```
---
## 7. Feature Implementation
### API Routes
#### GET /api/workload
```typescript
// src/app/api/workload/route.ts
import { NextResponse } from 'next/server';
import { accountRepository } from '@/lib/db/repositories/account.repository';
import { leadRepository } from '@/lib/db/repositories/lead.repository';
import { workloadResponseSchema } from '@/lib/validations/workload.schema';
export async function GET() {
try {
const [
beekeepers,
accountCount,
accountCountByOwnerEmail,
leadCount,
leadCountByOwnerEmail,
accountTotalCount,
] = await Promise.all([
accountRepository.getBeekeepers(),
accountRepository.getTotalCount(),
accountRepository.getCountByOwnerEmail(),
leadRepository.getTotalCount(),
leadRepository.getCountByOwnerEmail(),
accountRepository.getTotalCount(),
]);
const responseData = {
beekeepers,
accountCount,
accountCountByOwnerEmail,
leadCount,
leadCountByOwnerEmail,
accountTotalCount,
};
// Validate response shape
const validated = workloadResponseSchema.parse(responseData);
return NextResponse.json(validated);
} catch (error) {
console.error('Error fetching workload data:', error);
return NextResponse.json(
{ error: 'Failed to fetch workload data' },
{ status: 500 }
);
}
}
```
#### POST /api/reallocate
```typescript
// src/app/api/reallocate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { reallocationRequestSchema } from '@/lib/validations/reallocation.schema';
import { reallocationService } from '@/lib/services/reallocation.service';
import { auditService } from '@/lib/services/audit.service';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const userEmail = request.headers.get('x-user-email') || 'unknown';
// Validate request
const validationResult = reallocationRequestSchema.safeParse(body);
if (!validationResult.success) {
return NextResponse.json(
{ error: 'Validation failed', details: validationResult.error.issues },
{ status: 400 }
);
}
const { currentBeekeeper, targetBeekeeperArray, accountOrLead, customerType } = validationResult.data;
// Execute reallocation
const result = await reallocationService.executeReallocation({
currentBeekeeper,
targetBeekeeperArray,
accountOrLead,
customerType,
});
// Audit log
await auditService.log({
action: 'REALLOCATION_EXECUTED',
performedBy: userEmail,
details: {
currentBeekeeper,
targetBeekeeperArray,
accountOrLead,
customerType,
affectedCount: result.affectedCount,
},
timestamp: new Date(),
ipAddress: request.ip,
});
return NextResponse.json({
success: true,
message: `Successfully reallocated ${result.affectedCount} ${accountOrLead}s`,
details: result,
});
} catch (error) {
console.error('Reallocation error:', error);
return NextResponse.json(
{ error: 'Reallocation failed', message: (error as Error).message },
{ status: 500 }
);
}
}
```
### React Query Hooks
```typescript
// src/hooks/use-workload.ts
import { useQuery } from '@tanstack/react-query';
import { z } from 'zod';
import { workloadResponseSchema } from '@/lib/validations/workload.schema';
type WorkloadData = z.infer<typeof workloadResponseSchema>;
async function fetchWorkload(): Promise<WorkloadData> {
const response = await fetch('/api/workload');
if (!response.ok) {
throw new Error('Failed to fetch workload data');
}
const data = await response.json();
return workloadResponseSchema.parse(data);
}
export function useWorkload() {
return useQuery({
queryKey: ['workload'],
queryFn: fetchWorkload,
staleTime: 30 * 1000, // Consider data stale after 30 seconds
refetchOnWindowFocus: true,
});
}
```
```typescript
// src/hooks/use-reallocation.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { reallocationRequestSchema, reallocationResponseSchema } from '@/lib/validations/reallocation.schema';
type ReallocationRequest = z.infer<typeof reallocationRequestSchema>;
type ReallocationResponse = z.infer<typeof reallocationResponseSchema>;
async function executeReallocation(data: ReallocationRequest): Promise<ReallocationResponse> {
const response = await fetch('/api/reallocate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Reallocation failed');
}
return reallocationResponseSchema.parse(await response.json());
}
export function useReallocation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: executeReallocation,
onSuccess: () => {
// Invalidate workload query to refetch updated data
queryClient.invalidateQueries({ queryKey: ['workload'] });
},
});
}
```
---
## 8. Reallocation Business Logic Options
### Option A: Round-Robin Distribution
Distributes customers evenly across target beekeepers in order.
```typescript
// src/lib/services/reallocation.service.ts
interface ReallocationParams {
currentBeekeeper: string;
targetBeekeeperArray: string[];
accountOrLead: 'account' | 'lead';
customerType: string;
}
interface ReallocationResult {
affectedCount: number;
distribution: { [beekeeper: string]: number };
}
async function roundRobinReallocation(params: ReallocationParams): Promise<ReallocationResult> {
const { currentBeekeeper, targetBeekeeperArray, accountOrLead, customerType } = params;
return sequelize.transaction(async (transaction) => {
// Get all customers to reallocate
const repository = accountOrLead === 'account' ? accountRepository : leadRepository;
const customers = await repository.findByOwner(currentBeekeeper, customerType, { transaction });
const distribution: { [beekeeper: string]: number } = {};
targetBeekeeperArray.forEach(bk => distribution[bk] = 0);
// Round-robin assignment
for (let i = 0; i < customers.length; i++) {
const targetIndex = i % targetBeekeeperArray.length;
const targetBeekeeper = targetBeekeeperArray[targetIndex];
await repository.updateOwner(
customers[i].id,
targetBeekeeper,
{ transaction }
);
distribution[targetBeekeeper]++;
}
return {
affectedCount: customers.length,
distribution,
};
});
}
```
**Pros:**
- Simple and predictable
- Ensures even distribution
**Cons:**
- Doesn't consider existing workload
---
### Option B: Workload-Based Distribution
Distributes to equalize workload across all beekeepers.
```typescript
async function workloadBasedReallocation(params: ReallocationParams): Promise<ReallocationResult> {
const { currentBeekeeper, targetBeekeeperArray, accountOrLead, customerType } = params;
return sequelize.transaction(async (transaction) => {
// Get current workload for all target beekeepers
const currentWorkloads = await getWorkloadsForBeekeepers(
targetBeekeeperArray,
accountOrLead,
customerType,
{ transaction }
);
// Get customers to reallocate
const repository = accountOrLead === 'account' ? accountRepository : leadRepository;
const customers = await repository.findByOwner(currentBeekeeper, customerType, { transaction });
// Calculate target workload (average after redistribution)
const totalToDistribute = customers.length;
const currentTotal = Object.values(currentWorkloads).reduce((a, b) => a + b, 0);
const targetAverage = Math.floor((currentTotal + totalToDistribute) / targetBeekeeperArray.length);
const distribution: { [beekeeper: string]: number } = {};
targetBeekeeperArray.forEach(bk => distribution[bk] = 0);
// Assign to beekeepers below average first
let customerIndex = 0;
// Sort by current workload (lowest first)
const sortedBeekeepers = targetBeekeeperArray.sort(
(a, b) => currentWorkloads[a] - currentWorkloads[b]
);
for (const beekeeper of sortedBeekeepers) {
const currentLoad = currentWorkloads[beekeeper];
const toAssign = Math.min(targetAverage - currentLoad, customers.length - customerIndex);
if (toAssign > 0) {
for (let i = 0; i < toAssign && customerIndex < customers.length; i++) {
await repository.updateOwner(
customers[customerIndex].id,
beekeeper,
{ transaction }
);
distribution[beekeeper]++;
customerIndex++;
}
}
}
// Distribute remaining customers round-robin
while (customerIndex < customers.length) {
const targetIndex = customerIndex % targetBeekeeperArray.length;
const targetBeekeeper = targetBeekeeperArray[targetIndex];
await repository.updateOwner(
customers[customerIndex].id,
targetBeekeeper,
{ transaction }
);
distribution[targetBeekeeper]++;
customerIndex++;
}
return {
affectedCount: customers.length,
distribution,
};
});
}
```
**Pros:**
- Balances overall workload
- More equitable distribution
**Cons:**
- More complex logic
- Requires additional queries
---
### Option C: Proportional Distribution
Distributes based on current capacity ratios.
```typescript
async function proportionalReallocation(params: ReallocationParams): Promise<ReallocationResult> {
// Calculate what percentage of the total workload each beekeeper currently handles
// Distribute new customers in the same proportions
const currentWorkloads = await getWorkloadsForBeekeepers(targetBeekeeperArray, ...);
const totalWorkload = Object.values(currentWorkloads).reduce((a, b) => a + b, 0);
const proportions = targetBeekeeperArray.map(bk => ({
beekeeper: bk,
proportion: totalWorkload > 0 ? currentWorkloads[bk] / totalWorkload : 1 / targetBeekeeperArray.length,
}));
// Distribute according to proportions
// ...
}
```
**Pros:**
- Maintains current workload ratios
- Good for teams with different capacities
**Cons:**
- May perpetuate imbalances
- More complex calculation
---
### Option D: Configurable Strategy
Allow the user to choose the distribution method.
```typescript
// src/lib/services/reallocation.service.ts
type DistributionStrategy = 'round-robin' | 'workload-based' | 'proportional';
interface ReallocationParams {
currentBeekeeper: string;
targetBeekeeperArray: string[];
accountOrLead: 'account' | 'lead';
customerType: string;
strategy: DistributionStrategy; // User-selected
}
export const reallocationService = {
async executeReallocation(params: ReallocationParams): Promise<ReallocationResult> {
switch (params.strategy) {
case 'round-robin':
return roundRobinReallocation(params);
case 'workload-based':
return workloadBasedReallocation(params);
case 'proportional':
return proportionalReallocation(params);
default:
throw new Error(`Unknown distribution strategy: ${params.strategy}`);
}
},
};
```
### Recommendation
Start with **Option A (Round-Robin)** for initial implementation due to simplicity. Add **Option D (Configurable)** as a follow-up enhancement once business rules are finalized.
---
## 9. UI/UX Migration
### Tailwind Configuration
```typescript
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
// PensionBee brand colors (adjust to match brand)
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#2563eb', // Main brand blue
600: '#1d4ed8',
700: '#1e40af',
800: '#1e3a8a',
900: '#1e3a8a',
},
honey: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24', // Accent yellow
500: '#f59e0b',
600: '#d97706',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};
export default config;
```
### Component Examples
#### Tabs Component
```tsx
// src/components/ui/tabs.tsx
'use client';
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center gap-1 border-b border-gray-200',
className
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap px-4 py-2',
'text-sm font-medium text-gray-500 transition-all',
'hover:text-gray-700',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
'disabled:pointer-events-none disabled:opacity-50',
'data-[state=active]:border-b-2 data-[state=active]:border-primary-500',
'data-[state=active]:text-primary-600 data-[state=active]:font-semibold',
'-mb-px', // Overlap with border
className
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
className
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
```
#### Operations Page
```tsx
// src/app/(dashboard)/operations/page.tsx
'use client';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { AccountDistributionsTab } from '@/components/operations/account-distributions-tab';
import { LeadDistributionsTab } from '@/components/operations/lead-distributions-tab';
import { ReallocationsTab } from '@/components/operations/reallocations-tab';
import { useWorkload } from '@/hooks/use-workload';
import { Loader2 } from 'lucide-react';
export default function OperationsPage() {
const { data, isLoading, error } = useWorkload();
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary-500" />
</div>
);
}
if (error) {
return (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700">
Failed to load workload data. Please try again.
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Hive Operations</h1>
<p className="mt-1 text-sm text-gray-500">
Manage customer ownership and beekeeper workload distribution
</p>
</div>
<Tabs defaultValue="accounts" className="w-full">
<TabsList>
<TabsTrigger value="accounts">Account Distributions</TabsTrigger>
<TabsTrigger value="leads">Lead Distributions</TabsTrigger>
<TabsTrigger value="reallocations">Reallocations</TabsTrigger>
</TabsList>
<TabsContent value="accounts">
<AccountDistributionsTab data={data} />
</TabsContent>
<TabsContent value="leads">
<LeadDistributionsTab data={data} />
</TabsContent>
<TabsContent value="reallocations">
<ReallocationsTab data={data} />
</TabsContent>
</Tabs>
</div>
);
}
```
#### Reallocations Tab (Tailwind)
```tsx
// src/components/operations/reallocations-tab.tsx
'use client';
import { useState, useMemo } from 'react';
import { useReallocation } from '@/hooks/use-reallocation';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { WorkloadData } from '@/types';
interface ReallocationsTabProps {
data: WorkloadData | undefined;
}
export function ReallocationsTab({ data }: ReallocationsTabProps) {
const [currentOwner, setCurrentOwner] = useState<string>('');
const [accountOrLead, setAccountOrLead] = useState<'account' | 'lead'>('account');
const [customerType, setCustomerType] = useState<string>('');
const [selectedNewOwners, setSelectedNewOwners] = useState<string[]>([]);
const reallocation = useReallocation();
const ownerEmails = useMemo(() => {
return Object.keys(data?.accountCountByOwnerEmail ?? {}).sort();
}, [data]);
const customerTypeOptions = useMemo(() => {
if (accountOrLead === 'account') {
return [
{ label: 'Pension Live', value: 'pensionLive' },
{ label: 'Other Accounts', value: 'otherAccounts' },
];
}
return [
{ label: 'Contribution First', value: 'contributionFirst' },
{ label: 'Other Leads', value: 'otherLeads' },
];
}, [accountOrLead]);
const availableNewOwners = useMemo(() => {
return ownerEmails.filter((email) => email !== currentOwner);
}, [ownerEmails, currentOwner]);
const isFormValid = currentOwner && customerType && selectedNewOwners.length > 0;
const handleSubmit = async () => {
if (!isFormValid) return;
try {
await reallocation.mutateAsync({
currentBeekeeper: currentOwner,
targetBeekeeperArray: selectedNewOwners,
accountOrLead,
customerType,
});
// Reset form on success
setCurrentOwner('');
setCustomerType('');
setSelectedNewOwners([]);
} catch (error) {
// Error handled by mutation
}
};
const toggleOwnerSelection = (email: string) => {
setSelectedNewOwners((prev) =>
prev.includes(email)
? prev.filter((e) => e !== email)
: [...prev, email]
);
};
return (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-6">
<h3 className="mb-6 text-lg font-semibold text-gray-900">Transfer Ownership</h3>
<div className="grid gap-6 md:grid-cols-2">
{/* Current Owner */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Current Owner
</label>
<Select value={currentOwner} onValueChange={setCurrentOwner}>
<SelectTrigger>
<SelectValue placeholder="Select current owner" />
</SelectTrigger>
<SelectContent>
{ownerEmails.map((email) => (
<SelectItem key={email} value={email}>
{email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Account or Lead */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Record Type
</label>
<div className="flex gap-4">
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="accountOrLead"
value="account"
checked={accountOrLead === 'account'}
onChange={() => {
setAccountOrLead('account');
setCustomerType('');
}}
className="h-4 w-4 text-primary-500 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Account</span>
</label>
<label className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="accountOrLead"
value="lead"
checked={accountOrLead === 'lead'}
onChange={() => {
setAccountOrLead('lead');
setCustomerType('');
}}
className="h-4 w-4 text-primary-500 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">Lead</span>
</label>
</div>
</div>
{/* Customer Type */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Customer Type
</label>
<Select value={customerType} onValueChange={setCustomerType}>
<SelectTrigger>
<SelectValue placeholder="Select customer type" />
</SelectTrigger>
<SelectContent>
{customerTypeOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* New Owners Multi-Select */}
<div className="space-y-2 md:col-span-2">
<label className="block text-sm font-medium text-gray-700">
New Owner(s)
</label>
<div className="max-h-40 overflow-y-auto rounded-md border border-gray-300 bg-white p-2">
{availableNewOwners.map((email) => (
<label
key={email}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-gray-50"
>
<input
type="checkbox"
checked={selectedNewOwners.includes(email)}
onChange={() => toggleOwnerSelection(email)}
className="h-4 w-4 rounded text-primary-500 focus:ring-primary-500"
/>
<span className="text-sm text-gray-700">{email}</span>
</label>
))}
</div>
{selectedNewOwners.length > 0 && (
<p className="text-xs text-gray-500">
{selectedNewOwners.length} owner(s) selected
</p>
)}
</div>
</div>
{/* Submit */}
<div className="mt-6">
<Button
onClick={handleSubmit}
disabled={!isFormValid || reallocation.isPending}
className="w-full md:w-auto"
>
{reallocation.isPending ? 'Submitting...' : 'Submit Reallocation'}
</Button>
{reallocation.isError && (
<Alert variant="destructive" className="mt-4">
<AlertDescription>
{reallocation.error?.message || 'Failed to submit reallocation request'}
</AlertDescription>
</Alert>
)}
{reallocation.isSuccess && (
<Alert className="mt-4 border-green-200 bg-green-50 text-green-700">
<AlertDescription>
Reallocation completed successfully
</AlertDescription>
</Alert>
)}
</div>
</div>
);
}
```
---
## 10. Deployment Strategy
### Heroku Configuration
```yaml
# Procfile
web: npm start
```
```json
// package.json scripts
{
"scripts": {
"build": "next build",
"start": "next start -p $PORT"
}
}
```
### Environment Variables
```bash
# .env.example
# Database
DATABASE_URL=postgres://user:pass@host:5432/dbname
# App
NODE_ENV=production
NEXT_PUBLIC_APP_URL=https://hive-switch.herokuapp.com
# Security
BYPASS_AUTH=false # Only for development
# Cloudflare Access (optional verification)
CF_ACCESS_TEAM_DOMAIN=pensionbee
CF_ACCESS_AUD=<audience-tag>
```
### Heroku Setup Commands
```bash
# Create Heroku app
heroku create hive-switch --team=pensionbee
# Add buildpack for Node.js
heroku buildpacks:set heroku/nodejs -a hive-switch
# Set environment variables
heroku config:set NODE_ENV=production -a hive-switch
heroku config:set DATABASE_URL=<same-as-web-app-alpha> -a hive-switch
# Deploy
git push heroku main
# View logs
heroku logs --tail -a hive-switch
```
### VPN Access Configuration
#### Option 1: Cloudflare Access (Recommended)
1. **Configure Application in Cloudflare Zero Trust:**
- Self-hosted application
- Domain: `hive-switch.herokuapp.com`
- Authentication: Cloudflare WARP required
2. **No changes needed in Heroku** - Cloudflare acts as reverse proxy
#### Option 2: Heroku Private Spaces + VPN
```
# More expensive but provides network-level isolation
# Requires Heroku Enterprise
```
#### Option 3: IP Allowlisting (Basic)
```typescript
// src/middleware.ts
const ALLOWED_IPS = process.env.ALLOWED_IPS?.split(',') || [];
export function middleware(request: NextRequest) {
const clientIp = request.ip || request.headers.get('x-forwarded-for');
if (ALLOWED_IPS.length > 0 && !ALLOWED_IPS.includes(clientIp)) {
return new NextResponse('Forbidden', { status: 403 });
}
return NextResponse.next();
}
```
> **Note**: IP allowlisting is less secure than Cloudflare Access and harder to maintain with dynamic VPN IPs. Recommend pursuing Cloudflare Access integration.
---
## 11. Testing Strategy
### Unit Tests
```typescript
// tests/unit/services/reallocation.service.test.ts
import { reallocationService } from '@/lib/services/reallocation.service';
describe('ReallocationService', () => {
describe('roundRobinReallocation', () => {
it('distributes customers evenly across beekeepers', async () => {
// Mock setup
const mockCustomers = Array(10).fill(null).map((_, i) => ({ id: i }));
jest.spyOn(accountRepository, 'findByOwner').mockResolvedValue(mockCustomers);
jest.spyOn(accountRepository, 'updateOwner').mockResolvedValue(undefined);
const result = await reallocationService.executeReallocation({
currentBeekeeper: 'old@example.com',
targetBeekeeperArray: ['new1@example.com', 'new2@example.com'],
accountOrLead: 'account',
customerType: 'pensionLive',
strategy: 'round-robin',
});
expect(result.affectedCount).toBe(10);
expect(result.distribution['new1@example.com']).toBe(5);
expect(result.distribution['new2@example.com']).toBe(5);
});
});
});
```
### Integration Tests
```typescript
// tests/integration/api/workload.test.ts
import { GET } from '@/app/api/workload/route';
describe('GET /api/workload', () => {
it('returns workload data', async () => {
const response = await GET();
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveProperty('beekeepers');
expect(data).toHaveProperty('accountCountByOwnerEmail');
expect(data).toHaveProperty('leadCountByOwnerEmail');
});
});
```
### E2E Tests
```typescript
// tests/e2e/operations.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Operations Page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/operations');
});
test('displays all three tabs', async ({ page }) => {
await expect(page.getByRole('tab', { name: 'Account Distributions' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Lead Distributions' })).toBeVisible();
await expect(page.getByRole('tab', { name: 'Reallocations' })).toBeVisible();
});
test('can switch between tabs', async ({ page }) => {
await page.getByRole('tab', { name: 'Leads' }).click();
await expect(page.getByText('Contribution First')).toBeVisible();
});
test('reallocation form validates required fields', async ({ page }) => {
await page.getByRole('tab', { name: 'Reallocations' }).click();
const submitButton = page.getByRole('button', { name: 'Submit Reallocation' });
await expect(submitButton).toBeDisabled();
});
});
```
---
## 12. Migration Checklist
### Phase 1: Repository Setup (Day 1-2)
- [ ] Create new Git repository
- [ ] Initialize Next.js 14 project with App Router
- [ ] Configure TypeScript, ESLint, Prettier
- [ ] Set up Tailwind CSS
- [ ] Create initial folder structure
- [ ] Set up environment variables template
### Phase 2: Database Layer (Day 3-4)
- [ ] Install and configure Sequelize v5.22.5
- [ ] Port Account model from `PostgresAccount.js`
- [ ] Port Lead model from `PostgresLead.js`
- [ ] Create repository layer
- [ ] Write unit tests for repositories
- [ ] Test connection to shared database (staging)
### Phase 3: API Routes (Day 5-6)
- [ ] Implement GET `/api/workload` endpoint
- [ ] Implement POST `/api/reallocate` endpoint
- [ ] Implement GET `/api/health` endpoint
- [ ] Add Zod validation schemas
- [ ] Write integration tests
### Phase 4: UI Components (Day 7-9)
- [ ] Create base UI components (Button, Select, Table, Tabs)
- [ ] Port AccountDistributionsTab with Tailwind
- [ ] Port LeadDistributionsTab with Tailwind
- [ ] Port ReallocationsTab with Tailwind
- [ ] Create dashboard layout
- [ ] Add loading and error states
### Phase 5: Authentication & Security (Day 10-11)
- [ ] Implement Next.js middleware
- [ ] Integrate Cloudflare Access headers
- [ ] Add audit logging
- [ ] Test VPN access restriction
### Phase 6: Testing & QA (Day 12-13)
- [ ] Complete unit test coverage
- [ ] Complete integration tests
- [ ] E2E tests with Playwright
- [ ] Manual testing on staging
- [ ] Security review
### Phase 7: Deployment (Day 14)
- [ ] Set up Heroku application
- [ ] Configure environment variables
- [ ] Deploy to staging
- [ ] Configure Cloudflare Access
- [ ] Deploy to production
- [ ] Smoke tests
### Phase 8: Documentation & Handover (Day 15)
- [ ] Update README with setup instructions
- [ ] Document API endpoints
- [ ] Create runbook for common issues
- [ ] Team walkthrough
---
## 13. Alternative Architecture: Microservice (Option C)
### Overview
A shared microservice that both `web-app-alpha` and `hive-switch` can call.
```
┌──────────────────┐ ┌──────────────────┐
│ web-app-alpha │ │ hive-switch │
│ (Legacy App) │ │ (New App) │
└────────┬─────────┘ └────────┬─────────┘
│ │
│ HTTP/gRPC │ HTTP/gRPC
│ │
└────────┬───────────────┘
▼
┌──────────────────────────────┐
│ hive-management-service │
│ (Shared Microservice) │
│ │
│ - GET /workload │
│ - POST /reallocate │
│ - Owns database access │
│ - Single source of truth │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ Postgres Database │
└──────────────────────────────┘
```
### Technology Options
| Option | Stack | Pros | Cons |
|--------|-------|------|------|
| Node.js Express | Express + Sequelize | Familiar tech, reuse existing code | Another Node service to maintain |
| Python FastAPI | FastAPI + SQLAlchemy | Clean API design, good docs | Different tech stack |
| Go | Chi/Gin + sqlc | Performance, type safety | Learning curve |
### When to Consider This Approach
1. **Multiple consumers**: If more applications will need hive management data
2. **Data ownership**: Want a single service to own account/lead ownership logic
3. **Scalability**: Need to scale this functionality independently
4. **Team structure**: Dedicated team for hive management domain
### Why Not Recommended Initially
1. **Operational overhead**: Additional service to deploy, monitor, maintain
2. **Latency**: Extra network hop for every request
3. **Complexity**: Distributed system concerns (service discovery, circuit breakers)
4. **Single consumer**: Currently only internal operations team needs this
### Recommendation
Start with **Option A** (direct database connection). Consider migrating to Option C if:
- Multiple applications need this functionality
- Team grows and can dedicate resources to microservice
- Scalability becomes a concern
---
## 14. Risk Assessment & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| **Database connection conflicts** | Medium | High | Use lower connection pool limits; monitor connections |
| **Sequelize version mismatch** | Low | High | Lock exact versions; comprehensive testing |
| **Data integrity issues** | Medium | Critical | Use transactions; add audit logging; implement soft deletes |
| **VPN bypass** | Low | High | Cloudflare Access provides cryptographic verification |
| **Performance degradation** | Low | Medium | Monitor query performance; add indexes if needed |
| **Accidental bulk reallocation** | Medium | High | Add confirmation dialog; implement undo functionality |
### Data Integrity Safeguards
```typescript
// Pre-reallocation validation
async function validateReallocation(params: ReallocationParams): Promise<ValidationResult> {
const errors: string[] = [];
// Check current owner exists
const currentOwnerExists = await accountRepository.ownerExists(params.currentBeekeeper);
if (!currentOwnerExists) {
errors.push('Current owner not found');
}
// Check target owners exist
for (const target of params.targetBeekeeperArray) {
const exists = await accountRepository.ownerExists(target);
if (!exists) {
errors.push(`Target owner ${target} not found`);
}
}
// Check affected count isn't unexpectedly large
const affectedCount = await accountRepository.countByOwner(
params.currentBeekeeper,
params.customerType
);
if (affectedCount > 1000) {
errors.push(`Warning: This will affect ${affectedCount} records. Please confirm.`);
}
return { valid: errors.length === 0, errors };
}
```
---
## 15. Future Considerations
### Potential Enhancements
1. **Undo/Rollback Functionality**
- Store reallocation history
- Allow reverting recent changes
2. **Scheduled Reallocations**
- Set reallocations to execute at specific times
- Useful for off-hours bulk operations
3. **Preview Mode**
- Show what would happen before executing
- Dry-run capability
4. **Bulk Import**
- CSV upload for complex reallocations
- Mapping validation
5. **Notifications**
- Slack/email notifications on completion
- Alert on failures
6. **Analytics Dashboard**
- Historical reallocation trends
- Workload balance over time
### Extensibility Architecture
```
src/
├── features/
│ ├── workload/ # Current feature
│ ├── reallocations/ # Current feature
│ ├── bulk-operations/ # Future: CSV imports
│ ├── scheduling/ # Future: Scheduled tasks
│ └── analytics/ # Future: Dashboards
```
---
## Summary
This plan provides a comprehensive roadmap for migrating the hive switch functionality to a standalone Next.js application. Key decisions:
1. **Architecture**: Direct database connection (Option A) for simplicity
2. **Tech Stack**: Next.js 14, Sequelize 5.22.5, Tailwind CSS
3. **Security**: Cloudflare WARP Zero Trust for VPN access
4. **Database**: Shared Postgres with conservative connection pooling
5. **Timeline**: ~15 working days for full implementation
---
*Document generated: December 2025*
*Version: 1.0*