PSA/server/migrations/20251102090000_create_import_framework_tables.cjs
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

275 lines
10 KiB
JavaScript

/**
* Create foundational tables for the asset import framework.
* - import_sources: registry of available import adapters per tenant
* - import_jobs: high-level tracking for each import execution/preview
* - import_job_items: row-level tracking for validation + execution
* - external_entity_mappings: linkage between external systems and PSA assets
*/
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = async function up(knex) {
// ---------------------------------------------------------------------------
// Enum types for job + job item statuses
// ---------------------------------------------------------------------------
await knex.raw(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'import_job_status') THEN
CREATE TYPE import_job_status AS ENUM (
'preview',
'validating',
'processing',
'completed',
'failed',
'cancelled'
);
END IF;
END
$$;
`);
await knex.raw(`
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'import_job_item_status') THEN
CREATE TYPE import_job_item_status AS ENUM (
'staged',
'created',
'updated',
'duplicate',
'error'
);
END IF;
END
$$;
`);
// ---------------------------------------------------------------------------
// import_sources
// ---------------------------------------------------------------------------
const hasImportSources = await knex.schema.hasTable('import_sources');
if (!hasImportSources) {
await knex.schema.createTable('import_sources', (table) => {
table.uuid('tenant').notNullable();
table.uuid('import_source_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.text('source_type').notNullable();
table.text('name').notNullable();
table.text('description');
table.jsonb('field_mapping');
table.specificType('duplicate_detection_fields', 'text[]');
table.boolean('is_active').notNullable().defaultTo(true);
table.jsonb('metadata');
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'import_source_id']);
table
.foreign('tenant')
.references('tenants.tenant');
table.unique(['tenant', 'source_type', 'name'], 'uq_import_sources_type_name');
});
}
// ---------------------------------------------------------------------------
// import_jobs
// ---------------------------------------------------------------------------
const hasImportJobs = await knex.schema.hasTable('import_jobs');
if (!hasImportJobs) {
await knex.schema.createTable('import_jobs', (table) => {
table.uuid('tenant').notNullable();
table.uuid('import_job_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('import_source_id').notNullable();
table.uuid('job_id');
table.specificType('status', 'import_job_status').notNullable().defaultTo('preview');
table.text('file_name');
table.integer('total_rows').notNullable().defaultTo(0);
table.integer('processed_rows').notNullable().defaultTo(0);
table.integer('created_rows').notNullable().defaultTo(0);
table.integer('updated_rows').notNullable().defaultTo(0);
table.integer('duplicate_rows').notNullable().defaultTo(0);
table.integer('error_rows').notNullable().defaultTo(0);
table.jsonb('preview_data');
table.jsonb('error_summary');
table.jsonb('context');
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('completed_at');
table.uuid('created_by').notNullable();
table.primary(['tenant', 'import_job_id']);
table
.foreign('tenant')
.references('tenants.tenant');
table
.foreign(['tenant', 'import_source_id'])
.references(['tenant', 'import_source_id'])
.inTable('import_sources');
table
.foreign(['tenant', 'job_id'])
.references(['tenant', 'job_id'])
.inTable('jobs');
table
.foreign(['tenant', 'created_by'])
.references(['tenant', 'user_id'])
.inTable('users');
});
}
// ---------------------------------------------------------------------------
// import_job_items
// ---------------------------------------------------------------------------
const hasImportJobItems = await knex.schema.hasTable('import_job_items');
if (!hasImportJobItems) {
await knex.schema.createTable('import_job_items', (table) => {
table.uuid('tenant').notNullable();
table.uuid('import_job_item_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('import_job_id').notNullable();
table.text('external_id');
table.uuid('asset_id');
table.jsonb('source_data').notNullable();
table.jsonb('mapped_data');
table.jsonb('duplicate_details');
table.specificType('status', 'import_job_item_status').notNullable().defaultTo('staged');
table.text('error_message');
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'import_job_item_id']);
table
.foreign(['tenant', 'import_job_id'])
.references(['tenant', 'import_job_id'])
.inTable('import_jobs')
.onDelete('CASCADE');
table
.foreign(['tenant', 'asset_id'])
.references(['tenant', 'asset_id'])
.inTable('assets');
});
}
// ---------------------------------------------------------------------------
// external_entity_mappings
// ---------------------------------------------------------------------------
const hasExternalMappings = await knex.schema.hasTable('external_entity_mappings');
if (!hasExternalMappings) {
await knex.schema.createTable('external_entity_mappings', (table) => {
table.uuid('tenant').notNullable();
table.uuid('external_entity_mapping_id').notNullable().defaultTo(knex.raw('gen_random_uuid()'));
table.uuid('asset_id').notNullable();
table.uuid('import_source_id').notNullable();
table.text('external_id').notNullable();
table.text('external_hash');
table.jsonb('metadata');
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('last_synced_at').notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'external_entity_mapping_id']);
table
.foreign(['tenant', 'asset_id'])
.references(['tenant', 'asset_id'])
.inTable('assets');
table
.foreign(['tenant', 'import_source_id'])
.references(['tenant', 'import_source_id'])
.inTable('import_sources');
table.unique(['tenant', 'import_source_id', 'external_id'], 'uq_external_entity_unique_source');
});
}
// ---------------------------------------------------------------------------
// Indexes
// ---------------------------------------------------------------------------
await knex.raw('CREATE INDEX IF NOT EXISTS idx_import_sources_active ON import_sources (tenant, is_active)');
await knex.raw('CREATE INDEX IF NOT EXISTS idx_import_jobs_tenant_status ON import_jobs (tenant, status)');
await knex.raw('CREATE INDEX IF NOT EXISTS idx_import_jobs_created_at ON import_jobs (tenant, created_at DESC)');
await knex.raw('CREATE INDEX IF NOT EXISTS idx_import_job_items_job_status ON import_job_items (tenant, import_job_id, status)');
await knex.raw('CREATE INDEX IF NOT EXISTS idx_external_mappings_asset ON external_entity_mappings (tenant, asset_id)');
await knex.raw('CREATE INDEX IF NOT EXISTS idx_external_mappings_source ON external_entity_mappings (tenant, import_source_id, external_id)');
// ---------------------------------------------------------------------------
// Row Level Security (per-tenant isolation)
// ---------------------------------------------------------------------------
const tablesWithRls = [
'import_sources',
'import_jobs',
'import_job_items',
'external_entity_mappings'
];
for (const table of tablesWithRls) {
await knex.raw(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;`);
await knex.raw(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'public'
AND tablename = '${table}'
AND policyname = '${table}_tenant_isolation_policy'
) THEN
EXECUTE format(
'CREATE POLICY %I ON %I USING (tenant = current_setting(''app.current_tenant'')::uuid)',
'${table}_tenant_isolation_policy',
'${table}'
);
END IF;
END
$$;
`);
await knex.raw(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'public'
AND tablename = '${table}'
AND policyname = '${table}_tenant_insert_policy'
) THEN
EXECUTE format(
'CREATE POLICY %I ON %I FOR INSERT WITH CHECK (tenant = current_setting(''app.current_tenant'')::uuid)',
'${table}_tenant_insert_policy',
'${table}'
);
END IF;
END
$$;
`);
}
};
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = async function down(knex) {
await knex.schema.dropTableIfExists('external_entity_mappings');
await knex.schema.dropTableIfExists('import_job_items');
await knex.schema.dropTableIfExists('import_jobs');
await knex.schema.dropTableIfExists('import_sources');
await knex.raw(`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'import_job_item_status') THEN
DROP TYPE import_job_item_status;
END IF;
END
$$;
`);
await knex.raw(`
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'import_job_status') THEN
DROP TYPE import_job_status;
END IF;
END
$$;
`);
};