Autosave: 20260404-051420
This commit is contained in:
parent
33f59460fd
commit
95c088fa21
@ -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,22 +207,32 @@ module.exports = class TenantsDBApi {
|
||||
|
||||
|
||||
|
||||
const organizationIds = normalizeRelationIds(data.organizations);
|
||||
|
||||
await tenants.setOrganizations(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,
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
return tenants;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -350,15 +518,9 @@ module.exports = class TenantsDBApi {
|
||||
transaction
|
||||
});
|
||||
|
||||
output.properties = output.properties_tenant;
|
||||
|
||||
output.properties = await tenants.getProperties({
|
||||
transaction
|
||||
});
|
||||
|
||||
|
||||
output.audit_logs = await tenants.getAudit_logs({
|
||||
transaction
|
||||
});
|
||||
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]: [
|
||||
@ -613,11 +753,6 @@ module.exports = class TenantsDBApi {
|
||||
|
||||
|
||||
|
||||
if (globalAccess) {
|
||||
delete where.organizationsId;
|
||||
}
|
||||
|
||||
|
||||
const queryOptions = {
|
||||
where,
|
||||
include,
|
||||
@ -649,13 +784,21 @@ 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 = {
|
||||
[Op.or]: [
|
||||
@ -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']],
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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.
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
@ -31,6 +31,8 @@ const rolePermissionMatrix = {
|
||||
'CREATE_DOCUMENTS',
|
||||
'READ_DOCUMENTS',
|
||||
'UPDATE_DOCUMENTS',
|
||||
'READ_ORGANIZATIONS',
|
||||
'READ_TENANTS',
|
||||
'READ_PROPERTIES',
|
||||
'READ_UNITS',
|
||||
'READ_NEGOTIATED_RATES',
|
||||
|
||||
@ -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,8 +146,19 @@ 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 (
|
||||
<>
|
||||
<div className="relative hidden xl:block" ref={triggerRef}>
|
||||
<button
|
||||
type="button"
|
||||
@ -187,6 +214,16 @@ const CurrentWorkspaceChip = () => {
|
||||
<BaseIcon path={mdiDomain} size="14" />
|
||||
{tenantLabel}
|
||||
</span>
|
||||
{organizationSlug ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/80 px-2.5 py-1 dark:bg-dark-800">
|
||||
Slug: {organizationSlug}
|
||||
</span>
|
||||
) : null}
|
||||
{organizationDomain ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/80 px-2.5 py-1 dark:bg-dark-800">
|
||||
Domain: {organizationDomain}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -218,6 +255,20 @@ const CurrentWorkspaceChip = () => {
|
||||
<BaseIcon path={mdiEmailOutline} size="14" />
|
||||
<span className="truncate">{currentUser?.email || 'No email surfaced yet'}</span>
|
||||
</p>
|
||||
{hasOrganizationMetadata ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{organizationSlug ? (
|
||||
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-900 dark:text-gray-100">
|
||||
Slug: {organizationSlug}
|
||||
</span>
|
||||
) : null}
|
||||
{organizationDomain ? (
|
||||
<span className="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-900 dark:text-gray-100">
|
||||
Domain: {organizationDomain}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -227,6 +278,14 @@ const CurrentWorkspaceChip = () => {
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-gray-500 dark:text-gray-300">
|
||||
Linked tenants
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-semibold text-blue-700 hover:text-blue-800 dark:text-blue-200 dark:hover:text-blue-100"
|
||||
onClick={handleOpenWorkspaceModal}
|
||||
>
|
||||
Workspace details
|
||||
</button>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="text-xs font-semibold text-blue-700 hover:text-blue-800 dark:text-blue-200 dark:hover:text-blue-100"
|
||||
@ -234,6 +293,7 @@ const CurrentWorkspaceChip = () => {
|
||||
View profile context
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{isLoadingTenants ? (
|
||||
@ -297,6 +357,120 @@ const CurrentWorkspaceChip = () => {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<CardBoxModal
|
||||
title="Workspace details"
|
||||
buttonColor="info"
|
||||
buttonLabel="Done"
|
||||
isActive={isWorkspaceModalActive}
|
||||
onConfirm={handleCloseWorkspaceModal}
|
||||
onCancel={handleCloseWorkspaceModal}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
This view expands the same workspace summary from the navbar so the user can inspect their organization context without leaving the current page.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ConnectedEntityCard
|
||||
entityLabel="Workspace"
|
||||
title={organizationName}
|
||||
titleFallback="No organization assigned yet"
|
||||
badges={[
|
||||
<span
|
||||
key="workspace-role"
|
||||
className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100"
|
||||
>
|
||||
Role: {appRoleName}
|
||||
</span>,
|
||||
<span
|
||||
key="workspace-tenants"
|
||||
className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-dark-700 dark:text-gray-100"
|
||||
>
|
||||
{tenantLabel}
|
||||
</span>,
|
||||
]}
|
||||
details={[
|
||||
{ label: 'Email', value: currentUser?.email },
|
||||
{ label: 'Slug', value: organizationSlug },
|
||||
{ label: 'Domain', value: organizationDomain },
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
href: organizationHref,
|
||||
label: canViewOrganizations && organizationId ? 'Open workspace' : 'Open profile',
|
||||
color: 'info',
|
||||
},
|
||||
{
|
||||
href: '/profile',
|
||||
label: 'View profile',
|
||||
color: 'info',
|
||||
outline: true,
|
||||
},
|
||||
]}
|
||||
helperText={
|
||||
organizationId
|
||||
? 'This is the organization currently attached to the signed-in account context.'
|
||||
: 'This account does not have an organization link yet.'
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-500 dark:text-gray-300">
|
||||
Linked tenants
|
||||
</p>
|
||||
{linkedTenantCount > linkedTenants.length && !isLoadingTenants ? (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-300">
|
||||
Showing {linkedTenants.length} of {linkedTenantCount} linked tenants.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isLoadingTenants ? (
|
||||
<div className="rounded-xl border border-dashed border-blue-200 bg-blue-50/60 px-4 py-3 text-sm text-blue-900 dark:border-blue-900/70 dark:bg-blue-950/20 dark:text-blue-100">
|
||||
Loading tenant context for this workspace…
|
||||
</div>
|
||||
) : linkedTenants.length ? (
|
||||
<div className="space-y-3">
|
||||
{linkedTenants.map((tenant) => (
|
||||
<ConnectedEntityCard
|
||||
key={tenant.id}
|
||||
entityLabel="Tenant"
|
||||
title={tenant.name || 'Unnamed tenant'}
|
||||
badges={[<TenantStatusChip key={`${tenant.id}-status`} isActive={tenant.is_active} />]}
|
||||
details={[
|
||||
{ label: 'Slug', value: tenant.slug },
|
||||
{ label: 'Domain', value: tenant.primary_domain },
|
||||
{ label: 'Timezone', value: tenant.timezone },
|
||||
{ label: 'Currency', value: tenant.default_currency },
|
||||
]}
|
||||
actions={
|
||||
canViewTenants && tenant.id
|
||||
? [
|
||||
{
|
||||
href: getTenantViewHref(tenant.id, organizationId, organizationName),
|
||||
label: 'Open tenant',
|
||||
color: 'info',
|
||||
outline: true,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
helperText="This tenant is linked behind the organization context attached to the current account."
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300">
|
||||
No tenant link surfaced yet for this workspace.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBoxModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ const initialState: StyleState = {
|
||||
navBarItemLabelHoverStyle: styles.midnightBlueTheme.navBarItemLabelHover,
|
||||
navBarItemLabelActiveColorStyle: styles.midnightBlueTheme.navBarItemLabelActiveColor,
|
||||
overlayStyle: styles.midnightBlueTheme.overlay,
|
||||
darkMode: false,
|
||||
darkMode: true,
|
||||
bgLayoutColor: styles.midnightBlueTheme.bgLayoutColor,
|
||||
iconsColor: styles.midnightBlueTheme.iconsColor,
|
||||
cardsColor: styles.midnightBlueTheme.cardsColor,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user