feat(pim): align tenant schema for SaaS provisioning

This commit is contained in:
Inamul-hasan-tec
2026-09-08 16:26:41 +05:30
parent e9748a74a2
commit ad416d631a
4 changed files with 206 additions and 0 deletions
@@ -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);
}
}
}
};
@@ -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");
}
}
}
};
@@ -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);
}
}
}
}
};
@@ -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
});
}
};