Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb3cec8b8d | |||
| 51e3eddb29 | |||
| 27db8bbae4 | |||
| 84ba7eb8dc | |||
| a3d73740a1 | |||
| 0d3c7a47af | |||
| 2dbdd72e65 | |||
| f20d04eb6e | |||
| f72898063a |
@@ -5,6 +5,7 @@ import { FileApi } from '@/cli/utilities/api/file-api';
|
||||
import { LogicFunctionApi } from '@/cli/utilities/api/logic-function-api';
|
||||
import { SchemaApi } from '@/cli/utilities/api/schema-api';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
|
||||
type ApiServiceOptions = {
|
||||
disableInterceptors?: boolean;
|
||||
@@ -72,7 +73,12 @@ export class ApiService {
|
||||
return this.applicationApi.createDevelopmentApplication(...args);
|
||||
}
|
||||
|
||||
syncApplication(manifest: Manifest): Promise<ApiResponse> {
|
||||
syncApplication(manifest: Manifest): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
}>
|
||||
> {
|
||||
return this.applicationApi.syncApplication(manifest);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type ApiResponse } from '@/cli/utilities/api/api-response-type';
|
||||
import axios, { type AxiosInstance, type AxiosResponse } from 'axios';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
|
||||
export class ApplicationApi {
|
||||
constructor(private readonly client: AxiosInstance) {}
|
||||
@@ -251,7 +252,12 @@ export class ApplicationApi {
|
||||
}
|
||||
}
|
||||
|
||||
async syncApplication(manifest: Manifest): Promise<ApiResponse> {
|
||||
async syncApplication(manifest: Manifest): Promise<
|
||||
ApiResponse<{
|
||||
applicationUniversalIdentifier: string;
|
||||
actions: SyncAction[];
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const mutation = `
|
||||
mutation SyncApplication($manifest: JSON!) {
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
|
||||
|
||||
describe('formatSyncActionsSummary', () => {
|
||||
it('reports no changes when the actions list is empty', () => {
|
||||
expect(formatSyncActionsSummary([])).toEqual([
|
||||
{ message: 'No metadata changes', status: 'info' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('summarizes created, updated and deleted actions with their identifiers', () => {
|
||||
const events = formatSyncActionsSummary([
|
||||
{
|
||||
type: 'create',
|
||||
metadataName: 'objectMetadata',
|
||||
flatEntity: {
|
||||
universalIdentifier: 'uid-object',
|
||||
nameSingular: 'rocket',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'create',
|
||||
metadataName: 'fieldMetadata',
|
||||
flatEntity: {
|
||||
universalIdentifier: 'uid-field',
|
||||
name: 'timelineActivities',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'update',
|
||||
metadataName: 'fieldMetadata',
|
||||
universalIdentifier: 'uid-updated-field',
|
||||
},
|
||||
{
|
||||
type: 'delete',
|
||||
metadataName: 'pageLayout',
|
||||
universalIdentifier: 'uid-page-layout',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
message: 'Metadata changes: 2 created, 1 updated, 1 deleted',
|
||||
status: 'info',
|
||||
},
|
||||
{ message: ' created objectMetadata rocket', status: 'info' },
|
||||
{ message: ' created fieldMetadata timelineActivities', status: 'info' },
|
||||
{ message: ' updated fieldMetadata uid-updated-field', status: 'info' },
|
||||
{ message: ' deleted pageLayout uid-page-layout', status: 'info' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the universal identifier when a created entity has no name', () => {
|
||||
const events = formatSyncActionsSummary([
|
||||
{
|
||||
type: 'create',
|
||||
metadataName: 'fieldMetadata',
|
||||
flatEntity: { universalIdentifier: 'uid-nameless' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(events).toEqual([
|
||||
{ message: 'Metadata changes: 1 created', status: 'info' },
|
||||
{ message: ' created fieldMetadata uid-nameless', status: 'info' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('truncates the detail lines when there are more changes than the display limit', () => {
|
||||
const actions: SyncAction[] = Array.from({ length: 55 }, (_, index) => ({
|
||||
type: 'create' as const,
|
||||
metadataName: 'fieldMetadata' as const,
|
||||
flatEntity: {
|
||||
universalIdentifier: `uid-${index}`,
|
||||
name: `field${index}`,
|
||||
},
|
||||
}));
|
||||
|
||||
const events = formatSyncActionsSummary(actions);
|
||||
|
||||
expect(events[0]).toEqual({
|
||||
message: 'Metadata changes: 55 created',
|
||||
status: 'info',
|
||||
});
|
||||
expect(events).toHaveLength(52);
|
||||
expect(events[51]).toEqual({
|
||||
message: ' …and 5 more change(s)',
|
||||
status: 'info',
|
||||
});
|
||||
});
|
||||
});
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { type ApiService } from '@/cli/utilities/api/api-service';
|
||||
import { OrchestratorState } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { SyncApplicationOrchestratorStep } from '@/cli/utilities/dev/orchestrator/steps/sync-application-orchestrator-step';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
|
||||
vi.mock('@/cli/utilities/build/manifest/manifest-update-checksums', () => ({
|
||||
manifestUpdateChecksums: ({ manifest }: { manifest: unknown }) => manifest,
|
||||
}));
|
||||
|
||||
vi.mock('@/cli/utilities/build/manifest/manifest-writer', () => ({
|
||||
writeManifestToOutput: vi.fn(),
|
||||
}));
|
||||
|
||||
const buildStep = (
|
||||
syncApplication: ApiService['syncApplication'],
|
||||
): { state: OrchestratorState; step: SyncApplicationOrchestratorStep } => {
|
||||
const state = new OrchestratorState({ appPath: '/tmp/app' });
|
||||
|
||||
const apiService = { syncApplication } as unknown as ApiService;
|
||||
|
||||
const step = new SyncApplicationOrchestratorStep({
|
||||
apiService,
|
||||
state,
|
||||
notify: () => {},
|
||||
});
|
||||
|
||||
return { state, step };
|
||||
};
|
||||
|
||||
const executeInput = {
|
||||
manifest: {
|
||||
application: { displayName: 'Demo' },
|
||||
} as unknown as Manifest,
|
||||
builtFileInfos: new Map(),
|
||||
appPath: '/tmp/app',
|
||||
};
|
||||
|
||||
describe('SyncApplicationOrchestratorStep', () => {
|
||||
it('renders the applied metadata changes on a successful sync', async () => {
|
||||
const syncApplication = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: {
|
||||
applicationUniversalIdentifier: 'app-uid',
|
||||
actions: [
|
||||
{
|
||||
type: 'create',
|
||||
metadataName: 'fieldMetadata',
|
||||
flatEntity: {
|
||||
universalIdentifier: 'uid-field',
|
||||
name: 'timelineActivities',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { state, step } = buildStep(syncApplication);
|
||||
|
||||
await step.execute(executeInput);
|
||||
|
||||
const messages = state.events.map((event) => event.message);
|
||||
|
||||
expect(messages).toContain('Metadata changes: 1 created');
|
||||
expect(messages).toContain(' created fieldMetadata timelineActivities');
|
||||
expect(messages).toContain('✓ Synced');
|
||||
});
|
||||
|
||||
it('reports no metadata changes when the sync applies nothing', async () => {
|
||||
const syncApplication = vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
data: { applicationUniversalIdentifier: 'app-uid', actions: [] },
|
||||
});
|
||||
|
||||
const { state, step } = buildStep(syncApplication);
|
||||
|
||||
await step.execute(executeInput);
|
||||
|
||||
const messages = state.events.map((event) => event.message);
|
||||
|
||||
expect(messages).toContain('No metadata changes');
|
||||
expect(messages).toContain('✓ Synced');
|
||||
});
|
||||
});
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { type OrchestratorStateStepEvent } from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const MAX_DETAIL_LINES = 50;
|
||||
|
||||
const VERB_BY_TYPE = {
|
||||
create: 'created',
|
||||
update: 'updated',
|
||||
delete: 'deleted',
|
||||
} as const;
|
||||
|
||||
const getActionLabel = (action: SyncAction): string => {
|
||||
if (action.type === 'create') {
|
||||
return (
|
||||
action.flatEntity?.name ??
|
||||
action.flatEntity?.nameSingular ??
|
||||
action.flatEntity?.universalIdentifier ??
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
return action.universalIdentifier;
|
||||
};
|
||||
|
||||
export const formatSyncActionsSummary = (
|
||||
actions: SyncAction[],
|
||||
): OrchestratorStateStepEvent[] => {
|
||||
if (actions.length === 0) {
|
||||
return [{ message: 'No metadata changes', status: 'info' }];
|
||||
}
|
||||
|
||||
const counts = { create: 0, update: 0, delete: 0 };
|
||||
|
||||
for (const action of actions) {
|
||||
counts[action.type] += 1;
|
||||
}
|
||||
|
||||
const summaryParts = [
|
||||
counts.create > 0 ? `${counts.create} created` : null,
|
||||
counts.update > 0 ? `${counts.update} updated` : null,
|
||||
counts.delete > 0 ? `${counts.delete} deleted` : null,
|
||||
].filter(isDefined);
|
||||
|
||||
const events: OrchestratorStateStepEvent[] = [
|
||||
{ message: `Metadata changes: ${summaryParts.join(', ')}`, status: 'info' },
|
||||
];
|
||||
|
||||
const visibleActions = actions.slice(0, MAX_DETAIL_LINES);
|
||||
|
||||
for (const action of visibleActions) {
|
||||
events.push({
|
||||
message: ` ${VERB_BY_TYPE[action.type]} ${action.metadataName} ${getActionLabel(action)}`,
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
const hiddenCount = actions.length - visibleActions.length;
|
||||
|
||||
if (hiddenCount > 0) {
|
||||
events.push({
|
||||
message: ` …and ${hiddenCount} more change(s)`,
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
return events;
|
||||
};
|
||||
+4
@@ -7,6 +7,7 @@ import {
|
||||
type OrchestratorStateStepEvent,
|
||||
type OrchestratorStateSyncStatus,
|
||||
} from '@/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state';
|
||||
import { formatSyncActionsSummary } from '@/cli/utilities/dev/orchestrator/steps/format-sync-actions-summary';
|
||||
import { formatManifestValidationErrors } from '@/cli/utilities/error/format-manifest-validation-errors';
|
||||
import { serializeError } from '@/cli/utilities/error/serialize-error';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
@@ -69,6 +70,9 @@ export class SyncApplicationOrchestratorStep {
|
||||
const syncResult = await this.apiService.syncApplication(manifest);
|
||||
|
||||
if (syncResult.success) {
|
||||
const syncData = syncResult.data;
|
||||
|
||||
events.push(...formatSyncActionsSummary(syncData.actions));
|
||||
events.push({ message: '✓ Synced', status: 'success' });
|
||||
step.output = { syncStatus: 'synced', error: null };
|
||||
step.status = 'done';
|
||||
|
||||
+4
-2
@@ -144,9 +144,11 @@ export class SyncCallRecordingStandardObjectsCommand extends ActiveOrSuspendedWo
|
||||
];
|
||||
|
||||
if (!isDefined(calendarEventObjectMetadata)) {
|
||||
throw new Error(
|
||||
`calendarEvent object not found for workspace ${workspaceId}`,
|
||||
this.logger.warn(
|
||||
`calendarEvent object not found for workspace ${workspaceId}, skipping CallRecording standard metadata sync`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { twentyStandardFlatApplication } =
|
||||
|
||||
+2
@@ -5,6 +5,7 @@ import { ApplicationManifestModule } from 'src/engine/core-modules/application/a
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
@@ -17,6 +18,7 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
|
||||
ApplicationModule,
|
||||
ApplicationManifestModule,
|
||||
ApplicationRegistrationModule,
|
||||
CacheLockModule,
|
||||
FeatureFlagModule,
|
||||
SdkClientModule,
|
||||
TokenModule,
|
||||
|
||||
+15
@@ -33,6 +33,7 @@ import {
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
|
||||
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
|
||||
@@ -52,6 +53,8 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
const APP_DEV_RATE_LIMIT_MAX = 30;
|
||||
const APP_DEV_RATE_LIMIT_WINDOW_MS = 30_000;
|
||||
|
||||
const APP_SYNC_LOCK_OPTIONS = { ttl: 60_000, ms: 500, maxRetries: 120 };
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
@@ -71,6 +74,7 @@ export class ApplicationDevelopmentResolver {
|
||||
private readonly sdkClientGenerationService: SdkClientGenerationService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly throttlerService: ThrottlerService,
|
||||
private readonly cacheLockService: CacheLockService,
|
||||
) {}
|
||||
|
||||
@Mutation(() => DevelopmentApplicationDTO)
|
||||
@@ -137,6 +141,17 @@ export class ApplicationDevelopmentResolver {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return this.cacheLockService.withLock(
|
||||
() => this.applyManifestSync(manifest, workspaceId),
|
||||
`app-sync:${workspaceId}`,
|
||||
APP_SYNC_LOCK_OPTIONS,
|
||||
);
|
||||
}
|
||||
|
||||
private async applyManifestSync(
|
||||
manifest: ApplicationInput['manifest'],
|
||||
workspaceId: string,
|
||||
): Promise<WorkspaceMigrationDTO> {
|
||||
const applicationRegistrationId = await this.findApplicationRegistrationId(
|
||||
manifest.application.universalIdentifier,
|
||||
);
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { type SyncAction } from 'twenty-shared/metadata';
|
||||
|
||||
@ObjectType('WorkspaceMigration')
|
||||
export class WorkspaceMigrationDTO {
|
||||
@@ -8,5 +9,5 @@ export class WorkspaceMigrationDTO {
|
||||
applicationUniversalIdentifier: string;
|
||||
|
||||
@Field(() => GraphQLJSON)
|
||||
actions: unknown[];
|
||||
actions: SyncAction[];
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
|
||||
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
|
||||
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
|
||||
import { BillingGaugeService } from 'src/engine/core-modules/billing/billing-gauge.service';
|
||||
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
|
||||
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
|
||||
@@ -49,6 +50,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
@Module({
|
||||
imports: [
|
||||
ClickHouseModule,
|
||||
CoreEntityCacheModule,
|
||||
FeatureFlagModule,
|
||||
StripeModule,
|
||||
MessageQueueModule,
|
||||
|
||||
+1
@@ -263,6 +263,7 @@ export class BillingSubscriptionService {
|
||||
billingSubscription.stripeSubscriptionId,
|
||||
{
|
||||
trial_end: 'now',
|
||||
cancel_at_period_end: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+15
@@ -3,10 +3,12 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateTimeForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import {
|
||||
BillingException,
|
||||
BillingExceptionCode,
|
||||
@@ -49,6 +51,7 @@ export class BillingUsageService {
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly clickHouseService: ClickHouseService,
|
||||
private readonly billingUsageCapService: BillingUsageCapService,
|
||||
private readonly coreEntityCacheService: CoreEntityCacheService,
|
||||
) {}
|
||||
|
||||
async canFeatureBeUsed(workspaceId: string): Promise<boolean> {
|
||||
@@ -348,6 +351,18 @@ export class BillingUsageService {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workspace = await this.coreEntityCacheService.get(
|
||||
'workspaceEntity',
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (
|
||||
isDefined(workspace) &&
|
||||
workspace.activationStatus === WorkspaceActivationStatus.SUSPENDED
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { billingSubscription: subscription } =
|
||||
await this.workspaceCacheService.getOrRecompute(workspaceId, [
|
||||
'billingSubscription',
|
||||
|
||||
@@ -114,6 +114,15 @@ export class BullMQDriver
|
||||
Sentry.withIsolationScope(async () => {
|
||||
applyWorkspaceSentryContextFromJobData(job.data);
|
||||
|
||||
const queueLatency = Math.max(0, Date.now() - job.timestamp);
|
||||
|
||||
this.metricsService.recordHistogram({
|
||||
key: MetricsKeys.JobLatencyMs,
|
||||
value: queueLatency,
|
||||
unit: 'ms',
|
||||
attributes: { queue: queueName, job_name: job.name },
|
||||
});
|
||||
|
||||
// TODO: Correctly support for job.id
|
||||
const timeStart = performance.now();
|
||||
const workspaceId = job.data?.workspaceId;
|
||||
|
||||
@@ -40,6 +40,7 @@ export enum MetricsKeys {
|
||||
JobCompleted = 'job/completed',
|
||||
JobFailed = 'job/failed',
|
||||
JobWaiting = 'job/waiting',
|
||||
JobLatencyMs = 'job/latency-ms',
|
||||
AiChatTurnLatencyMs = 'ai-chat/turn-latency-ms',
|
||||
AiChatStepLatencyMs = 'ai-chat/step-latency-ms',
|
||||
AiChatTtftMs = 'ai-chat/ttft-ms',
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ Report: {
|
||||
`;
|
||||
|
||||
exports[`formatUpgradeErrorForStorage should format a WorkspaceMigrationRunnerException with EXECUTION_FAILED 1`] = `
|
||||
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' failed
|
||||
"[WorkspaceMigrationRunnerException] Migration action 'create' for 'objectMetadata' (universalIdentifier: test-object-uid) failed
|
||||
Code: EXECUTION_FAILED
|
||||
Action: create on objectMetadata
|
||||
Metadata error: column "label" cannot be null
|
||||
|
||||
+1
@@ -57,6 +57,7 @@ describe('formatUpgradeErrorForStorage', () => {
|
||||
const action = {
|
||||
type: 'create',
|
||||
metadataName: 'objectMetadata',
|
||||
flatEntity: { universalIdentifier: 'test-object-uid' },
|
||||
} as unknown as AllUniversalWorkspaceMigrationAction;
|
||||
|
||||
const error = new WorkspaceMigrationRunnerException({
|
||||
|
||||
+12
-1
@@ -279,11 +279,22 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
|
||||
const hasPermissionFromSettingPermissions = isDefined(
|
||||
rolePermissionFlags.find(
|
||||
(rolePermissionFlag) =>
|
||||
rolePermissionFlag.permissionFlag.universalIdentifier ===
|
||||
this.getRolePermissionFlagUniversalIdentifier(rolePermissionFlag) ===
|
||||
permissionFlagUniversalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
return hasPermissionFromRole || hasPermissionFromSettingPermissions;
|
||||
}
|
||||
|
||||
private getRolePermissionFlagUniversalIdentifier(
|
||||
rolePermissionFlag: RolePermissionFlagEntity,
|
||||
): string {
|
||||
// The `permissionFlag` relation is stripped during upgrades until the 2.6.0
|
||||
// cursor (@WasIntroducedInUpgrade), so fall back to the legacy `flag` column.
|
||||
return (
|
||||
rolePermissionFlag.permissionFlag?.universalIdentifier ??
|
||||
SystemPermissionFlag[rolePermissionFlag.flag]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { type AllUniversalWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-action-common';
|
||||
import {
|
||||
WorkspaceMigrationRunnerException,
|
||||
WorkspaceMigrationRunnerExceptionCode,
|
||||
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/exceptions/workspace-migration-runner.exception';
|
||||
|
||||
describe('WorkspaceMigrationRunnerException', () => {
|
||||
it('includes the universal identifier of a failed create action in the message', () => {
|
||||
const action = {
|
||||
type: 'create',
|
||||
metadataName: 'fieldMetadata',
|
||||
flatEntity: {
|
||||
universalIdentifier: '20202020-6736-4337-b5c4-8b39fae325a5',
|
||||
},
|
||||
} as unknown as AllUniversalWorkspaceMigrationAction;
|
||||
|
||||
const exception = new WorkspaceMigrationRunnerException({
|
||||
code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED,
|
||||
action,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
expect(exception.message).toBe(
|
||||
"Migration action 'create' for 'fieldMetadata' (universalIdentifier: 20202020-6736-4337-b5c4-8b39fae325a5) failed",
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the universal identifier of a failed delete action in the message', () => {
|
||||
const action = {
|
||||
type: 'delete',
|
||||
metadataName: 'pageLayout',
|
||||
universalIdentifier: 'uid-page-layout',
|
||||
} as unknown as AllUniversalWorkspaceMigrationAction;
|
||||
|
||||
const exception = new WorkspaceMigrationRunnerException({
|
||||
code: WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED,
|
||||
action,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
expect(exception.message).toBe(
|
||||
"Migration action 'delete' for 'pageLayout' (universalIdentifier: uid-page-layout) failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
+25
-1
@@ -34,6 +34,25 @@ export type WorkspaceMigrationRunnerExecutionErrors = {
|
||||
actionTranspilation?: Error;
|
||||
};
|
||||
|
||||
const getActionUniversalIdentifierOrThrow = (
|
||||
action: AllUniversalWorkspaceMigrationAction,
|
||||
): string => {
|
||||
if (action.type === 'create') {
|
||||
const universalIdentifier = action.flatEntity?.universalIdentifier;
|
||||
|
||||
if (!universalIdentifier) {
|
||||
throw new WorkspaceMigrationRunnerException({
|
||||
message: `Missing universalIdentifier on create action for '${action.metadataName}'`,
|
||||
code: WorkspaceMigrationRunnerExceptionCode.INTERNAL_SERVER_ERROR,
|
||||
});
|
||||
}
|
||||
|
||||
return universalIdentifier;
|
||||
}
|
||||
|
||||
return action.universalIdentifier;
|
||||
};
|
||||
|
||||
const {
|
||||
// oxlint-disable-next-line unused-imports/no-unused-vars
|
||||
EXECUTION_FAILED: WorkspaceMigrationRunnerExceptionExecutionFailedCode,
|
||||
@@ -62,8 +81,13 @@ export class WorkspaceMigrationRunnerException extends CustomError {
|
||||
|
||||
constructor(args: WorkspaceMigrationRunnerExceptionConstructorArgs) {
|
||||
if (args.code === WorkspaceMigrationRunnerExceptionCode.EXECUTION_FAILED) {
|
||||
const universalIdentifier = getActionUniversalIdentifierOrThrow(
|
||||
args.action,
|
||||
);
|
||||
const identifierClause = ` (universalIdentifier: ${universalIdentifier})`;
|
||||
|
||||
super(
|
||||
`Migration action '${args.action.type}' for '${args.action.metadataName}' failed`,
|
||||
`Migration action '${args.action.type}' for '${args.action.metadataName}'${identifierClause} failed`,
|
||||
);
|
||||
|
||||
this.code = args.code;
|
||||
|
||||
+11
-2
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
@@ -378,8 +378,17 @@ export class WorkflowCommonWorkspaceService {
|
||||
for (const workflowVersion of workflowVersions) {
|
||||
for (const step of workflowVersion.steps ?? []) {
|
||||
if (step.type === WorkflowActionType.CODE) {
|
||||
const logicFunctionId = step.settings.input.logicFunctionId;
|
||||
|
||||
if (!isValidUuid(logicFunctionId)) {
|
||||
this.logger.warn(
|
||||
`Skipping destroy for CODE step with undefined logicFunctionId in workflow ${workflowId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.logicFunctionFromSourceService.deleteOneWithSource({
|
||||
id: step.settings.input.logicFunctionId,
|
||||
id: logicFunctionId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
+2
-2
@@ -188,7 +188,7 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
|
||||
nextStepIds: [],
|
||||
settings: {
|
||||
input: {
|
||||
logicFunctionId: 'function-id',
|
||||
logicFunctionId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
},
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
@@ -206,7 +206,7 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
|
||||
expect(
|
||||
logicFunctionFromSourceService.deleteOneWithSource,
|
||||
).toHaveBeenCalledWith({
|
||||
id: 'function-id',
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
});
|
||||
|
||||
+4
@@ -91,6 +91,10 @@ export class WorkflowVersionStepOperationsWorkspaceService {
|
||||
}) {
|
||||
switch (step.type) {
|
||||
case WorkflowActionType.CODE: {
|
||||
if (!isValidUuid(step.settings.input.logicFunctionId)) {
|
||||
break;
|
||||
}
|
||||
|
||||
await this.logicFunctionFromSourceService.deleteOneWithSource({
|
||||
id: step.settings.input.logicFunctionId,
|
||||
workspaceId,
|
||||
|
||||
+14
@@ -9,6 +9,10 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm
|
||||
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
|
||||
import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
|
||||
import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import {
|
||||
WorkflowVersionStepException,
|
||||
WorkflowVersionStepExceptionCode,
|
||||
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
|
||||
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
|
||||
import {
|
||||
type WorkflowToolContext,
|
||||
@@ -116,6 +120,16 @@ This is the most efficient way for AI to create workflows as it handles all the
|
||||
activate?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const codeSteps = parameters.steps.filter(
|
||||
(step) => step.type === ('CODE' as string),
|
||||
);
|
||||
|
||||
if (codeSteps.length > 0) {
|
||||
throw new WorkflowVersionStepException(
|
||||
'CODE steps cannot be created via create_complete_workflow because it does not create the underlying logic function. Use create_workflow_version_step instead.',
|
||||
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
|
||||
);
|
||||
}
|
||||
const workflowId = await createWorkflow({
|
||||
deps,
|
||||
context,
|
||||
|
||||
@@ -25,6 +25,7 @@ export type {
|
||||
MetadataValidationErrorResponse,
|
||||
} from './types/MetadataValidationError';
|
||||
export { WorkspaceMigrationV2ExceptionCode } from './types/MetadataValidationError';
|
||||
export type { SyncAction } from './types/sync-action.type';
|
||||
export { addCustomSuffixIfIsReserved } from './utils/add-custom-suffix-if-reserved.util';
|
||||
export { computeMetadataNameFromLabel } from './utils/compute-metadata-name-from-label.util';
|
||||
export { computeMetadataNamesFromLabelsOrThrow } from './utils/compute-metadata-names-from-labels-or-throw.util';
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type AllMetadataName } from '@/metadata/types/all-metadata-name.type';
|
||||
|
||||
type SyncCreateAction = {
|
||||
type: 'create';
|
||||
metadataName: AllMetadataName;
|
||||
flatEntity?: {
|
||||
name?: string | null;
|
||||
nameSingular?: string | null;
|
||||
universalIdentifier?: string | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type SyncUpdateAction = {
|
||||
type: 'update';
|
||||
metadataName: AllMetadataName;
|
||||
universalIdentifier: string;
|
||||
};
|
||||
|
||||
type SyncDeleteAction = {
|
||||
type: 'delete';
|
||||
metadataName: AllMetadataName;
|
||||
universalIdentifier: string;
|
||||
};
|
||||
|
||||
export type SyncAction = SyncCreateAction | SyncUpdateAction | SyncDeleteAction;
|
||||
Reference in New Issue
Block a user