Files
twenty/packages/twenty-apps/examples/postcard/src/components/generate-post-card-component-effect.tsx
T
martmull 617f571400 20215 convert application variable to a syncable entity (#20269)
##  Summary

- Converts applicationVariable from a bespoke sync path to a proper
SyncableEntity,
unifying it with the workspace migration pipeline used by all other
manifest-managed
  entities (agent, skill, frontComponent, webhook, etc.)
- Removes the upsertManyApplicationVariableEntities method and its
direct-DB-mutation
approach in favor of the standard validate → build → run action handler
pipeline
- Adds universalIdentifier, deletedAt columns and makes applicationId
NOT NULL via an
  instance command migration

##  Motivation

Before this change, applicationVariable was the only manifest-managed
entity that bypassed
ApplicationManifestMigrationService.syncMetadataFromManifest(). It used
a bespoke service
method called directly from syncApplication(), creating two mental
models, two validation
styles, and two cache invalidation patterns. Now there's one unified
pipeline for all
  manifest entities.

##  What changed

###  Entity refactor:
- ApplicationVariableEntity now extends SyncableEntity (gains
universalIdentifier,
  non-nullable applicationId with CASCADE, soft-delete via deletedAt)

###  New flat entity layer (flat-application-variable/):
- Type, maps type, editable properties constant, entity-to-flat
converter, cache service,
  module

###  New migration pipeline wiring:
- Manifest converter
(fromApplicationVariableManifestToUniversalFlatApplicationVariable)
  - Validator service (FlatApplicationVariableValidatorService)
- Builder service
(WorkspaceMigrationApplicationVariableActionsBuilderService)
  - Create/Update/Delete action handlers with secret encryption hooks
- Registered in orchestrator, builder module, runner module, and all
type registries

###  Removed bespoke path:
- Deleted upsertManyApplicationVariableEntities from
ApplicationVariableEntityService
  - Removed its call from ApplicationSyncService.syncApplication()
- Kept update() (operator-set value at runtime) and getDisplayValue()
(runtime display)

###  Database migration:
- Instance command to add columns, backfill universalIdentifier, enforce
NOT NULL
  constraints, and update indexes

##  Test plan

  - npx nx typecheck twenty-server passes (0 errors)
- Unit tests pass (application-variable.service.spec.ts,
build-env-var.spec.ts)
- Install an app with applicationVariables in its manifest → variables
appear with correct
  universalIdentifier
- Update app manifest (add/remove/modify a variable) → migration
pipeline handles diff
  correctly
- Operator-set value via update endpoint persists correctly with
encryption
  - Uninstall app → variables cascade-deleted
  - app dev --once on example app syncs without errors
2026-05-06 08:23:53 +00:00

125 lines
3.5 KiB
TypeScript

import { useEffect } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import {
enqueueSnackbar,
unmountFrontComponent,
updateProgress,
useRecordId,
} from 'twenty-sdk/front-component';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { POST_CARD_UNIVERSAL_IDENTIFIER } from '../objects/post-card.object';
const SYSTEM_PROMPT =
'You are a postcard writing assistant. Write a short, warm postcard message ' +
'under 150 words. Use a personal tone. Output ONLY the postcard message ' +
'text, nothing else — no greeting label, no sign-off label, just the message.';
const GeneratePostCardEffect = () => {
const recordId = useRecordId();
useEffect(() => {
if (recordId === null) {
enqueueSnackbar({
message: 'Please select exactly one record',
variant: 'error',
});
unmountFrontComponent();
return;
}
const generate = async () => {
try {
const client = new CoreApiClient();
const userPrompt = 'Write a postcard message for a friend.';
const apiBaseUrl = process.env.TWENTY_API_URL;
const token =
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
if (!apiBaseUrl || !token) {
throw new Error('API configuration missing');
}
const response = await fetch(`${apiBaseUrl}/rest/ai/generate-text`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
systemPrompt: SYSTEM_PROMPT,
userPrompt,
}),
});
if (!response.ok) {
const errorBody = await response.text();
if (errorBody.includes('No AI models are available')) {
throw new Error(
'No AI models configured. Go to Settings > Admin Panel > AI to add a provider API key.',
);
}
throw new Error(`AI request failed with status ${response.status}`);
}
const data = (await response.json()) as { text?: string };
const generatedContent = data.text ?? '';
await updateProgress(0.7);
await client.mutation({
updatePostCard: {
__args: {
id: recordId,
data: { content: generatedContent },
},
id: true,
},
});
await updateProgress(1);
await enqueueSnackbar({
message: 'Post card content generated!',
variant: 'success',
});
await unmountFrontComponent();
} catch (error) {
await enqueueSnackbar({
message:
error instanceof Error
? error.message
: 'Failed to generate content',
variant: 'error',
});
await unmountFrontComponent();
}
};
generate();
}, [recordId]);
return null;
};
export default defineFrontComponent({
universalIdentifier: 'bd7649c0-7540-4267-ac4b-f062fbd635a3',
name: 'Generate Post Card',
description: 'Generates postcard content using AI',
isHeadless: true,
component: GeneratePostCardEffect,
command: {
universalIdentifier: '0f795c1c-8e25-44da-8962-80bef9602ee2',
label: 'Generate post card content',
shortLabel: 'Generate content',
icon: 'IconSparkles',
isPinned: true,
availabilityType: 'RECORD_SELECTION',
availabilityObjectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
},
});