diff --git a/src/migrations/20260907130000-add-tenant-subscription-quotas.cjs b/src/migrations/20260907130000-add-tenant-subscription-quotas.cjs new file mode 100644 index 0000000..2426fa8 --- /dev/null +++ b/src/migrations/20260907130000-add-tenant-subscription-quotas.cjs @@ -0,0 +1,40 @@ +"use strict"; + +const TABLE = "tenants"; + +module.exports = { + async up(queryInterface, Sequelize) { + const columns = await queryInterface.describeTable(TABLE); + + const additions = [ + ["plan_name", { type: Sequelize.STRING(50), allowNull: false, defaultValue: "STARTER" }], + ["max_users", { type: Sequelize.INTEGER, allowNull: false, defaultValue: 10 }], + ["max_products", { type: Sequelize.INTEGER, allowNull: false, defaultValue: 5000 }], + ["storage_limit_mb", { type: Sequelize.INTEGER, allowNull: false, defaultValue: 5000 }], + ["subscription_expires_at", { type: Sequelize.DATE, allowNull: true }] + ]; + + for (const [name, definition] of additions) { + if (!columns[name]) { + await queryInterface.addColumn(TABLE, name, definition); + } + } + }, + + async down(queryInterface) { + const columns = await queryInterface.describeTable(TABLE); + const removals = [ + "subscription_expires_at", + "storage_limit_mb", + "max_products", + "max_users", + "plan_name" + ]; + + for (const name of removals) { + if (columns[name]) { + await queryInterface.removeColumn(TABLE, name); + } + } + } +}; diff --git a/src/migrations/20260907131000-add-tenant-scope-to-asset-taxonomy.cjs b/src/migrations/20260907131000-add-tenant-scope-to-asset-taxonomy.cjs new file mode 100644 index 0000000..88bf0e0 --- /dev/null +++ b/src/migrations/20260907131000-add-tenant-scope-to-asset-taxonomy.cjs @@ -0,0 +1,34 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + for (const table of ["asset_types", "asset_families"]) { + const columns = await queryInterface.describeTable(table); + if (!columns.tenant_id) { + await queryInterface.addColumn(table, "tenant_id", { + type: Sequelize.INTEGER, + allowNull: true, + references: { model: "tenants", key: "id" }, + onUpdate: "CASCADE", + onDelete: "CASCADE" + }); + } + + await queryInterface.addIndex(table, ["tenant_id"], { + name: `${table}_tenant_id_idx` + }).catch((error) => { + if (!String(error?.message || "").toLowerCase().includes("already exists")) throw error; + }); + } + }, + + async down(queryInterface) { + for (const table of ["asset_families", "asset_types"]) { + await queryInterface.removeIndex(table, `${table}_tenant_id_idx`).catch(() => {}); + const columns = await queryInterface.describeTable(table); + if (columns.tenant_id) { + await queryInterface.removeColumn(table, "tenant_id"); + } + } + } +}; diff --git a/src/migrations/20260907132000-align-current-model-schema.cjs b/src/migrations/20260907132000-align-current-model-schema.cjs new file mode 100644 index 0000000..d547b5b --- /dev/null +++ b/src/migrations/20260907132000-align-current-model-schema.cjs @@ -0,0 +1,109 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + const definitions = { + users: { + is_saas_user: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + reset_otp: { type: Sequelize.STRING(10), allowNull: true }, + reset_otp_expiry: { type: Sequelize.DATE, allowNull: true } + }, + products: { + metadata: { type: Sequelize.JSON, allowNull: true, defaultValue: {} } + }, + catalogs: { + tenant_id: { type: Sequelize.INTEGER, allowNull: true }, + attribute_set_id: { + type: Sequelize.UUID, + allowNull: true, + references: { model: "attribute_sets", key: "id" }, + onUpdate: "CASCADE", + onDelete: "SET NULL" + } + }, + categories: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } }, + brands: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } }, + units: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } }, + auditlogs: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } }, + attributes: { + tenant_id: { type: Sequelize.INTEGER, allowNull: true }, + help_text: { type: Sequelize.TEXT, allowNull: true }, + placeholder: { type: Sequelize.STRING(255), allowNull: true }, + sortable: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, + visible_in_grid: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }, + visible_in_product: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true }, + api_visible: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: true } + }, + attribute_groups: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } }, + attribute_sets: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } }, + syndication_jobs: { + triggered_by: { type: Sequelize.INTEGER, allowNull: true }, + total_products: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + success_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + failed_count: { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 }, + error_log: { type: Sequelize.JSONB, allowNull: true, defaultValue: [] }, + started_at: { type: Sequelize.DATE, allowNull: true }, + completed_at: { type: Sequelize.DATE, allowNull: true } + }, + workflow_registries: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } }, + product_variants: { + cost_price: { type: Sequelize.DECIMAL(12, 2), allowNull: true, defaultValue: 0 }, + currency: { type: Sequelize.STRING(10), allowNull: true, defaultValue: "USD" } + }, + assets: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } }, + asset_folders: { tenant_id: { type: Sequelize.INTEGER, allowNull: true } } + }; + + for (const [table, columnsToAdd] of Object.entries(definitions)) { + const existing = await queryInterface.describeTable(table); + for (const [column, definition] of Object.entries(columnsToAdd)) { + if (!existing[column]) { + await queryInterface.addColumn(table, column, definition); + } + } + } + + const tenantScopedTables = [ + "catalogs", "categories", "brands", "units", "auditlogs", "attributes", + "attribute_groups", "attribute_sets", "workflow_registries", "assets", "asset_folders" + ]; + for (const table of tenantScopedTables) { + const name = `${table}_tenant_id_idx`; + const indexes = await queryInterface.showIndex(table); + if (!indexes.some((index) => index.name === name)) { + await queryInterface.addIndex(table, ["tenant_id"], { name }); + } + } + }, + + async down(queryInterface) { + const removals = { + asset_folders: ["tenant_id"], + assets: ["tenant_id"], + product_variants: ["currency", "cost_price"], + workflow_registries: ["tenant_id"], + syndication_jobs: ["completed_at", "started_at", "error_log", "failed_count", "success_count", "total_products", "triggered_by"], + attribute_sets: ["tenant_id"], + attribute_groups: ["tenant_id"], + attributes: ["api_visible", "visible_in_product", "visible_in_grid", "sortable", "placeholder", "help_text", "tenant_id"], + auditlogs: ["tenant_id"], + units: ["tenant_id"], + brands: ["tenant_id"], + categories: ["tenant_id"], + catalogs: ["attribute_set_id", "tenant_id"], + products: ["metadata"], + users: ["reset_otp_expiry", "reset_otp", "is_saas_user"] + }; + + for (const [table, columnsToRemove] of Object.entries(removals)) { + const indexName = `${table}_tenant_id_idx`; + await queryInterface.removeIndex(table, indexName).catch(() => {}); + const existing = await queryInterface.describeTable(table); + for (const column of columnsToRemove) { + if (existing[column]) { + await queryInterface.removeColumn(table, column); + } + } + } + } +}; diff --git a/src/migrations/20260907133000-allow-passwordless-saas-users.cjs b/src/migrations/20260907133000-allow-passwordless-saas-users.cjs new file mode 100644 index 0000000..249e298 --- /dev/null +++ b/src/migrations/20260907133000-allow-passwordless-saas-users.cjs @@ -0,0 +1,23 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.changeColumn("users", "password_hash", { + type: Sequelize.STRING(255), + allowNull: true + }); + }, + + async down(queryInterface, Sequelize) { + const [rows] = await queryInterface.sequelize.query( + "SELECT COUNT(*)::int AS count FROM users WHERE password_hash IS NULL" + ); + if (Number(rows?.[0]?.count || 0) > 0) { + throw new Error("Cannot restore NOT NULL password_hash while passwordless SaaS users exist"); + } + await queryInterface.changeColumn("users", "password_hash", { + type: Sequelize.STRING(255), + allowNull: false + }); + } +};