This commit is contained in:
Ahmed Bouhuolia
2026-05-12 18:25:52 +02:00
parent 6ef1a6a651
commit c53bf60406
8 changed files with 106 additions and 79 deletions

View File

@@ -0,0 +1,6 @@
export enum WorkspacesError {
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
NOT_WORKSPACE_OWNER = 'NOT_WORKSPACE_OWNER',
WORKSPACE_DELETING = 'WORKSPACE_DELETING',
CANNOT_DELETE_CURRENT_ORGANIZATION = 'CANNOT_DELETE_CURRENT_ORGANIZATION',
}

View File

@@ -31,6 +31,7 @@ import {
} from './dtos/WorkspaceResponse.dto';
import { WorkspaceBuildJobResponseDto } from './dtos/WorkspaceBuildJobResponse.dto';
import { ServiceError } from '@/modules/Items/ServiceError';
import { WorkspacesError } from './Workspaces.constants';
@ApiTags('Workspaces')
@Controller('workspaces')
@@ -128,7 +129,7 @@ export class WorkspacesController {
if (organizationId === currentOrganizationId) {
throw new ServiceError(
'CANNOT_DELETE_CURRENT_ORGANIZATION',
WorkspacesError.CANNOT_DELETE_CURRENT_ORGANIZATION,
'Cannot delete the current organization',
);
}

View File

@@ -5,11 +5,7 @@ import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { TenantDBManager } from '@/modules/TenantDBManager/TenantDBManager';
import { events } from '@/common/events/events';
const ERRORS = {
WORKSPACE_NOT_FOUND: 'WORKSPACE.NOT_FOUND',
NOT_WORKSPACE_OWNER: 'NOT.WORKSPACE.OWNER',
};
import { WorkspacesError } from '../Workspaces.constants';
@Injectable()
export class DeleteWorkspaceService {
@@ -33,14 +29,14 @@ export class DeleteWorkspaceService {
const tenant = await this.tenantModel.query().findOne({ organizationId });
if (!tenant) {
throw new ServiceError(ERRORS.WORKSPACE_NOT_FOUND);
throw new ServiceError(WorkspacesError.WORKSPACE_NOT_FOUND);
}
const membership = await this.userTenantModel
.query()
.findOne({ userId, tenantId: tenant.id });
if (!membership || membership.role !== 'owner') {
throw new ServiceError(ERRORS.NOT_WORKSPACE_OWNER);
throw new ServiceError(WorkspacesError.NOT_WORKSPACE_OWNER);
}
// Drop the physical tenant database if it exists.
await this.tenantDBManager.dropDatabaseIfExists();

View File

@@ -10,12 +10,7 @@ import {
DeleteWorkspaceQueueJobPayload,
} from '../Workspaces.types';
import { events } from '@/common/events/events';
const ERRORS = {
WORKSPACE_NOT_FOUND: 'WORKSPACE.NOT_FOUND',
NOT_WORKSPACE_OWNER: 'NOT.WORKSPACE.OWNER',
WORKSPACE_DELETING: 'WORKSPACE.DELETING',
};
import { WorkspacesError } from '../Workspaces.constants';
interface DeleteWorkspaceResult {
jobId: string | number;
@@ -51,7 +46,7 @@ export class DeleteWorkspaceJobService {
const tenant = await this.tenantModel.query().findOne({ organizationId });
if (!tenant) {
throw new ServiceError(ERRORS.WORKSPACE_NOT_FOUND);
throw new ServiceError(WorkspacesError.WORKSPACE_NOT_FOUND);
}
const membership = await this.userTenantModel
@@ -59,12 +54,12 @@ export class DeleteWorkspaceJobService {
.findOne({ userId, tenantId: tenant.id });
if (!membership || membership.role !== 'owner') {
throw new ServiceError(ERRORS.NOT_WORKSPACE_OWNER);
throw new ServiceError(WorkspacesError.NOT_WORKSPACE_OWNER);
}
// Check if workspace is already being deleted.
if (tenant.isDeleting) {
throw new ServiceError(ERRORS.WORKSPACE_DELETING);
throw new ServiceError(WorkspacesError.WORKSPACE_DELETING);
}
// Emit workspace deleting event.

View File

@@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { ServiceError } from '@/modules/Items/ServiceError';
import { UserTenant } from '@/modules/System/models/UserTenant.model';
import { TenantModel } from '@/modules/System/models/TenantModel';
import { WorkspacesError } from '../Workspaces.constants';
@Injectable()
export class InactivateWorkspaceService {
@@ -23,20 +24,19 @@ export class InactivateWorkspaceService {
const tenant = await this.tenantModel.query().findOne({ organizationId });
if (!tenant) {
throw new ServiceError('WORKSPACE_NOT_FOUND', 'Workspace not found');
throw new ServiceError(WorkspacesError.WORKSPACE_NOT_FOUND, 'Workspace not found');
}
const membership = await this.userTenantModel
.query()
.findOne({ userId, tenantId: tenant.id })
.withGraphFetched('tenant');
if (!membership) {
throw new ServiceError('WORKSPACE_NOT_FOUND', 'Workspace not found');
throw new ServiceError(WorkspacesError.WORKSPACE_NOT_FOUND, 'Workspace not found');
}
if (membership.role !== 'owner') {
throw new ServiceError(
'NOT_OWNER',
WorkspacesError.NOT_WORKSPACE_OWNER,
'Only the workspace owner can inactivate the workspace',
);
}
@@ -58,7 +58,7 @@ export class InactivateWorkspaceService {
const tenant = await this.tenantModel.query().findOne({ organizationId });
if (!tenant) {
throw new ServiceError('WORKSPACE_NOT_FOUND', 'Workspace not found');
throw new ServiceError(WorkspacesError.WORKSPACE_NOT_FOUND, 'Workspace not found');
}
const membership = await this.userTenantModel
@@ -67,12 +67,12 @@ export class InactivateWorkspaceService {
.withGraphFetched('tenant');
if (!membership) {
throw new ServiceError('WORKSPACE_NOT_FOUND', 'Workspace not found');
throw new ServiceError(WorkspacesError.WORKSPACE_NOT_FOUND, 'Workspace not found');
}
if (membership.role !== 'owner') {
throw new ServiceError(
'NOT_OWNER',
WorkspacesError.NOT_WORKSPACE_OWNER,
'Only the workspace owner can reactivate the workspace',
);
}

View File

@@ -12,8 +12,10 @@ export class SetDefaultWorkspaceService {
constructor(
@Inject(UserTenant.name)
private readonly userTenantModel: typeof UserTenant,
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel,
private readonly eventEmitter: EventEmitter2,

View File

@@ -4,6 +4,7 @@ import { SystemUser } from '@/modules/System/models/SystemUser';
import { WorkspaceDto } from '../dtos/WorkspaceResponse.dto';
import { WorkspaceTransformer } from '../transformers/WorkspaceTransformer';
import { GetWorkspacesFinancialService } from './GetWorkspacesFinancial.service';
import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service';
@Injectable()
export class GetWorkspacesService {
@@ -13,6 +14,7 @@ export class GetWorkspacesService {
@Inject(SystemUser.name)
private readonly systemUserModel: typeof SystemUser,
private readonly financialService: GetWorkspacesFinancialService,
private readonly transformer: TransformerInjectable,
) {}
/**
@@ -50,30 +52,15 @@ export class GetWorkspacesService {
const financialDataMap =
await this.financialService.getWorkspacesFinancial(workspaceInfos);
const transformer = new WorkspaceTransformer();
let workspaces = memberships.map((membership) => {
const financialData = financialDataMap.get(membership.tenantId);
return transformer.transform(
membership,
return this.transformer.transform(
memberships,
new WorkspaceTransformer(),
{
defaultTenantId,
financialData,
);
});
// Filter out inactive workspaces unless includeInactive is true
if (!includeInactive) {
workspaces = workspaces.filter((w) => w.isActive);
}
// Sort: current organization first, then by name
return workspaces.sort((a, b) => {
if (currentOrganizationId) {
if (a.organizationId === currentOrganizationId) return -1;
if (b.organizationId === currentOrganizationId) return 1;
}
return (a.metadata?.name || a.organizationId).localeCompare(
b.metadata?.name || b.organizationId,
);
});
financialDataMap,
includeInactive,
currentOrganizationId,
},
);
}
}

View File

@@ -15,9 +15,6 @@ interface FinancialData {
* Transforms UserTenant (workspace membership) to WorkspaceDto.
*/
export class WorkspaceTransformer extends Transformer<UserTenant> {
private defaultTenantId?: number;
private financialData?: FinancialData;
/**
* Include these attributes in the transformed output.
*/
@@ -29,7 +26,6 @@ export class WorkspaceTransformer extends Transformer<UserTenant> {
'isDeleting',
'isActive',
'buildJobId',
'role',
'metadata',
'isDefault',
'totalIncome',
@@ -104,44 +100,48 @@ export class WorkspaceTransformer extends Transformer<UserTenant> {
* Determine if this workspace is the user's default.
*/
protected isDefault = (membership: UserTenant): boolean => {
if (!this.defaultTenantId) return false;
return membership.tenantId === this.defaultTenantId;
const defaultTenantId = this.options?.defaultTenantId;
if (!defaultTenantId) return false;
return membership.tenantId === defaultTenantId;
};
/**
* Get total income from financial data.
*/
protected totalIncome = (): number => {
return this.financialData?.totalIncome ?? 0;
protected totalIncome = (financialData?: FinancialData): number => {
return financialData?.totalIncome ?? 0;
};
/**
* Get total expenses from financial data.
*/
protected totalExpenses = (): number => {
return this.financialData?.totalExpenses ?? 0;
protected totalExpenses = (financialData?: FinancialData): number => {
return financialData?.totalExpenses ?? 0;
};
/**
* Get total assets from financial data.
*/
protected totalAssets = (): number => {
return this.financialData?.totalAssets ?? 0;
protected totalAssets = (financialData?: FinancialData): number => {
return financialData?.totalAssets ?? 0;
};
/**
* Get total liabilities from financial data.
*/
protected totalLiabilities = (): number => {
return this.financialData?.totalLiabilities ?? 0;
protected totalLiabilities = (financialData?: FinancialData): number => {
return financialData?.totalLiabilities ?? 0;
};
/**
* Get formatted total assets.
*/
protected formattedTotalAssets = (membership: UserTenant): string => {
protected formattedTotalAssets = (
membership: UserTenant,
financialData?: FinancialData,
): string => {
const currencyCode = membership.tenant?.metadata?.baseCurrency;
return formatNumber(this.totalAssets(), {
return formatNumber(financialData?.totalAssets ?? 0, {
currencyCode,
money: true,
});
@@ -150,9 +150,12 @@ export class WorkspaceTransformer extends Transformer<UserTenant> {
/**
* Get formatted total liabilities.
*/
protected formattedTotalLiabilities = (membership: UserTenant): string => {
protected formattedTotalLiabilities = (
membership: UserTenant,
financialData?: FinancialData,
): string => {
const currencyCode = membership.tenant?.metadata?.baseCurrency;
return formatNumber(this.totalLiabilities(), {
return formatNumber(financialData?.totalLiabilities ?? 0, {
currencyCode,
money: true,
});
@@ -161,13 +164,11 @@ export class WorkspaceTransformer extends Transformer<UserTenant> {
/**
* Transform single membership to WorkspaceDto.
*/
transform = (
membership: UserTenant,
defaultTenantId?: number,
financialData?: FinancialData,
): WorkspaceDto => {
this.defaultTenantId = defaultTenantId;
this.financialData = financialData;
transform = (membership: UserTenant): WorkspaceDto => {
const financialData = (
this.options?.financialDataMap as Map<number, FinancialData>
)?.get(membership.tenantId);
return {
organizationId: this.organizationId(membership),
isReady: this.isReady(membership),
@@ -178,12 +179,51 @@ export class WorkspaceTransformer extends Transformer<UserTenant> {
role: membership.role,
isDefault: this.isDefault(membership),
metadata: this.metadata(membership),
totalIncome: this.totalIncome(),
totalExpenses: this.totalExpenses(),
totalAssets: this.totalAssets(),
totalLiabilities: this.totalLiabilities(),
formattedTotalAssets: this.formattedTotalAssets(membership),
formattedTotalLiabilities: this.formattedTotalLiabilities(membership),
totalIncome: this.totalIncome(financialData),
totalExpenses: this.totalExpenses(financialData),
totalAssets: this.totalAssets(financialData),
totalLiabilities: this.totalLiabilities(financialData),
formattedTotalAssets: this.formattedTotalAssets(membership, financialData),
formattedTotalLiabilities: this.formattedTotalLiabilities(
membership,
financialData,
),
};
};
/**
* Process collections directly through transform, then apply
* post-collection filtering and sorting.
*/
public work = (object: any) => {
if (Array.isArray(object)) {
const transformed = object.map((item) => this.transform(item));
return this.postCollectionTransform(transformed);
}
return this.transform(object);
};
/**
* Filter and sort the transformed workspaces collection.
*/
protected postCollectionTransform = (
workspaces: WorkspaceDto[],
): WorkspaceDto[] => {
let result = workspaces;
if (!this.options?.includeInactive) {
result = result.filter((w) => w.isActive);
}
return result.sort((a, b) => {
const currentOrganizationId = this.options?.currentOrganizationId as string;
if (currentOrganizationId) {
if (a.organizationId === currentOrganizationId) return -1;
if (b.organizationId === currentOrganizationId) return 1;
}
return (a.metadata?.name || a.organizationId).localeCompare(
b.metadata?.name || b.organizationId,
);
});
};
}