diff --git a/backend/src/db/api/tenants.js b/backend/src/db/api/tenants.js index e44a97b..eab8880 100644 --- a/backend/src/db/api/tenants.js +++ b/backend/src/db/api/tenants.js @@ -1,7 +1,5 @@ const db = require('../models'); -const FileDBApi = require('./file'); -const crypto = require('crypto'); const Utils = require('../utils'); @@ -9,6 +7,149 @@ const Utils = require('../utils'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; +const normalizeRelationIds = (items) => { + if (!Array.isArray(items)) { + return []; + } + + return [...new Set(items.map((item) => { + if (!item) { + return null; + } + + if (typeof item === 'string') { + return item; + } + + if (typeof item === 'object' && item.id) { + return item.id; + } + + if (typeof item === 'object' && item.value) { + return item.value; + } + + return null; + }).filter(Boolean))]; +}; + +const syncTenantOwnedChildren = async ({ + tenantId, + model, + selectedIds, + organizationIds, + transaction, + currentUserId, + organizationField = 'organizationsId', +}) => { + const normalizedSelectedIds = normalizeRelationIds(selectedIds); + + const detachWhere = { + tenantId, + }; + + if (normalizedSelectedIds.length) { + detachWhere.id = { + [Op.notIn]: normalizedSelectedIds, + }; + } + + await model.update( + { + tenantId: null, + updatedById: currentUserId, + }, + { + where: detachWhere, + transaction, + }, + ); + + if (!normalizedSelectedIds.length) { + return normalizedSelectedIds; + } + + await model.update( + { + tenantId, + updatedById: currentUserId, + }, + { + where: { + id: { + [Op.in]: normalizedSelectedIds, + }, + }, + transaction, + }, + ); + + if (!organizationField) { + return normalizedSelectedIds; + } + + if (!organizationIds.length) { + await model.update( + { + [organizationField]: null, + updatedById: currentUserId, + }, + { + where: { + id: { + [Op.in]: normalizedSelectedIds, + }, + tenantId, + }, + transaction, + }, + ); + + return normalizedSelectedIds; + } + + if (organizationIds.length === 1) { + await model.update( + { + [organizationField]: organizationIds[0], + updatedById: currentUserId, + }, + { + where: { + id: { + [Op.in]: normalizedSelectedIds, + }, + tenantId, + }, + transaction, + }, + ); + + return normalizedSelectedIds; + } + + await model.update( + { + [organizationField]: null, + updatedById: currentUserId, + }, + { + where: { + id: { + [Op.in]: normalizedSelectedIds, + }, + tenantId, + [organizationField]: { + [Op.notIn]: organizationIds, + }, + }, + transaction, + }, + ); + + return normalizedSelectedIds; +}; + module.exports = class TenantsDBApi { @@ -66,19 +207,29 @@ module.exports = class TenantsDBApi { - - await tenants.setOrganizations(data.organizations || [], { + const organizationIds = normalizeRelationIds(data.organizations); + + await tenants.setOrganizations(organizationIds, { transaction, }); - - await tenants.setProperties(data.properties || [], { + + await syncTenantOwnedChildren({ + tenantId: tenants.id, + model: db.properties, + selectedIds: data.properties || [], + organizationIds, transaction, + currentUserId: currentUser.id, }); - - await tenants.setAudit_logs(data.audit_logs || [], { + + await syncTenantOwnedChildren({ + tenantId: tenants.id, + model: db.audit_logs, + selectedIds: data.audit_logs || [], + organizationIds, transaction, + currentUserId: currentUser.id, }); - @@ -148,8 +299,6 @@ module.exports = class TenantsDBApi { static async update(id, data, options) { const currentUser = (options && options.currentUser) || {id: null}; const transaction = (options && options.transaction) || undefined; - const globalAccess = currentUser.app_role?.globalAccess; - const tenants = await db.tenants.findByPk(id, {}, {transaction}); @@ -185,18 +334,37 @@ module.exports = class TenantsDBApi { - - + let organizationIds = null; + if (data.organizations !== undefined) { - await tenants.setOrganizations(data.organizations, { transaction }); + organizationIds = normalizeRelationIds(data.organizations); + await tenants.setOrganizations(organizationIds, { transaction }); + } else { + organizationIds = normalizeRelationIds( + (await tenants.getOrganizations({ transaction })) || [], + ); } if (data.properties !== undefined) { - await tenants.setProperties(data.properties, { transaction }); + await syncTenantOwnedChildren({ + tenantId: tenants.id, + model: db.properties, + selectedIds: data.properties, + organizationIds, + transaction, + currentUserId: currentUser.id, + }); } if (data.audit_logs !== undefined) { - await tenants.setAudit_logs(data.audit_logs, { transaction }); + await syncTenantOwnedChildren({ + tenantId: tenants.id, + model: db.audit_logs, + selectedIds: data.audit_logs, + organizationIds, + transaction, + currentUserId: currentUser.id, + }); } @@ -349,16 +517,10 @@ module.exports = class TenantsDBApi { output.organizations = await tenants.getOrganizations({ transaction }); - - - output.properties = await tenants.getProperties({ - transaction - }); - - - output.audit_logs = await tenants.getAudit_logs({ - transaction - }); + + output.properties = output.properties_tenant; + + output.audit_logs = output.audit_logs_tenant; @@ -380,41 +542,19 @@ module.exports = class TenantsDBApi { - if (userOrganizations) { - if (options?.currentUser?.organizationsId) { - where.organizationsId = options.currentUser.organizationsId; - } - } - - offset = currentPage * limit; - const orderBy = null; - - const transaction = (options && options.transaction) || undefined; - let include = [ - - { model: db.organizations, as: 'organizations', - required: false, + required: !globalAccess && Boolean(userOrganizations), + where: !globalAccess && userOrganizations + ? { + id: Utils.uuid(userOrganizations), + } + : undefined, }, - - { - model: db.properties, - as: 'properties', - required: false, - }, - - { - model: db.audit_logs, - as: 'audit_logs', - required: false, - }, - - ]; if (filter) { @@ -545,7 +685,7 @@ module.exports = class TenantsDBApi { include = [ { model: db.properties, - as: 'properties_filter', + as: 'properties_tenant', required: searchTerms.length > 0, where: searchTerms.length > 0 ? { [Op.or]: [ @@ -568,7 +708,7 @@ module.exports = class TenantsDBApi { include = [ { model: db.audit_logs, - as: 'audit_logs_filter', + as: 'audit_logs_tenant', required: searchTerms.length > 0, where: searchTerms.length > 0 ? { [Op.or]: [ @@ -612,11 +752,6 @@ module.exports = class TenantsDBApi { } - - if (globalAccess) { - delete where.organizationsId; - } - const queryOptions = { where, @@ -649,12 +784,20 @@ module.exports = class TenantsDBApi { static async findAllAutocomplete(query, limit, offset, globalAccess, organizationId,) { let where = {}; - - + let include = []; + if (!globalAccess && organizationId) { - where.organizationId = organizationId; + include = [ + { + model: db.organizations, + as: 'organizations_filter', + required: true, + where: { + id: Utils.uuid(organizationId), + }, + }, + ]; } - if (query) { where = { @@ -672,6 +815,7 @@ module.exports = class TenantsDBApi { const records = await db.tenants.findAll({ attributes: [ 'id', 'name' ], where, + include, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, orderBy: [['name', 'ASC']], diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index c716367..fdb3744 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -12,6 +12,57 @@ const config = require('../../config'); const Sequelize = db.Sequelize; const Op = Sequelize.Op; + +const resolveCurrentOrganizationContext = async (output, transaction) => { + const directOrganization = output.organizations; + + if (directOrganization && directOrganization.id) { + output.organization = output.organization || directOrganization; + output.organizationId = output.organizationId || directOrganization.id; + output.organizationsId = output.organizationsId || directOrganization.id; + output.organizationName = output.organizationName || directOrganization.name || null; + return output; + } + + const membership = (Array.isArray(output.organization_memberships_user) + ? [...output.organization_memberships_user] + : []) + .sort((left, right) => { + if (Boolean(left?.is_primary) !== Boolean(right?.is_primary)) { + return left?.is_primary ? -1 : 1; + } + + if (Boolean(left?.is_active) !== Boolean(right?.is_active)) { + return left?.is_active ? -1 : 1; + } + + return 0; + }) + .find((item) => item?.organizationId); + + if (!membership?.organizationId) { + return output; + } + + const organization = await db.organizations.findByPk(membership.organizationId, { + transaction, + }); + + if (!organization) { + return output; + } + + const organizationData = organization.get({ plain: true }); + + output.organizations = organizationData; + output.organization = organizationData; + output.organizationId = organizationData.id; + output.organizationsId = organizationData.id; + output.organizationName = output.organizationName || organizationData.name || null; + + return output; +}; + module.exports = class UsersDBApi { static async create(data,globalAccess, options) { @@ -519,7 +570,7 @@ module.exports = class UsersDBApi { transaction }); - + await resolveCurrentOrganizationContext(output, transaction); return output; } diff --git a/backend/src/db/migrations/20260404050000-create-tenants-organizations-join-table.js b/backend/src/db/migrations/20260404050000-create-tenants-organizations-join-table.js new file mode 100644 index 0000000..4842458 --- /dev/null +++ b/backend/src/db/migrations/20260404050000-create-tenants-organizations-join-table.js @@ -0,0 +1,89 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.\"tenantsOrganizationsOrganizations\"') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const tableName = rows[0].regclass_name; + + if (tableName) { + await transaction.commit(); + return; + } + + await queryInterface.createTable( + 'tenantsOrganizationsOrganizations', + { + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + tenants_organizationsId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'tenants', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + organizationId: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + primaryKey: true, + references: { + model: 'organizations', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + }, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.\"tenantsOrganizationsOrganizations\"') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const tableName = rows[0].regclass_name; + + if (!tableName) { + await transaction.commit(); + return; + } + + await queryInterface.dropTable('tenantsOrganizationsOrganizations', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260404051500-grant-administrator-workspace-summary-read-permissions.js b/backend/src/db/migrations/20260404051500-grant-administrator-workspace-summary-read-permissions.js new file mode 100644 index 0000000..09d92cd --- /dev/null +++ b/backend/src/db/migrations/20260404051500-grant-administrator-workspace-summary-read-permissions.js @@ -0,0 +1,45 @@ +'use strict'; + +module.exports = { + async up(queryInterface) { + const sequelize = queryInterface.sequelize; + const [roles] = await sequelize.query( + `SELECT "id" FROM "roles" WHERE "name" = 'Administrator' LIMIT 1;`, + ); + + if (!roles.length) { + return; + } + + const [permissions] = await sequelize.query( + `SELECT "id", "name" FROM "permissions" WHERE "name" IN ('READ_TENANTS', 'READ_ORGANIZATIONS');`, + ); + + const now = new Date(); + + for (const permission of permissions) { + await sequelize.query( + `INSERT INTO "rolesPermissionsPermissions" ("createdAt", "updatedAt", "roles_permissionsId", "permissionId") + SELECT :createdAt, :updatedAt, :roleId, :permissionId + WHERE NOT EXISTS ( + SELECT 1 + FROM "rolesPermissionsPermissions" + WHERE "roles_permissionsId" = :roleId + AND "permissionId" = :permissionId + );`, + { + replacements: { + createdAt: now, + updatedAt: now, + roleId: roles[0].id, + permissionId: permission.id, + }, + }, + ); + } + }, + + async down() { + // Intentionally left blank. This protects live permission assignments. + }, +}; diff --git a/backend/src/db/migrations/20260404053000-create-tenants-properties-and-auditlogs-join-tables.js b/backend/src/db/migrations/20260404053000-create-tenants-properties-and-auditlogs-join-tables.js new file mode 100644 index 0000000..8ea2843 --- /dev/null +++ b/backend/src/db/migrations/20260404053000-create-tenants-properties-and-auditlogs-join-tables.js @@ -0,0 +1,114 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tableDefinitions = [ + { + tableName: 'tenantsPropertiesProperties', + sourceKey: 'tenants_propertiesId', + sourceTable: 'tenants', + targetKey: 'propertyId', + targetTable: 'properties', + }, + { + tableName: 'tenantsAudit_logsAudit_logs', + sourceKey: 'tenants_audit_logsId', + sourceTable: 'tenants', + targetKey: 'auditLogId', + targetTable: 'audit_logs', + }, + ]; + + for (const definition of tableDefinitions) { + const rows = await queryInterface.sequelize.query( + `SELECT to_regclass('public."${definition.tableName}"') AS regclass_name;`, + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const tableName = rows[0].regclass_name; + + if (tableName) { + continue; + } + + await queryInterface.createTable( + definition.tableName, + { + createdAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + updatedAt: { + type: Sequelize.DataTypes.DATE, + allowNull: false, + }, + [definition.sourceKey]: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + primaryKey: true, + references: { + model: definition.sourceTable, + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + [definition.targetKey]: { + type: Sequelize.DataTypes.UUID, + allowNull: false, + primaryKey: true, + references: { + model: definition.targetTable, + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + }, + { transaction }, + ); + } + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const tableNames = [ + 'tenantsPropertiesProperties', + 'tenantsAudit_logsAudit_logs', + ]; + + for (const tableName of tableNames) { + const rows = await queryInterface.sequelize.query( + `SELECT to_regclass('public."${tableName}"') AS regclass_name;`, + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const existingTableName = rows[0].regclass_name; + + if (!existingTableName) { + continue; + } + + await queryInterface.dropTable(tableName, { transaction }); + } + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/src/db/migrations/20260404061000-backfill-tenants-organizations-join-table.js b/backend/src/db/migrations/20260404061000-backfill-tenants-organizations-join-table.js new file mode 100644 index 0000000..c41643c --- /dev/null +++ b/backend/src/db/migrations/20260404061000-backfill-tenants-organizations-join-table.js @@ -0,0 +1,119 @@ +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + const rows = await queryInterface.sequelize.query( + "SELECT to_regclass('public.\"tenantsOrganizationsOrganizations\"') AS regclass_name;", + { + transaction, + type: Sequelize.QueryTypes.SELECT, + }, + ); + const tableName = rows[0].regclass_name; + + if (!tableName) { + await transaction.commit(); + return; + } + + await queryInterface.sequelize.query( + ` + INSERT INTO "tenantsOrganizationsOrganizations" ( + "tenants_organizationsId", + "organizationId", + "createdAt", + "updatedAt" + ) + SELECT DISTINCT + relation_pairs.tenant_id, + relation_pairs.organization_id, + NOW(), + NOW() + FROM ( + SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id + FROM booking_requests + WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL + + UNION + + SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id + FROM reservations + WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL + + UNION + + SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id + FROM documents + WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL + + UNION + + SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id + FROM invoices + WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL + + UNION + + SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id + FROM role_assignments + WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL + + UNION + + SELECT "tenantId" AS tenant_id, "organizationId" AS organization_id + FROM activity_comments + WHERE "tenantId" IS NOT NULL AND "organizationId" IS NOT NULL + + UNION + + SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id + FROM service_requests + WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL + + UNION + + SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id + FROM properties + WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL + + UNION + + SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id + FROM audit_logs + WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL + + UNION + + SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id + FROM notifications + WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL + + UNION + + SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id + FROM checklists + WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL + + UNION + + SELECT "tenantId" AS tenant_id, "organizationsId" AS organization_id + FROM job_runs + WHERE "tenantId" IS NOT NULL AND "organizationsId" IS NOT NULL + ) AS relation_pairs + ON CONFLICT ("tenants_organizationsId", "organizationId") DO NOTHING; + `, + { transaction }, + ); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down() { + return Promise.resolve(); + }, +}; diff --git a/backend/src/db/seeders/20260403090000-align-business-role-matrix.js b/backend/src/db/seeders/20260403090000-align-business-role-matrix.js index ce5f5d0..cad7890 100644 --- a/backend/src/db/seeders/20260403090000-align-business-role-matrix.js +++ b/backend/src/db/seeders/20260403090000-align-business-role-matrix.js @@ -31,6 +31,8 @@ const rolePermissionMatrix = { 'CREATE_DOCUMENTS', 'READ_DOCUMENTS', 'UPDATE_DOCUMENTS', + 'READ_ORGANIZATIONS', + 'READ_TENANTS', 'READ_PROPERTIES', 'READ_UNITS', 'READ_NEGOTIATED_RATES', diff --git a/frontend/src/components/CurrentWorkspaceChip.tsx b/frontend/src/components/CurrentWorkspaceChip.tsx index 9b3aff3..8f524ba 100644 --- a/frontend/src/components/CurrentWorkspaceChip.tsx +++ b/frontend/src/components/CurrentWorkspaceChip.tsx @@ -13,7 +13,9 @@ import { import BaseButton from './BaseButton' import BaseIcon from './BaseIcon' +import CardBoxModal from './CardBoxModal' import ClickOutside from './ClickOutside' +import ConnectedEntityCard from './ConnectedEntityCard' import TenantStatusChip from './TenantStatusChip' import { useAppSelector } from '../stores/hooks' import { @@ -31,6 +33,7 @@ const CurrentWorkspaceChip = () => { const [linkedTenantSummary, setLinkedTenantSummary] = useState(emptyOrganizationTenantSummary) const [isLoadingTenants, setIsLoadingTenants] = useState(false) const [isPopoverActive, setIsPopoverActive] = useState(false) + const [isWorkspaceModalActive, setIsWorkspaceModalActive] = useState(false) const organizationId = useMemo( () => @@ -90,16 +93,18 @@ const CurrentWorkspaceChip = () => { useEffect(() => { setIsPopoverActive(false) + setIsWorkspaceModalActive(false) }, [router.asPath]) useEffect(() => { - if (!isPopoverActive) { + if (!isPopoverActive && !isWorkspaceModalActive) { return () => undefined } const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { setIsPopoverActive(false) + setIsWorkspaceModalActive(false) } } @@ -108,7 +113,7 @@ const CurrentWorkspaceChip = () => { return () => { document.removeEventListener('keydown', handleEscape) } - }, [isPopoverActive]) + }, [isPopoverActive, isWorkspaceModalActive]) if (!currentUser) { return null @@ -120,6 +125,17 @@ const CurrentWorkspaceChip = () => { currentUser?.organizationName || 'No organization assigned yet' + const organizationSlug = + currentUser?.organizations?.slug || currentUser?.organization?.slug || currentUser?.organizationSlug || '' + + const organizationDomain = + currentUser?.organizations?.domain || + currentUser?.organization?.domain || + currentUser?.organizations?.primary_domain || + currentUser?.organization?.primary_domain || + currentUser?.organizationDomain || + '' + const appRoleName = currentUser?.app_role?.name || 'User' const linkedTenants = Array.isArray(linkedTenantSummary.rows) ? linkedTenantSummary.rows : [] const linkedTenantCount = linkedTenantSummary.count || 0 @@ -130,173 +146,331 @@ const CurrentWorkspaceChip = () => { const canViewTenants = hasPermission(currentUser, 'READ_TENANTS') const organizationHref = canViewOrganizations && organizationId ? getOrganizationViewHref(organizationId) : '/profile' + const hasOrganizationMetadata = Boolean(organizationSlug || organizationDomain) + + const handleOpenWorkspaceModal = () => { + setIsPopoverActive(false) + setIsWorkspaceModalActive(true) + } + + const handleCloseWorkspaceModal = () => { + setIsWorkspaceModalActive(false) + } return ( -