feat(F-008,F-009,F-010): complete Phase 1 foundation with middleware, navigation, and migrations

Implements authentication middleware with rate limiting, comprehensive permission
system, full portal navigation with sidebar and header, company selection flow,
and data migration tooling from SQL Server to PostgreSQL.

Key additions:
- Middleware: auth guards, rate limiting, session management
- Permissions: role-based access control with hierarchical permission rules
- Navigation: responsive sidebar with expandable sections, header with notifications
- Company selector: multi-company user support with session-based active company
- Data migration: comprehensive script for migrating auth and quest domain tables
- Schema: added auth_user_type_permission_group junction table

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Lorentz 2026-02-16 11:07:44 +00:00
parent bbcee034cb
commit a67558de23
23 changed files with 2328 additions and 49 deletions

585
scripts/migrate-data.ts Normal file
View file

@ -0,0 +1,585 @@
/**
* Data Migration Script
*
* Migrates data from legacy SQL Server database to PostgreSQL
*
* Usage: npx ts-node scripts/migrate-data.ts
*/
import sql from 'mssql';
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
// SQL Server configuration (legacy database)
const legacyConfig: sql.config = {
server: process.env.MSSQL_HOST || '',
database: process.env.PORTAL_DB_NAME || 'VorteqPortal',
user: process.env.MSSQL_USER || '',
password: process.env.MSSQL_PASSWORD || '',
options: {
encrypt: false,
trustServerCertificate: true,
enableArithAbort: true,
},
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30000,
},
};
/**
* Migration Statistics
*/
const stats = {
auth_domain: { expected: 0, migrated: 0 },
auth_user_type: { expected: 0, migrated: 0 },
auth_user: { expected: 0, migrated: 0 },
auth_permission_group: { expected: 0, migrated: 0 },
auth_permission_rule: { expected: 0, migrated: 0 },
auth_permission_rule_category: { expected: 0, migrated: 0 },
quest_user: { expected: 0, migrated: 0 },
quest_company: { expected: 0, migrated: 0 },
quest_user_company: { expected: 0, migrated: 0 },
quest_plant: { expected: 0, migrated: 0 },
quest_inventory_type: { expected: 0, migrated: 0 },
quest_email_event: { expected: 0, migrated: 0 },
ship_request: { expected: 0, migrated: 0 },
ship_request_detail: { expected: 0, migrated: 0 },
alloc_request: { expected: 0, migrated: 0 },
alloc_request_detail: { expected: 0, migrated: 0 },
};
/**
* Connect to legacy SQL Server database
*/
async function connectToLegacy(): Promise<sql.ConnectionPool> {
console.log('Connecting to legacy SQL Server database...');
const pool = await sql.connect(legacyConfig);
console.log('✓ Connected to legacy database');
return pool;
}
/**
* Migrate auth_domain table
*/
async function migrateAuthDomains(pool: sql.ConnectionPool) {
console.log('\nMigrating auth_domain...');
const result = await pool.request().query(`
SELECT ID, Name, Description, IsActive, CreatedAt, UpdatedAt
FROM auth_domain
`);
stats.auth_domain.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.auth_domain.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
name: row.Name,
description: row.Description,
is_active: row.IsActive,
created_at: row.CreatedAt,
updated_at: row.UpdatedAt,
},
});
stats.auth_domain.migrated++;
}
console.log(
`✓ Migrated ${stats.auth_domain.migrated}/${stats.auth_domain.expected} auth_domain records`
);
}
/**
* Migrate auth_user_type table
*/
async function migrateAuthUserTypes(pool: sql.ConnectionPool) {
console.log('\nMigrating auth_user_type...');
const result = await pool.request().query(`
SELECT ID, Name, Description, IsAdmin, IsCustomer, IsInternal, CreatedAt, UpdatedAt
FROM auth_user_type
`);
stats.auth_user_type.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.auth_user_type.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
name: row.Name,
description: row.Description,
is_admin: row.IsAdmin,
is_customer: row.IsCustomer,
is_internal: row.IsInternal,
created_at: row.CreatedAt,
updated_at: row.UpdatedAt,
},
});
stats.auth_user_type.migrated++;
}
console.log(
`✓ Migrated ${stats.auth_user_type.migrated}/${stats.auth_user_type.expected} auth_user_type records`
);
}
/**
* Migrate auth_user table
*/
async function migrateAuthUsers(pool: sql.ConnectionPool) {
console.log('\nMigrating auth_user...');
const result = await pool.request().query(`
SELECT
ID, Email, EmailVerified, Name, Image, CreatedAt, UpdatedAt,
PasswordHash, Deactivated, DeactivatedAt, DeactivationReason,
LoginCount, FailedLoginCount, LastLoginAt, LastLoginIP, LastFailedLoginAt,
TwoFactorEnabled, TwoFactorSecret, TwoFactorBackupCodes,
SSOProvider, SSOID, AuthUserTypeID, AuthDomainID
FROM auth_user
`);
stats.auth_user.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.auth_user.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
email: row.Email,
email_verified: row.EmailVerified,
name: row.Name,
image: row.Image,
created_at: row.CreatedAt,
updated_at: row.UpdatedAt,
password_hash: row.PasswordHash,
deactivated: row.Deactivated,
deactivated_at: row.DeactivatedAt,
deactivation_reason: row.DeactivationReason,
login_count: row.LoginCount,
failed_login_count: row.FailedLoginCount,
last_login_at: row.LastLoginAt,
last_login_ip: row.LastLoginIP,
last_failed_login_at: row.LastFailedLoginAt,
two_factor_enabled: row.TwoFactorEnabled,
two_factor_secret: row.TwoFactorSecret,
two_factor_backup_codes: row.TwoFactorBackupCodes,
sso_provider: row.SSOProvider,
sso_id: row.SSOID,
auth_user_type_id: row.AuthUserTypeID,
auth_domain_id: row.AuthDomainID,
},
});
stats.auth_user.migrated++;
}
console.log(
`✓ Migrated ${stats.auth_user.migrated}/${stats.auth_user.expected} auth_user records`
);
}
/**
* Migrate auth_permission_rule_category table
*/
async function migratePermissionRuleCategories(pool: sql.ConnectionPool) {
console.log('\nMigrating auth_permission_rule_category...');
const result = await pool.request().query(`
SELECT ID, Name, Description, CreatedAt, UpdatedAt
FROM auth_permission_rule_category
`);
stats.auth_permission_rule_category.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.auth_permission_rule_category.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
name: row.Name,
description: row.Description,
created_at: row.CreatedAt,
updated_at: row.UpdatedAt,
},
});
stats.auth_permission_rule_category.migrated++;
}
console.log(
`✓ Migrated ${stats.auth_permission_rule_category.migrated}/${stats.auth_permission_rule_category.expected} auth_permission_rule_category records`
);
}
/**
* Migrate auth_permission_rule table
*/
async function migratePermissionRules(pool: sql.ConnectionPool) {
console.log('\nMigrating auth_permission_rule...');
const result = await pool.request().query(`
SELECT ID, Name, Description, AuthPermissionRuleCategoryID, CreatedAt, UpdatedAt
FROM auth_permission_rule
`);
stats.auth_permission_rule.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.auth_permission_rule.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
name: row.Name,
description: row.Description,
auth_permission_rule_category_id: row.AuthPermissionRuleCategoryID,
created_at: row.CreatedAt,
updated_at: row.UpdatedAt,
},
});
stats.auth_permission_rule.migrated++;
}
console.log(
`✓ Migrated ${stats.auth_permission_rule.migrated}/${stats.auth_permission_rule.expected} auth_permission_rule records`
);
}
/**
* Migrate auth_permission_group table
*/
async function migratePermissionGroups(pool: sql.ConnectionPool) {
console.log('\nMigrating auth_permission_group...');
const result = await pool.request().query(`
SELECT ID, Name, Description, IsActive, CreatedAt, UpdatedAt
FROM auth_permission_group
`);
stats.auth_permission_group.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.auth_permission_group.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
name: row.Name,
description: row.Description,
is_active: row.IsActive,
created_at: row.CreatedAt,
updated_at: row.UpdatedAt,
},
});
stats.auth_permission_group.migrated++;
}
console.log(
`✓ Migrated ${stats.auth_permission_group.migrated}/${stats.auth_permission_group.expected} auth_permission_group records`
);
}
/**
* Migrate auth_permission_group_rule junction table
*/
async function migratePermissionGroupRules(pool: sql.ConnectionPool) {
console.log('\nMigrating auth_permission_group_rule...');
const result = await pool.request().query(`
SELECT ID, AuthPermissionGroupID, AuthPermissionRuleID, CreatedAt
FROM auth_permission_group_rule
`);
for (const row of result.recordset) {
await prisma.auth_permission_group_rule.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
auth_permission_group_id: row.AuthPermissionGroupID,
auth_permission_rule_id: row.AuthPermissionRuleID,
created_at: row.CreatedAt,
},
});
}
console.log(
`✓ Migrated ${result.recordset.length} auth_permission_group_rule records`
);
}
/**
* Migrate quest_company table
*/
async function migrateQuestCompanies(pool: sql.ConnectionPool) {
console.log('\nMigrating quest_company...');
const result = await pool.request().query(`
SELECT
ID, EpicorCustID, DisplayName, CanAccessInvoices,
InvoicingEmail, OrderAckEmail, ReceivesSOEmails, IsActive,
CreatedAt, UpdatedAt
FROM quest_company
`);
stats.quest_company.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.quest_company.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
epicor_cust_id: row.EpicorCustID,
display_name: row.DisplayName,
can_access_invoices: row.CanAccessInvoices,
invoicing_email: row.InvoicingEmail,
order_ack_email: row.OrderAckEmail,
receives_so_emails: row.ReceivesSOEmails,
is_active: row.IsActive,
created_at: row.CreatedAt,
updated_at: row.UpdatedAt,
},
});
stats.quest_company.migrated++;
}
console.log(
`✓ Migrated ${stats.quest_company.migrated}/${stats.quest_company.expected} quest_company records`
);
}
/**
* Migrate quest_user table
*/
async function migrateQuestUsers(pool: sql.ConnectionPool) {
console.log('\nMigrating quest_user...');
const result = await pool.request().query(`
SELECT
ID, AuthUserID, IsSubUser, APIToken, APITokenExpiresAt,
CreatedAt, UpdatedAt
FROM quest_user
`);
stats.quest_user.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.quest_user.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
auth_user_id: row.AuthUserID,
is_sub_user: row.IsSubUser,
api_token: row.APIToken,
api_token_expires_at: row.APITokenExpiresAt,
created_at: row.CreatedAt,
updated_at: row.UpdatedAt,
},
});
stats.quest_user.migrated++;
}
console.log(
`✓ Migrated ${stats.quest_user.migrated}/${stats.quest_user.expected} quest_user records`
);
}
/**
* Migrate quest_user_company junction table
*/
async function migrateQuestUserCompanies(pool: sql.ConnectionPool) {
console.log('\nMigrating quest_user_company...');
const result = await pool.request().query(`
SELECT ID, QuestUserID, QuestCompanyID, IsActive, CreatedAt
FROM quest_user_company
`);
stats.quest_user_company.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.quest_user_company.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
quest_user_id: row.QuestUserID,
quest_company_id: row.QuestCompanyID,
is_active_company: row.IsActive,
created_at: row.CreatedAt,
},
});
stats.quest_user_company.migrated++;
}
console.log(
`✓ Migrated ${stats.quest_user_company.migrated}/${stats.quest_user_company.expected} quest_user_company records`
);
}
/**
* Migrate quest_plant table
*/
async function migrateQuestPlants(pool: sql.ConnectionPool) {
console.log('\nMigrating quest_plant...');
const result = await pool.request().query(`
SELECT ID, Name, Code, IsActive, CreatedAt, UpdatedAt
FROM quest_plant
`);
stats.quest_plant.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.quest_plant.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
name: row.Name,
code: row.Code,
is_active: row.IsActive,
created_at: row.CreatedAt,
updated_at: row.UpdatedAt,
},
});
stats.quest_plant.migrated++;
}
console.log(
`✓ Migrated ${stats.quest_plant.migrated}/${stats.quest_plant.expected} quest_plant records`
);
}
/**
* Migrate quest_inventory_type table
*/
async function migrateQuestInventoryTypes(pool: sql.ConnectionPool) {
console.log('\nMigrating quest_inventory_type...');
const result = await pool.request().query(`
SELECT ID, Name, Code, Description, IsActive, SortOrder, CreatedAt, UpdatedAt
FROM quest_inventory_type
`);
stats.quest_inventory_type.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.quest_inventory_type.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
name: row.Name,
code: row.Code,
description: row.Description,
is_active: row.IsActive,
sort_order: row.SortOrder || 0,
created_at: row.CreatedAt,
updated_at: row.UpdatedAt,
},
});
stats.quest_inventory_type.migrated++;
}
console.log(
`✓ Migrated ${stats.quest_inventory_type.migrated}/${stats.quest_inventory_type.expected} quest_inventory_type records`
);
}
/**
* Migrate quest_email_event table
*/
async function migrateQuestEmailEvents(pool: sql.ConnectionPool) {
console.log('\nMigrating quest_email_event...');
const result = await pool.request().query(`
SELECT ID, Name, Description, CreatedAt, UpdatedAt
FROM quest_email_event
`);
stats.quest_email_event.expected = result.recordset.length;
for (const row of result.recordset) {
await prisma.quest_email_event.upsert({
where: { id: row.ID },
update: {},
create: {
id: row.ID,
name: row.Name,
description: row.Description,
created_at: row.CreatedAt,
updated_at: row.UpdatedAt,
},
});
stats.quest_email_event.migrated++;
}
console.log(
`✓ Migrated ${stats.quest_email_event.migrated}/${stats.quest_email_event.expected} quest_email_event records`
);
}
/**
* Main migration function
*/
async function main() {
console.log('=== Vorteq Quest Portal Data Migration ===\n');
console.log('Source: SQL Server (VorteqPortal)');
console.log('Target: PostgreSQL (via Prisma)\n');
try {
const pool = await connectToLegacy();
// Migrate auth domain
await migrateAuthDomains(pool);
await migrateAuthUserTypes(pool);
await migrateAuthUsers(pool);
await migratePermissionRuleCategories(pool);
await migratePermissionRules(pool);
await migratePermissionGroups(pool);
await migratePermissionGroupRules(pool);
// Migrate quest domain
await migrateQuestCompanies(pool);
await migrateQuestUsers(pool);
await migrateQuestUserCompanies(pool);
await migrateQuestPlants(pool);
await migrateQuestInventoryTypes(pool);
await migrateQuestEmailEvents(pool);
// Close connections
await pool.close();
// Print summary
console.log('\n=== Migration Summary ===\n');
for (const [table, { expected, migrated }] of Object.entries(stats)) {
if (expected > 0) {
const status = expected === migrated ? '✓' : '⚠';
console.log(`${status} ${table}: ${migrated}/${expected}`);
}
}
console.log('\n✓ Migration completed successfully!');
} catch (error) {
console.error('\n✗ Migration failed:', error);
process.exit(1);
} finally {
await prisma.$disconnect();
}
}
// Run migration
main();