PSA/server/migrations/20260320100000_create_quotes_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

170 lines
8.4 KiB
JavaScript

/**
* @param {import('knex').Knex} knex
* @returns {Promise<void>}
*/
exports.up = async function up(knex) {
await knex.schema.createTable('quotes', (table) => {
table.uuid('tenant').notNullable();
table.uuid('quote_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.text('quote_number').nullable();
table.uuid('client_id').nullable();
table.uuid('contact_id').nullable();
table.text('title').notNullable();
table.text('description').nullable();
table.timestamp('quote_date', { useTz: true }).nullable();
table.timestamp('valid_until', { useTz: true }).nullable();
table.text('status').nullable();
table.integer('version').notNullable().defaultTo(1);
table.uuid('parent_quote_id').nullable();
table.text('po_number').nullable();
table.bigInteger('subtotal').notNullable().defaultTo(0);
table.bigInteger('discount_total').notNullable().defaultTo(0);
table.bigInteger('tax').notNullable().defaultTo(0);
table.bigInteger('total_amount').notNullable().defaultTo(0);
table.string('currency_code', 3).notNullable().defaultTo('USD');
table.text('internal_notes').nullable();
table.text('client_notes').nullable();
table.text('terms_and_conditions').nullable();
table.boolean('is_template').notNullable().defaultTo(false);
table.uuid('template_id').nullable();
table.uuid('converted_contract_id').nullable();
table.uuid('converted_invoice_id').nullable();
table.timestamp('sent_at', { useTz: true }).nullable();
table.timestamp('viewed_at', { useTz: true }).nullable();
table.timestamp('accepted_at', { useTz: true }).nullable();
table.uuid('accepted_by').nullable();
table.timestamp('rejected_at', { useTz: true }).nullable();
table.text('rejection_reason').nullable();
table.timestamp('cancelled_at', { useTz: true }).nullable();
table.timestamp('expired_at', { useTz: true }).nullable();
table.timestamp('converted_at', { useTz: true }).nullable();
table.timestamp('archived_at', { useTz: true }).nullable();
table.uuid('opportunity_id').nullable();
table.uuid('created_by').nullable();
table.uuid('updated_by').nullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'quote_id']);
table.foreign('tenant').references('tenants.tenant');
table.foreign(['tenant', 'client_id']).references(['tenant', 'client_id']).inTable('clients').onDelete('SET NULL');
table.foreign(['tenant', 'contact_id']).references(['tenant', 'contact_name_id']).inTable('contacts').onDelete('SET NULL');
table.foreign(['tenant', 'parent_quote_id']).references(['tenant', 'quote_id']).inTable('quotes').onDelete('SET NULL');
table.foreign(['tenant', 'converted_contract_id']).references(['tenant', 'contract_id']).inTable('contracts').onDelete('SET NULL');
table.foreign(['tenant', 'converted_invoice_id']).references(['tenant', 'invoice_id']).inTable('invoices').onDelete('SET NULL');
table.foreign(['tenant', 'accepted_by']).references(['tenant', 'user_id']).inTable('users').onDelete('SET NULL');
table.foreign(['tenant', 'created_by']).references(['tenant', 'user_id']).inTable('users').onDelete('SET NULL');
table.foreign(['tenant', 'updated_by']).references(['tenant', 'user_id']).inTable('users').onDelete('SET NULL');
});
await knex.raw(`
ALTER TABLE quotes
ADD CONSTRAINT quotes_status_check
CHECK (
status IS NULL OR status IN (
'draft',
'sent',
'accepted',
'rejected',
'expired',
'converted',
'cancelled',
'superseded',
'archived'
)
)
`);
await knex.raw(`
CREATE UNIQUE INDEX idx_quotes_tenant_quote_number
ON quotes (tenant, quote_number)
WHERE quote_number IS NOT NULL
`);
await knex.raw('CREATE INDEX idx_quotes_tenant_client ON quotes (tenant, client_id)');
await knex.raw('CREATE INDEX idx_quotes_tenant_status ON quotes (tenant, status)');
await knex.raw('CREATE INDEX idx_quotes_tenant_parent_quote ON quotes (tenant, parent_quote_id)');
await knex.schema.createTable('quote_items', (table) => {
table.uuid('tenant').notNullable();
table.uuid('quote_item_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.uuid('quote_id').notNullable();
table.uuid('service_id').nullable();
table.text('service_item_kind').nullable();
table.text('service_name').nullable();
table.text('service_sku').nullable();
table.text('billing_method').nullable();
table.text('description').notNullable();
table.bigInteger('quantity').notNullable().defaultTo(1);
table.bigInteger('unit_price').notNullable().defaultTo(0);
table.bigInteger('total_price').notNullable().defaultTo(0);
table.bigInteger('tax_amount').notNullable().defaultTo(0);
table.bigInteger('net_amount').notNullable().defaultTo(0);
table.text('unit_of_measure').nullable();
table.integer('display_order').notNullable().defaultTo(0);
table.text('phase').nullable();
table.boolean('is_optional').notNullable().defaultTo(false);
table.boolean('is_selected').notNullable().defaultTo(true);
table.boolean('is_recurring').notNullable().defaultTo(false);
table.text('billing_frequency').nullable();
table.boolean('is_discount').notNullable().defaultTo(false);
table.text('discount_type').nullable();
table.integer('discount_percentage').nullable();
table.uuid('applies_to_item_id').nullable();
table.uuid('applies_to_service_id').nullable();
table.boolean('is_taxable').notNullable().defaultTo(true);
table.text('tax_region').nullable();
table.integer('tax_rate').nullable();
table.uuid('created_by').nullable();
table.uuid('updated_by').nullable();
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'quote_item_id']);
table.foreign('tenant').references('tenants.tenant');
table.foreign(['tenant', 'quote_id']).references(['tenant', 'quote_id']).inTable('quotes').onDelete('CASCADE');
table.foreign(['tenant', 'service_id']).references(['tenant', 'service_id']).inTable('service_catalog').onDelete('SET NULL');
table.foreign(['tenant', 'created_by']).references(['tenant', 'user_id']).inTable('users').onDelete('SET NULL');
table.foreign(['tenant', 'updated_by']).references(['tenant', 'user_id']).inTable('users').onDelete('SET NULL');
});
await knex.raw(`
ALTER TABLE quote_items
ADD CONSTRAINT quote_items_discount_type_check
CHECK (discount_type IS NULL OR discount_type IN ('percentage', 'fixed'))
`);
await knex.raw('CREATE INDEX idx_quote_items_tenant_quote_order ON quote_items (tenant, quote_id, display_order)');
await knex.schema.createTable('quote_activities', (table) => {
table.uuid('tenant').notNullable();
table.uuid('activity_id').defaultTo(knex.raw('gen_random_uuid()')).notNullable();
table.uuid('quote_id').notNullable();
table.text('activity_type').notNullable();
table.text('description').notNullable();
table.uuid('performed_by').nullable();
table.jsonb('metadata').notNullable().defaultTo(knex.raw(`'{}'::jsonb`));
table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now());
table.primary(['tenant', 'activity_id']);
table.foreign('tenant').references('tenants.tenant');
table.foreign(['tenant', 'quote_id']).references(['tenant', 'quote_id']).inTable('quotes').onDelete('CASCADE');
table.foreign(['tenant', 'performed_by']).references(['tenant', 'user_id']).inTable('users').onDelete('SET NULL');
});
await knex.raw('CREATE INDEX idx_quote_activities_tenant_quote_created_at ON quote_activities (tenant, quote_id, created_at)');
await knex.raw(`
INSERT INTO next_number (tenant, entity_type, last_number, initial_value, prefix, padding_length)
SELECT tenant, 'QUOTE', 0, 1, 'Q-', 4
FROM tenants
ON CONFLICT (tenant, entity_type) DO NOTHING
`);
};
/**
* @param {import('knex').Knex} knex
* @returns {Promise<void>}
*/
exports.down = async function down(knex) {
await knex('next_number').where({ entity_type: 'QUOTE' }).del();
await knex.schema.dropTableIfExists('quote_activities');
await knex.schema.dropTableIfExists('quote_items');
await knex.schema.dropTableIfExists('quotes');
};