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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
275 lines
10 KiB
JavaScript
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
|
|
$$;
|
|
`);
|
|
};
|