diff --git a/packages/twenty-apps/examples/document-generator/.gitignore b/packages/twenty-apps/examples/document-generator/.gitignore new file mode 100644 index 0000000000..6cd0610624 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn + +# codegen +generated + +# testing +/coverage + +# dev +/dist/ + +.twenty + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# typescript +*.tsbuildinfo +*.d.ts diff --git a/packages/twenty-apps/examples/document-generator/.nvmrc b/packages/twenty-apps/examples/document-generator/.nvmrc new file mode 100644 index 0000000000..341cb50613 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/.nvmrc @@ -0,0 +1 @@ +24.5.0 diff --git a/packages/twenty-apps/examples/document-generator/.oxlintrc.json b/packages/twenty-apps/examples/document-generator/.oxlintrc.json new file mode 100644 index 0000000000..2c2035b0fe --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/.oxlintrc.json @@ -0,0 +1,58 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": ["../../.oxlintrc.base.json"], + "plugins": ["react", "typescript", "import", "unicorn"], + "categories": { + "correctness": "off" + }, + "ignorePatterns": ["node_modules", "dist"], + "rules": { + "func-style": ["error", "declaration", { "allowArrowFunctions": true }], + "no-console": "off", + "no-control-regex": "off", + "no-debugger": "error", + "no-duplicate-imports": "error", + "no-undef": "off", + "no-unused-vars": "off", + "no-redeclare": "off", + "import/no-duplicates": "error", + "typescript/no-redeclare": "error", + "typescript/ban-ts-comment": "error", + "typescript/consistent-type-imports": [ + "error", + { + "prefer": "type-imports", + "fixStyle": "inline-type-imports" + } + ], + "typescript/explicit-function-return-type": "off", + "typescript/explicit-module-boundary-types": "off", + "typescript/no-empty-object-type": [ + "error", + { + "allowInterfaces": "with-single-extends" + } + ], + "typescript/no-empty-function": "off", + "typescript/no-explicit-any": "off", + "typescript/no-unused-vars": [ + "warn", + { + "vars": "all", + "varsIgnorePattern": "^_", + "args": "after-used", + "argsIgnorePattern": "^_" + } + ], + "react/no-unescaped-entities": "off", + "react/prop-types": "off", + "react/jsx-key": "off", + "react/display-name": "off", + "react/jsx-uses-react": "off", + "react/react-in-jsx-scope": "off", + "react/jsx-no-useless-fragment": "off", + "react/jsx-props-no-spreading": ["error", { "explicitSpread": "ignore" }], + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn" + } +} diff --git a/packages/twenty-apps/examples/document-generator/.yarnrc.yml b/packages/twenty-apps/examples/document-generator/.yarnrc.yml new file mode 100644 index 0000000000..3186f3f079 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/packages/twenty-apps/examples/document-generator/README.md b/packages/twenty-apps/examples/document-generator/README.md new file mode 100644 index 0000000000..693652fffb --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/README.md @@ -0,0 +1,16 @@ +# Document Generator + +**Turn your CRM data into finished documents — in one click.** + +## ✨ What you get + +- **Reusable templates** — write once with `{{placeholders}}` like `{{name.firstName}}` or `{{company.name}}` +- **Generate anywhere** — from the command menu on a record, a workflow step, or AI chat +- **Polished PDFs** — every document is saved to the record with a downloadable PDF +- **Shareable links** — open any document as a standalone, printable web page +- **Native rich-text editor** — author templates in the same editor as Notes and Tasks + +## 📌 Heads up + +- **Free to run** — generation uses no external API, so there's no per-document charge. +- **A hands-on reference** — this is the app built in the [Document Generator tutorial](https://docs.twenty.com/developers/extend/apps/tutorials/document-generator/overview), a tour through most of the Twenty SDK. diff --git a/packages/twenty-apps/examples/document-generator/package.json b/packages/twenty-apps/examples/document-generator/package.json new file mode 100644 index 0000000000..0855f51233 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/package.json @@ -0,0 +1,41 @@ +{ + "name": "@twentyhq/document-generator", + "version": "0.1.0", + "description": "Create reusable document templates and generate personalized documents from your CRM records", + "license": "MIT", + "engines": { + "node": "^24.5.0", + "npm": "please-use-yarn", + "yarn": ">=4.0.2" + }, + "keywords": [ + "twenty-app" + ], + "packageManager": "yarn@4.13.0", + "scripts": { + "twenty": "twenty", + "lint": "oxlint -c .oxlintrc.json .", + "lint:fix": "oxlint --fix -c .oxlintrc.json .", + "typecheck": "tsgo --noEmit -p tsconfig.spec.json", + "test": "vitest run", + "test:watch": "vitest", + "test:unit": "vitest run --config vitest.unit.config.ts" + }, + "devDependencies": { + "@types/node": "^24.7.2", + "@types/react": "^18.2.0", + "@typescript/native-preview": "^7.0.0-dev.20260116.1", + "oxlint": "^0.16.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "twenty-client-sdk": "^2.16.0", + "twenty-sdk": "^2.16.0", + "typescript": "^5.9.3", + "vite-tsconfig-paths": "^4.2.1", + "vitest": "^4.0.0" + }, + "dependencies": { + "marked": "^18.0.5", + "pdf-lib": "^1.17.1" + } +} diff --git a/packages/twenty-apps/examples/document-generator/public/document-generator.svg b/packages/twenty-apps/examples/document-generator/public/document-generator.svg new file mode 100644 index 0000000000..d653615696 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/public/document-generator.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/twenty-apps/examples/document-generator/public/gallery/01-template-editor.png b/packages/twenty-apps/examples/document-generator/public/gallery/01-template-editor.png new file mode 100644 index 0000000000..1301140d49 Binary files /dev/null and b/packages/twenty-apps/examples/document-generator/public/gallery/01-template-editor.png differ diff --git a/packages/twenty-apps/examples/document-generator/public/gallery/02-command-menu.png b/packages/twenty-apps/examples/document-generator/public/gallery/02-command-menu.png new file mode 100644 index 0000000000..7ddd2fe986 Binary files /dev/null and b/packages/twenty-apps/examples/document-generator/public/gallery/02-command-menu.png differ diff --git a/packages/twenty-apps/examples/document-generator/public/gallery/03-documents.png b/packages/twenty-apps/examples/document-generator/public/gallery/03-documents.png new file mode 100644 index 0000000000..d6457672f4 Binary files /dev/null and b/packages/twenty-apps/examples/document-generator/public/gallery/03-documents.png differ diff --git a/packages/twenty-apps/examples/document-generator/public/gallery/04-generated-document.png b/packages/twenty-apps/examples/document-generator/public/gallery/04-generated-document.png new file mode 100644 index 0000000000..4a05a7948d Binary files /dev/null and b/packages/twenty-apps/examples/document-generator/public/gallery/04-generated-document.png differ diff --git a/packages/twenty-apps/examples/document-generator/src/__tests__/document-generator.integration-test.ts b/packages/twenty-apps/examples/document-generator/src/__tests__/document-generator.integration-test.ts new file mode 100644 index 0000000000..b6ea83f6d3 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/__tests__/document-generator.integration-test.ts @@ -0,0 +1,27 @@ +import { MetadataApiClient } from 'twenty-client-sdk/metadata'; +import { describe, expect, it } from 'vitest'; + +import { APPLICATION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +// The app is synced once in src/__tests__/global-setup.ts and uninstalled on +// teardown, so this test just asserts the install succeeded. +describe('Document Generator installation', () => { + it('should install the application onto the workspace', async () => { + const metadataClient = new MetadataApiClient(); + + const result = await metadataClient.query({ + findManyApplications: { + id: true, + name: true, + universalIdentifier: true, + }, + }); + + const installedApp = result.findManyApplications.find( + (application: { universalIdentifier: string }) => + application.universalIdentifier === APPLICATION_UNIVERSAL_IDENTIFIER, + ); + + expect(installedApp).toBeDefined(); + }); +}); diff --git a/packages/twenty-apps/examples/document-generator/src/__tests__/global-setup.ts b/packages/twenty-apps/examples/document-generator/src/__tests__/global-setup.ts new file mode 100644 index 0000000000..e0f8c23b05 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/__tests__/global-setup.ts @@ -0,0 +1,87 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { appDevOnce, appUninstall } from 'twenty-sdk/cli'; + +const APP_PATH = process.cwd(); +const CONFIG_DIR = path.join(os.homedir(), '.twenty'); + +function validateEnv(): { apiUrl: string; apiKey: string } { + const apiUrl = process.env.TWENTY_API_URL; + const apiKey = process.env.TWENTY_API_KEY; + + if (!apiUrl || !apiKey) { + throw new Error( + 'TWENTY_API_URL and TWENTY_API_KEY must be set.\n' + + 'Start a local server: yarn twenty docker:start\n' + + 'Or set them in vitest env config.', + ); + } + + return { apiUrl, apiKey }; +} + +async function checkServer(apiUrl: string) { + let response: Response; + + try { + response = await fetch(`${apiUrl}/healthz`); + } catch { + throw new Error( + `Twenty server is not reachable at ${apiUrl}. ` + + 'Make sure the server is running before executing integration tests.', + ); + } + + if (!response.ok) { + throw new Error(`Server at ${apiUrl} returned ${response.status}`); + } +} + +function writeConfig(apiUrl: string, apiKey: string) { + const payload = JSON.stringify( + { + remotes: { + local: { apiUrl, apiKey, accessToken: apiKey }, + }, + defaultRemote: 'local', + }, + null, + 2, + ); + + fs.mkdirSync(CONFIG_DIR, { recursive: true }); + fs.writeFileSync(path.join(CONFIG_DIR, 'config.test.json'), payload); +} + +export async function setup() { + const { apiUrl, apiKey } = validateEnv(); + + await checkServer(apiUrl); + + writeConfig(apiUrl, apiKey); + + await appUninstall({ appPath: APP_PATH }).catch(() => {}); + + const result = await appDevOnce({ + appPath: APP_PATH, + onProgress: (message: string) => console.log(`[dev] ${message}`), + }); + + if (!result.success) { + throw new Error( + `Dev sync failed: ${result.error?.message ?? 'Unknown error'}`, + ); + } +} + +export async function teardown() { + const uninstallResult = await appUninstall({ appPath: APP_PATH }); + + if (!uninstallResult.success) { + console.warn( + `App uninstall failed: ${uninstallResult.error?.message ?? 'Unknown error'}`, + ); + } +} diff --git a/packages/twenty-apps/examples/document-generator/src/agents/document-assistant.agent.ts b/packages/twenty-apps/examples/document-generator/src/agents/document-assistant.agent.ts new file mode 100644 index 0000000000..d7c4c0d1dd --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/agents/document-assistant.agent.ts @@ -0,0 +1,18 @@ +import { defineAgent } from 'twenty-sdk/define'; + +import { DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export default defineAgent({ + universalIdentifier: DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER, + name: 'document-assistant', + label: 'Document Assistant', + description: 'Generates documents from your templates and CRM records.', + icon: 'IconFileText', + responseFormat: { type: 'text' }, + prompt: [ + 'You are the Document Assistant for a CRM.', + 'You help users generate personalized documents (proposals, letters, contracts)', + 'from reusable templates and the data already in their CRM.', + 'Use the generate-document tool to produce documents, and always confirm what you created.', + ].join(' '), +}); diff --git a/packages/twenty-apps/examples/document-generator/src/application-config.ts b/packages/twenty-apps/examples/document-generator/src/application-config.ts new file mode 100644 index 0000000000..15f734cf7c --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/application-config.ts @@ -0,0 +1,29 @@ +import { defineApplication } from 'twenty-sdk/define'; + +import { + APP_DESCRIPTION, + APP_DISPLAY_NAME, + APPLICATION_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +// The default role is declared with defineApplicationRole() in +// src/roles/default-role.ts and picked up automatically. +export default defineApplication({ + universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + displayName: APP_DISPLAY_NAME, + description: APP_DESCRIPTION, + logoUrl: 'public/document-generator.svg', + screenshots: [ + 'public/gallery/01-template-editor.png', + 'public/gallery/02-command-menu.png', + 'public/gallery/03-documents.png', + 'public/gallery/04-generated-document.png', + ], + author: 'Twenty', + category: 'Productivity', + websiteUrl: + 'https://docs.twenty.com/developers/extend/apps/tutorials/document-generator/overview', + termsUrl: 'https://www.twenty.com/terms', + emailSupport: 'contact@twenty.com', + issueReportUrl: 'https://github.com/twentyhq/twenty/issues', +}); diff --git a/packages/twenty-apps/examples/document-generator/src/command-menu-items/generate-document.command-menu-item.ts b/packages/twenty-apps/examples/document-generator/src/command-menu-items/generate-document.command-menu-item.ts new file mode 100644 index 0000000000..b66d3fc38b --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/command-menu-items/generate-document.command-menu-item.ts @@ -0,0 +1,23 @@ +import { + defineCommandMenuItem, + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS, +} from 'twenty-sdk/define'; + +import { + GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER, + GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +// Shows up in the command menu when a Person record is selected, opening the +// "Generate document" form in the side panel. +export default defineCommandMenuItem({ + universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER, + label: 'Generate document', + shortLabel: 'Generate', + isPinned: false, + availabilityType: 'RECORD_SELECTION', + availabilityObjectUniversalIdentifier: + STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier, + frontComponentUniversalIdentifier: + GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/examples/document-generator/src/constants/universal-identifiers.ts b/packages/twenty-apps/examples/document-generator/src/constants/universal-identifiers.ts new file mode 100644 index 0000000000..944eec7d83 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/constants/universal-identifiers.ts @@ -0,0 +1,126 @@ +// Central registry of the app's universal identifiers. +// Every entity in a Twenty app carries a stable UUID (its `universalIdentifier`). +// Keeping them in one file makes cross-references (relations, views, layouts) +// easy to follow and guarantees they stay stable across syncs and versions. + +export const APP_DISPLAY_NAME = 'Document Generator'; +export const APP_DESCRIPTION = + 'Create reusable document templates and generate personalized documents from your CRM records.'; + +export const APPLICATION_UNIVERSAL_IDENTIFIER = + '10812367-41ef-42a3-814b-ee12d69bb88c'; +export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER = + '9f13095f-7448-415e-bd85-f4e65d684fb0'; + +// Document template object + fields +export const DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER = + '79a17fab-7846-401e-b5b0-968d30a9e8da'; +export const TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER = + '5964460e-8b27-4e24-9285-21837d63c51c'; +export const TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER = + 'e17cbc62-b6e0-4606-adeb-029d25c33b60'; +export const TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER = + '29a89aec-4f75-4e5a-aee7-f23ab6a91b1c'; +export const TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER = + '21cb2f05-d35e-41f8-95ab-470f55e6eaa4'; +export const TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER = + 'dbea00f7-66cd-43d5-995e-d7731a761a72'; +export const TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER = + 'dddf334c-7e0c-4282-851d-d4447556e467'; + +// Document object + fields +export const DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER = + '052930e6-57bb-4ff5-b996-04a27e6d36fc'; +export const DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER = + 'be732f60-c301-4202-a45b-ecbc4dad36eb'; +export const DOCUMENT_CONTENT_FIELD_UNIVERSAL_IDENTIFIER = + '602d7dfd-a2e1-48c0-8f20-257fdb5c69ab'; +export const DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER = + '93b3ee71-f8a1-4eb5-8c6e-842b95f5db04'; +export const DOCUMENT_STATUS_OPTION_DRAFT_UNIVERSAL_IDENTIFIER = + '6724f4a5-ee81-4c81-8fe3-06a3ffd075db'; +export const DOCUMENT_STATUS_OPTION_GENERATED_UNIVERSAL_IDENTIFIER = + 'e11ae1bd-834d-438e-a26d-a74026250619'; +export const DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER = + 'b6da07ba-60d4-426b-b28f-17020e94d777'; +export const DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER = + '60168c70-b842-4d14-a73f-120ac05e1576'; + +// Logic functions +export const GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER = + 'e2f80832-07a4-408f-b9d4-55f47e188516'; +export const GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER = + 'c88bf870-df0c-4b27-96b9-290b1b257cf6'; +export const VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER = + 'b7d2eff6-d150-4f49-b0e6-9a1952a5928b'; + +// Front component + command +export const GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = + 'd6a82a87-f44b-4a12-9ea6-6da91b32f023'; +export const GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER = + 'e98f0501-68ee-4fd4-9af7-c741026189fe'; + +// Views + view fields +export const TEMPLATES_VIEW_UNIVERSAL_IDENTIFIER = + 'b7fa76ab-0e68-4792-9ef1-597d5644fcbf'; +export const TEMPLATES_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER = + 'c42b9ad3-d018-4a6f-979d-1d764937ddbe'; +export const TEMPLATES_VIEW_TARGET_FIELD_UNIVERSAL_IDENTIFIER = + 'e09e0f92-912d-4239-a64f-f26c0282e725'; +export const DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER = + '4373afdc-0cc8-4875-bd00-f444b4eb7601'; +export const DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER = + '929a17d1-6328-4506-90b0-8cb8fd5113d3'; +export const DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER = + 'b0e995a3-2778-4f59-b0e0-bbcdb762e508'; +export const DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER = + '18c8a6df-4d34-40b4-a35c-ee246a62fa1d'; + +// Navigation menu items +export const TEMPLATES_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER = + '3f771e0b-389a-4d6a-9cca-0cd5cc80f794'; +export const DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER = + '3249fdb2-8edb-41ac-b3ae-510fe4d0a099'; + +// Document viewer front component + the document and template record page layouts +// (the template body is edited with Twenty's native RICH_TEXT editor) +export const DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER = + '2c95f42a-cc54-4016-b26a-4d044ce15ae1'; +export const DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = + '5299b588-96f4-453f-a8b4-796b3f57090b'; +export const DOCUMENT_FIELDS_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = + '1b3d2f4a-9c6e-4b2a-8f1d-7e5c0a9b4d31'; +export const DOCUMENT_FIELDS_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER = + '2c4e3a5b-0d7f-4c3b-9a2e-8f6d1b0c5e42'; +export const DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = + 'fc275829-b201-4e44-b1e8-22c55daed5b4'; +export const DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER = + 'fdefbf77-6bb4-450a-a40c-de1168193444'; +export const TEMPLATE_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER = + 'c87b5d7c-419f-4f53-b08a-5a0f975d4415'; +export const TEMPLATE_FIELDS_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = + '3d5f4b6c-1e8a-4d4c-ab3f-9a7e2c1d6f53'; +export const TEMPLATE_FIELDS_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER = + '4e6a5c7d-2f9b-4e5d-bc4a-0b8f3d2e7a64'; +export const TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = + '2a8cdc7c-f5f8-47d5-aa29-0630c9f43430'; +export const TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER = + 'ba980ce0-a230-49cd-951a-9981e72af9c4'; +export const TIMELINE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER = + 'a518d6e2-6079-4d10-85e3-c81ba2c0a0d3'; +export const TIMELINE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER = + '065002ea-b44e-49e4-8dda-a1591d6f010b'; + +// Agent + skill +export const DOCUMENT_AGENT_UNIVERSAL_IDENTIFIER = + '08eaaa8c-2f70-45e6-bfd1-403d5c24850e'; +export const DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER = + 'b2e2d896-d76d-4012-b2f3-7bb1809a450c'; + +// Select option values must be UPPER_CASE. Centralized so the object metadata +// and the handler that writes them can never drift apart. +export const TEMPLATE_TARGET_PERSON = 'PERSON'; +export const TEMPLATE_TARGET_COMPANY = 'COMPANY'; + +export const DOCUMENT_STATUS_DRAFT = 'DRAFT'; +export const DOCUMENT_STATUS_GENERATED = 'GENERATED'; diff --git a/packages/twenty-apps/examples/document-generator/src/fields/document-template-relation.field.ts b/packages/twenty-apps/examples/document-generator/src/fields/document-template-relation.field.ts new file mode 100644 index 0000000000..44c5175806 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/fields/document-template-relation.field.ts @@ -0,0 +1,34 @@ +import { + defineField, + FieldType, + OnDeleteAction, + RelationType, +} from 'twenty-sdk/define'; + +import { + DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER, + DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER, + DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER, + TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +// The "many" side: each document points to the template it was generated from. +export default defineField({ + universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'template', + label: 'Template', + description: 'The template this document was generated from.', + icon: 'IconFileText', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.MANY_TO_ONE, + onDelete: OnDeleteAction.SET_NULL, + joinColumnName: 'templateId', + }, +}); diff --git a/packages/twenty-apps/examples/document-generator/src/fields/template-documents-relation.field.ts b/packages/twenty-apps/examples/document-generator/src/fields/template-documents-relation.field.ts new file mode 100644 index 0000000000..1781bdc42a --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/fields/template-documents-relation.field.ts @@ -0,0 +1,27 @@ +import { defineField, FieldType, RelationType } from 'twenty-sdk/define'; + +import { + DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER, + DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER, + DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER, + TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +// The "one" side: a template lists every document generated from it. +export default defineField({ + universalIdentifier: TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER, + objectUniversalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER, + type: FieldType.RELATION, + name: 'documents', + label: 'Documents', + description: 'Documents generated from this template.', + icon: 'IconFile', + isNullable: true, + relationTargetObjectMetadataUniversalIdentifier: + DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER, + relationTargetFieldMetadataUniversalIdentifier: + DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER, + universalSettings: { + relationType: RelationType.ONE_TO_MANY, + }, +}); diff --git a/packages/twenty-apps/examples/document-generator/src/front-components/document-viewer.front-component.tsx b/packages/twenty-apps/examples/document-generator/src/front-components/document-viewer.front-component.tsx new file mode 100644 index 0000000000..85285b7aac --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/front-components/document-viewer.front-component.tsx @@ -0,0 +1,124 @@ +import { type CSSProperties, useEffect, useState } from 'react'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component'; + +import { DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { Markdown } from 'src/utils/markdown-to-react'; + +const useCurrentRecordId = (): string | null => + useFrontComponentExecutionContext((context) => + context.recordId ?? + (context.selectedRecordIds.length === 1 + ? context.selectedRecordIds[0] + : null), + ); + +const styles: Record = { + scroll: { height: '100%', overflow: 'auto', background: '#eef1f6', padding: '24px' }, + paper: { + maxWidth: '720px', + margin: '0 auto', + background: '#ffffff', + borderRadius: '10px', + boxShadow: '0 10px 40px rgba(24, 39, 75, 0.08)', + overflow: 'hidden', + fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif", + }, + body: { padding: '48px 56px 56px' }, + empty: { padding: '40px', textAlign: 'center', color: '#6b7280', fontFamily: 'sans-serif' }, + actions: { + maxWidth: '720px', + margin: '0 auto 16px', + display: 'flex', + gap: '10px', + justifyContent: 'flex-end', + fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif", + }, + actionLink: { + fontSize: '13px', + fontWeight: 500, + color: '#1961ed', + background: '#ffffff', + border: '1px solid #d7deee', + borderRadius: '6px', + padding: '6px 12px', + textDecoration: 'none', + }, +}; + +type LoadedDocument = { name: string; content: string; pdfUrl?: string }; + +const DocumentViewer = () => { + const recordId = useCurrentRecordId(); + const [document, setDocument] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!recordId) { + setLoading(false); + return; + } + // Reset while the newly-selected document loads, so the previous one isn't + // shown against the new record. + setLoading(true); + setDocument(null); + let cancelled = false; + const load = async () => { + const { documents } = await new CoreApiClient().query({ + documents: { + __args: { filter: { id: { eq: recordId } }, first: 1 }, + edges: { + node: { id: true, name: true, content: true, file: { url: true } }, + }, + }, + }); + if (cancelled) return; + const node = documents?.edges?.[0]?.node; + setDocument({ + name: node?.name ?? 'Document', + content: node?.content ?? '', + pdfUrl: node?.file?.[0]?.url ?? undefined, + }); + }; + load().finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [recordId]); + + if (loading || !document) { + return
{loading ? 'Loading…' : 'Open a document to preview it here.'}
; + } + + const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`; + + return ( +
+
+ + Open web page + + {document.pdfUrl ? ( + + Download PDF + + ) : null} +
+
+
+ +
+
+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + name: 'document-viewer', + description: 'Renders a generated document as a styled, printable preview.', + component: DocumentViewer, +}); diff --git a/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx b/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx new file mode 100644 index 0000000000..541b5086eb --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx @@ -0,0 +1,353 @@ +import { + type CSSProperties, + type SyntheticEvent, + useCallback, + useEffect, + useState, +} from 'react'; +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineFrontComponent } from 'twenty-sdk/define'; +import { + closeSidePanel, + enqueueSnackbar, + unmountFrontComponent, + useSelectedRecordIds, +} from 'twenty-sdk/front-component'; + +import { GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +// Theme tokens are inlined as CSS-variable values: the SDK mocks the UI package +// during manifest extraction, so importing them at module level would be undefined. +const theme = { + spacing2: 'var(--t-spacing-2)', + spacing3: 'var(--t-spacing-3)', + spacing4: 'var(--t-spacing-4)', + spacing8: 'var(--t-spacing-8)', + bgPrimary: 'var(--t-background-primary)', + bgSecondary: 'var(--t-background-secondary)', + borderMedium: 'var(--t-border-color-medium)', + borderLight: 'var(--t-border-color-light)', + radiusSm: 'var(--t-border-radius-sm)', + fontPrimary: 'var(--t-font-color-primary)', + fontSecondary: 'var(--t-font-color-secondary)', + fontTertiary: 'var(--t-font-color-tertiary)', + fontInverted: 'var(--t-font-color-inverted)', + fontFamily: 'var(--t-font-family)', + sizeXs: 'var(--t-font-size-xs)', + sizeSm: 'var(--t-font-size-sm)', + sizeMd: 'var(--t-font-size-md)', + weightMedium: 'var(--t-font-weight-medium)', + weightSemiBold: 'var(--t-font-weight-semi-bold)', + blue: 'var(--t-color-blue)', + accent: 'var(--t-accent-accent4060)', +}; + +type Template = { id: string; name: string }; + +type GenerateResponse = { + success: boolean; + message?: string; + documentId?: string; + missingTokens?: string[]; + error?: string; +}; + +const readValue = (event: SyntheticEvent): string | undefined => { + const object = event as { + detail?: { value?: string }; + target?: { value?: string }; + }; + + return object.detail?.value ?? object.target?.value; +}; + +const callAppRoute = async ( + path: string, + method: 'GET' | 'POST', + body?: Record, +): Promise => { + 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('App is missing API URL or access token configuration.'); + } + + const response = await fetch(`${apiBaseUrl}/s${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + + if (!response.ok) { + const errorBody = (await response.json().catch(() => null)) as { + message?: string; + } | null; + + throw new Error( + errorBody?.message ?? `Request failed with status ${response.status}.`, + ); + } + + return response.json() as Promise; +}; + +const styles: Record = { + container: { + fontFamily: theme.fontFamily, + fontSize: theme.sizeSm, + color: theme.fontPrimary, + background: theme.bgPrimary, + display: 'flex', + flexDirection: 'column', + height: '100%', + boxSizing: 'border-box', + }, + header: { + padding: theme.spacing4, + borderBottom: `1px solid ${theme.borderLight}`, + }, + title: { + fontSize: theme.sizeMd, + fontWeight: theme.weightSemiBold, + margin: 0, + }, + subtitle: { + fontSize: theme.sizeSm, + color: theme.fontTertiary, + margin: `${theme.spacing2} 0 0`, + }, + body: { + flex: 1, + padding: theme.spacing4, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing2, + }, + label: { + fontSize: theme.sizeXs, + fontWeight: theme.weightMedium, + color: theme.fontSecondary, + }, + select: { + appearance: 'none', + WebkitAppearance: 'none', + background: theme.bgSecondary, + border: `1px solid ${theme.borderMedium}`, + borderRadius: theme.radiusSm, + padding: `${theme.spacing2} ${theme.spacing3}`, + color: theme.fontPrimary, + fontSize: theme.sizeSm, + fontFamily: theme.fontFamily, + height: theme.spacing8, + width: '100%', + boxSizing: 'border-box', + cursor: 'pointer', + }, + footer: { + display: 'flex', + justifyContent: 'flex-end', + gap: theme.spacing2, + padding: theme.spacing3, + borderTop: `1px solid ${theme.borderLight}`, + }, + button: { + height: theme.spacing8, + padding: `0 ${theme.spacing3}`, + borderRadius: theme.radiusSm, + fontSize: theme.sizeSm, + fontFamily: theme.fontFamily, + fontWeight: theme.weightMedium, + cursor: 'pointer', + border: '1px solid transparent', + }, + secondary: { + background: theme.bgSecondary, + color: theme.fontSecondary, + border: `1px solid ${theme.borderMedium}`, + }, + primary: { background: theme.blue, color: theme.fontInverted }, + primaryDisabled: { background: theme.accent, cursor: 'not-allowed' }, + helper: { fontSize: theme.sizeXs, color: theme.fontTertiary }, +}; + +const GenerateDocumentForm = () => { + const selectedRecordIds = useSelectedRecordIds(); + const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null; + + const [templates, setTemplates] = useState([]); + const [templateId, setTemplateId] = useState(''); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + + const loadTemplates = useCallback(async () => { + setLoading(true); + + try { + const client = new CoreApiClient(); + const { documentTemplates } = await client.query({ + documentTemplates: { + __args: { filter: { target: { eq: 'PERSON' } }, first: 100 }, + edges: { node: { id: true, name: true } }, + }, + }); + + const list: Template[] = + documentTemplates?.edges?.map( + (edge: { node: { id: string; name?: string | null } }) => ({ + id: edge.node.id, + name: edge.node.name ?? 'Untitled template', + }), + ) ?? []; + + setTemplates(list); + + if (list.length > 0) { + setTemplateId(list[0].id); + } + } catch { + await enqueueSnackbar({ + message: 'Failed to load templates.', + variant: 'error', + }); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadTemplates(); + }, [loadTemplates]); + + const handleClose = () => { + unmountFrontComponent(); + closeSidePanel(); + }; + + const handleGenerate = async () => { + if (!templateId || !recordId) { + return; + } + + setSubmitting(true); + + try { + const result = await callAppRoute( + '/documents/generate', + 'POST', + { templateId, recordId }, + ); + + if (!result.success) { + await enqueueSnackbar({ + message: result.message ?? result.error ?? 'Generation failed.', + variant: 'error', + }); + + return; + } + + const missing = result.missingTokens?.length + ? ` (${result.missingTokens.length} placeholder(s) had no value)` + : ''; + + await enqueueSnackbar({ + message: `Document generated${missing}. Find it in the Documents view.`, + variant: 'success', + }); + + handleClose(); + } catch (error) { + await enqueueSnackbar({ + message: + error instanceof Error ? error.message : 'Generation failed.', + variant: 'error', + }); + } finally { + setSubmitting(false); + } + }; + + const canSubmit = + templateId !== '' && recordId !== null && !submitting && !loading; + + return ( +
+
+

Generate document

+

+ Pick a template; it will be filled with this person's data. +

+
+ +
+ + + {recordId === null && ( + + Select a single person to generate a document. + + )} +
+ +
+ + +
+
+ ); +}; + +export default defineFrontComponent({ + universalIdentifier: + GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + name: 'generate-document-form', + description: 'Form to generate a document from a template for a person.', + component: GenerateDocumentForm, +}); diff --git a/packages/twenty-apps/examples/document-generator/src/logic-functions/generate-document-route.ts b/packages/twenty-apps/examples/document-generator/src/logic-functions/generate-document-route.ts new file mode 100644 index 0000000000..dd31ddef0e --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/logic-functions/generate-document-route.ts @@ -0,0 +1,35 @@ +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { Response } from 'twenty-sdk/logic-function'; + +import { GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler'; + +// HTTP entry point used by the "Generate document" front component. The shared +// handler returns a suggested HTTP status on failure (400/404/500); map it onto +// the response so callers get proper status codes instead of a 200 with an error. +const handler = async (event: RoutePayload): Promise => { + const body = event.body as Record | null; + + const result = await generateDocumentHandler({ + templateId: (body?.templateId as string | undefined) ?? '', + recordId: (body?.recordId as string | undefined) ?? '', + }); + + return new Response(JSON.stringify(result), { + status: result.success ? 200 : (result.status ?? 400), + headers: { 'Content-Type': 'application/json' }, + }); +}; + +export default defineLogicFunction({ + universalIdentifier: GENERATE_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER, + name: 'generate-document-route', + description: 'HTTP endpoint that generates a document for the given record.', + timeoutSeconds: 30, + handler, + httpRouteTriggerSettings: { + path: '/documents/generate', + httpMethod: 'POST', + isAuthRequired: true, + }, +}); diff --git a/packages/twenty-apps/examples/document-generator/src/logic-functions/generate-document.ts b/packages/twenty-apps/examples/document-generator/src/logic-functions/generate-document.ts new file mode 100644 index 0000000000..dd8dda48dc --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/logic-functions/generate-document.ts @@ -0,0 +1,38 @@ +import { defineLogicFunction } from 'twenty-sdk/define'; +import { jsonSchemaToInputSchema } from 'twenty-sdk/logic-function'; + +import { GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler'; +import { generateDocumentInputSchema } from 'src/logic-functions/schemas/generate-document-input.schema'; + +// Same function, exposed two ways: +// - as an AI tool, so agents can call it, +// - as a workflow action, so it can be dropped into the visual workflow builder. +export default defineLogicFunction({ + universalIdentifier: GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER, + name: 'generate-document', + description: + 'Generate a document from a template and a CRM record, filling the template placeholders with the record data.', + timeoutSeconds: 30, + toolTriggerSettings: { + inputSchema: generateDocumentInputSchema, + }, + workflowActionTriggerSettings: { + label: 'Generate Document', + icon: 'IconFileText', + inputSchema: jsonSchemaToInputSchema(generateDocumentInputSchema), + outputSchema: [ + { + type: 'object', + properties: { + success: { type: 'boolean' }, + message: { type: 'string' }, + documentId: { type: 'string' }, + content: { type: 'string' }, + missingTokens: { type: 'array', items: { type: 'string' } }, + }, + }, + ], + }, + handler: generateDocumentHandler, +}); diff --git a/packages/twenty-apps/examples/document-generator/src/logic-functions/handlers/generate-document-handler.ts b/packages/twenty-apps/examples/document-generator/src/logic-functions/handlers/generate-document-handler.ts new file mode 100644 index 0000000000..e29dbe4a96 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/logic-functions/handlers/generate-document-handler.ts @@ -0,0 +1,198 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { MetadataApiClient } from 'twenty-client-sdk/metadata'; + +import { + DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER, + DOCUMENT_STATUS_GENERATED, +} from 'src/constants/universal-identifiers'; +import { generateDocumentPdf } from 'src/logic-functions/utils/generate-document-pdf'; +import { + isSupportedTarget, + loadRecordValues, +} from 'src/logic-functions/utils/load-record-values'; +import { renderTemplate } from 'src/logic-functions/utils/render-template'; + +// Builds a filesystem-safe PDF filename from the document name. +const toPdfFileName = (documentName: string): string => { + const slug = documentName + .normalize('NFKD') + .replace(/[^a-z0-9]+/gi, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); + + return `${slug || 'document'}.pdf`; +}; + +// Generates the PDF, uploads it to the app-owned `file` field, and stores the +// reference on the document so the record shows a downloadable PDF. +const attachGeneratedPdf = async ( + client: CoreApiClient, + documentId: string, + documentName: string, + content: string, +): Promise => { + const bytes = await generateDocumentPdf(content); + const fileName = toPdfFileName(documentName); + + const uploaded = await new MetadataApiClient().uploadFile( + Buffer.from(bytes), + fileName, + 'application/pdf', + DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER, + ); + + await client.mutation({ + updateDocument: { + __args: { + id: documentId, + data: { file: [{ fileId: uploaded.id, label: fileName }] }, + }, + id: true, + }, + }); +}; + +export type GenerateDocumentInput = { + templateId: string; + recordId: string; +}; + +export type GenerateDocumentResult = { + success: boolean; + message: string; + // Suggested HTTP status for the route trigger. Ignored by the tool and + // workflow-action triggers, which only care about `success`. + status?: number; + documentId?: string; + content?: string; + missingTokens?: string[]; +}; + +// Shared business logic behind every trigger (AI tool, workflow action, HTTP +// route). It loads a template + a CRM record, fills the placeholders, and +// stores the result as a `document` record. +export const generateDocumentHandler = async ( + input: GenerateDocumentInput, +): Promise => { + const { templateId, recordId } = input; + + if (!templateId || !recordId) { + return { + success: false, + status: 400, + message: 'Both templateId and recordId are required.', + }; + } + + const client = new CoreApiClient(); + + // Use a filtered list query rather than the singular lookup: the singular + // query throws when no record matches, which would surface as a 500 instead + // of our intended 404. + const { documentTemplates } = await client.query({ + documentTemplates: { + __args: { filter: { id: { eq: templateId } }, first: 1 }, + edges: { + // `body` is a RICH_TEXT field: request its Markdown projection. The + // generated client only types the composite after + // `twenty dev:generate-client` is re-run against a remote that has this + // field; until then the cast bridges the lag (drop it after regen). + node: { + id: true, + name: true, + target: true, + body: { markdown: true } as unknown as true, + }, + }, + }, + }); + + const documentTemplate = documentTemplates?.edges?.[0]?.node; + + if (!documentTemplate?.id) { + return { + success: false, + status: 404, + message: `No document template found with id ${templateId}.`, + }; + } + + const target = documentTemplate.target ?? ''; + + if (!isSupportedTarget(target)) { + return { + success: false, + status: 400, + message: `Template target "${target}" is not supported.`, + }; + } + + const record = await loadRecordValues(client, target, recordId); + + if (!record.found) { + return { + success: false, + status: 404, + message: `No ${target} found with id ${recordId}.`, + }; + } + + // RICH_TEXT stores { blocknote, markdown }; the Markdown projection feeds the + // existing placeholder + PDF/HTML pipeline unchanged. + const bodyMarkdown = + (documentTemplate.body as unknown as { markdown: string | null } | null) + ?.markdown ?? ''; + + const { content, missingTokens } = renderTemplate(bodyMarkdown, record.values); + + const documentName = `${documentTemplate.name ?? 'Document'} — ${record.displayName}`; + + const { createDocument } = await client.mutation({ + createDocument: { + __args: { + data: { + name: documentName, + content, + status: DOCUMENT_STATUS_GENERATED, + templateId: documentTemplate.id, + }, + }, + id: true, + name: true, + }, + }); + + if (!createDocument?.id) { + return { + success: false, + status: 500, + message: 'Failed to create the document.', + }; + } + + // Best-effort: the document already exists, so a PDF/upload failure shouldn't + // discard it — surface a warning instead. + let message = `Generated "${createDocument.name}".`; + + try { + await attachGeneratedPdf( + client, + createDocument.id, + createDocument.name ?? documentName, + content, + ); + } catch (error) { + // Log the real cause server-side, but keep the caller-facing message + // generic — this handler is exposed via HTTP, AI tool, and workflow action. + console.warn('[document-generator] PDF attachment failed:', error); + message += ' (PDF file could not be attached)'; + } + + return { + success: true, + message, + documentId: createDocument.id, + content, + missingTokens, + }; +}; diff --git a/packages/twenty-apps/examples/document-generator/src/logic-functions/schemas/generate-document-input.schema.ts b/packages/twenty-apps/examples/document-generator/src/logic-functions/schemas/generate-document-input.schema.ts new file mode 100644 index 0000000000..4de7716be5 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/logic-functions/schemas/generate-document-input.schema.ts @@ -0,0 +1,20 @@ +import { type InputJsonSchema } from 'twenty-sdk/logic-function'; + +export const generateDocumentInputSchema: InputJsonSchema = { + type: 'object', + properties: { + templateId: { + type: 'string', + label: 'Document template', + description: 'The id of the document template to render.', + }, + recordId: { + type: 'string', + label: 'Record', + description: + 'The id of the Person or Company whose data fills the template placeholders.', + }, + }, + required: ['templateId', 'recordId'], + additionalProperties: false, +}; diff --git a/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/__tests__/generate-document-pdf.test.ts b/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/__tests__/generate-document-pdf.test.ts new file mode 100644 index 0000000000..d0682f2d02 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/__tests__/generate-document-pdf.test.ts @@ -0,0 +1,43 @@ +import { PDFDocument } from 'pdf-lib'; +import { describe, expect, it } from 'vitest'; + +import { generateDocumentPdf } from '../generate-document-pdf'; + +const MARKDOWN = `## Introduction + +Dear **Jeffery Griffin**, here is our _proposal_. + +- First point +- Second point + +> A closing note.`; + +describe('generateDocumentPdf', () => { + it('should produce a valid PDF from markdown', async () => { + const bytes = await generateDocumentPdf(MARKDOWN); + + expect(bytes.byteLength).toBeGreaterThan(0); + expect(Buffer.from(bytes.slice(0, 5)).toString()).toBe('%PDF-'); + expect(Buffer.from(bytes).toString('latin1')).toContain('%%EOF'); + }); + + it('should paginate long content across multiple pages', async () => { + const long = Array.from({ length: 120 }, (_, i) => `Paragraph number ${i} with some text.`).join('\n\n'); + + const bytes = await generateDocumentPdf(long); + const loaded = await PDFDocument.load(bytes); + + expect(loaded.getPageCount()).toBeGreaterThan(1); + }); + + it('should render explicit line breaks and non-Latin characters without throwing', async () => { + // The body fonts use WinAnsi encoding: a naive sanitizer would either + // throw on unencodable characters or swallow the `\n` line breaks. + const content = 'Bonjour **José**,\nRendez-vous à 20€ le 5 — merci.\n\n世界 dropped gracefully.'; + + const bytes = await generateDocumentPdf(content); + + expect(bytes.byteLength).toBeGreaterThan(0); + expect(Buffer.from(bytes.slice(0, 5)).toString()).toBe('%PDF-'); + }); +}); diff --git a/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/__tests__/render-template.test.ts b/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/__tests__/render-template.test.ts new file mode 100644 index 0000000000..7e934483b2 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/__tests__/render-template.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { flattenRecord, renderTemplate } from '../render-template'; + +describe('flattenRecord', () => { + it('should flatten nested objects into dot paths', () => { + const result = flattenRecord({ + name: { firstName: 'Ada', lastName: 'Lovelace' }, + jobTitle: 'Engineer', + }); + + expect(result).toEqual({ + 'name.firstName': 'Ada', + 'name.lastName': 'Lovelace', + jobTitle: 'Engineer', + }); + }); + + it('should coerce nullish leaves to empty strings', () => { + const result = flattenRecord({ jobTitle: null, city: undefined }); + + expect(result).toEqual({ jobTitle: '', city: '' }); + }); + + it('should stringify number and boolean leaves', () => { + const result = flattenRecord({ employees: 42, isActive: true }); + + expect(result).toEqual({ employees: '42', isActive: 'true' }); + }); +}); + +describe('renderTemplate', () => { + it('should replace placeholders with matching values', () => { + const result = renderTemplate('Hi {{name.firstName}}!', { + 'name.firstName': 'Ada', + }); + + expect(result.content).toBe('Hi Ada!'); + expect(result.missingTokens).toEqual([]); + }); + + it('should tolerate whitespace inside placeholders', () => { + const result = renderTemplate('Hi {{ name.firstName }}', { + 'name.firstName': 'Ada', + }); + + expect(result.content).toBe('Hi Ada'); + }); + + it('should render unknown placeholders as empty and report them', () => { + const result = renderTemplate('Hi {{firstName}} from {{city}}', { + firstName: 'Ada', + }); + + expect(result.content).toBe('Hi Ada from '); + expect(result.missingTokens).toEqual(['city']); + }); + + it('should not report the same missing token twice', () => { + const result = renderTemplate('{{x}} {{x}}', {}); + + expect(result.missingTokens).toEqual(['x']); + }); +}); diff --git a/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/generate-document-pdf.ts b/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/generate-document-pdf.ts new file mode 100644 index 0000000000..3ea1975e75 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/generate-document-pdf.ts @@ -0,0 +1,327 @@ +import { marked, type Token, type Tokens } from 'marked'; +import { + type PDFFont, + type PDFPage, + PDFDocument, + rgb, + StandardFonts, +} from 'pdf-lib'; + +// Renders a document (title + Markdown body) into a marketable, multi-page A4 +// PDF using pdf-lib: a coloured header band, real typography, bold/italic runs, +// headings and lists. + +const PAGE_WIDTH = 595.28; // A4 +const PAGE_HEIGHT = 841.89; +const MARGIN = 64; +const BODY_SIZE = 11; +const LINE_HEIGHT = 16; + +const ACCENT = rgb(0.098, 0.38, 0.929); // #1961ED +const INK = rgb(0.06, 0.08, 0.16); +const MUTED = rgb(0.28, 0.31, 0.42); + +type Fonts = { + regular: PDFFont; + bold: PDFFont; + italic: PDFFont; + boldItalic: PDFFont; + mono: PDFFont; +}; + +type Ctx = { + pdf: PDFDocument; + page: PDFPage; + y: number; + fonts: Fonts; +}; + +type Run = { text: string; bold: boolean; italic: boolean; code: boolean; link: boolean }; + +// The built-in fonts use WinAnsi encoding and throw on characters they can't +// encode. Map common punctuation and drop anything outside Latin-1 so the PDF +// never fails (HTML surfaces still render the full text). Non-Latin scripts +// (CJK, Arabic, Cyrillic) would need an embedded Unicode font. +const toWinAnsi = (text: string): string => + text + .replace(/[‐-―]/g, '-') + .replace(/[‘’]/g, "'") + .replace(/[“”]/g, '"') + .replace(/…/g, '...') + .replace(/[^ -~ -ÿ]/g, ''); + +const pickFont = (fonts: Fonts, run: Run): PDFFont => { + if (run.code) return fonts.mono; + if (run.bold && run.italic) return fonts.boldItalic; + if (run.bold) return fonts.bold; + if (run.italic) return fonts.italic; + return fonts.regular; +}; + +// Flatten marked inline tokens into styled runs. +const toRuns = ( + tokens: Token[] | undefined, + style: Omit, +): Run[] => { + if (!tokens) return []; + + return tokens.flatMap((token): Run[] => { + switch (token.type) { + case 'strong': + return toRuns((token as Tokens.Strong).tokens, { ...style, bold: true }); + case 'em': + return toRuns((token as Tokens.Em).tokens, { ...style, italic: true }); + case 'link': + return toRuns((token as Tokens.Link).tokens, { ...style, link: true }); + case 'codespan': + return [{ ...style, code: true, text: (token as Tokens.Codespan).text }]; + case 'br': + return [{ ...style, text: '\n' }]; + default: { + // List items (and other block wrappers) expose their inline formatting + // via nested `.tokens`; recurse so bold/italic inside them still render. + const nested = (token as Tokens.Text).tokens; + if (Array.isArray(nested) && nested.length > 0) { + return toRuns(nested, style); + } + const text = (token as Tokens.Text).text ?? (token as { raw?: string }).raw ?? ''; + return text ? [{ ...style, text }] : []; + } + } + }); +}; + +const EMPTY_STYLE = { bold: false, italic: false, code: false, link: false }; + +const newPage = (ctx: Ctx) => { + ctx.page = ctx.pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]); + ctx.y = PAGE_HEIGHT - MARGIN; +}; + +const ensureSpace = (ctx: Ctx, needed: number) => { + if (ctx.y - needed < MARGIN) newPage(ctx); +}; + +// Word-wraps styled runs across lines, switching font per run, and draws them. +const drawRuns = ( + ctx: Ctx, + runs: Run[], + options: { size: number; indent?: number; lineHeight?: number }, +) => { + const { size } = options; + const indent = options.indent ?? 0; + const lineHeight = options.lineHeight ?? LINE_HEIGHT; + const left = MARGIN + indent; + const maxRight = PAGE_WIDTH - MARGIN; + + type Word = { text: string; font: PDFFont; color: ReturnType }; + const words: (Word | 'break')[] = []; + const lineWidth = maxRight - left; + + // Break a token that is wider than a whole line into chunks that fit, so long + // URLs or identifiers wrap instead of overflowing the right margin. + const pushWord = (text: string, font: PDFFont, color: ReturnType) => { + if (text.length <= 1 || font.widthOfTextAtSize(text, size) <= lineWidth) { + words.push({ text, font, color }); + return; + } + let chunk = ''; + for (const char of text) { + if (chunk !== '' && font.widthOfTextAtSize(chunk + char, size) > lineWidth) { + words.push({ text: chunk, font, color }); + chunk = char; + } else { + chunk += char; + } + } + if (chunk !== '') words.push({ text: chunk, font, color }); + }; + + for (const run of runs) { + const font = pickFont(ctx.fonts, run); + const color = run.link ? ACCENT : run.code ? MUTED : INK; + // Split on newlines first: `toWinAnsi` drops the `\n`, so sanitizing before + // splitting would swallow explicit line breaks. + const segments = run.text.split('\n'); + + segments.forEach((segment, index) => { + if (index > 0) words.push('break'); + for (const word of toWinAnsi(segment).split(/(\s+)/)) { + if (word === '') continue; + if (/^\s+$/.test(word)) { + words.push({ text: word, font, color }); + } else { + pushWord(word, font, color); + } + } + }); + } + + ensureSpace(ctx, lineHeight); + let cursorX = left; + + const wrap = () => { + ctx.y -= lineHeight; + ensureSpace(ctx, 0); + if (ctx.y < MARGIN) newPage(ctx); + cursorX = left; + }; + + for (const word of words) { + if (word === 'break') { + wrap(); + continue; + } + const isSpace = /^\s+$/.test(word.text); + const width = word.font.widthOfTextAtSize(word.text, size); + + if (!isSpace && cursorX + width > maxRight && cursorX > left) { + wrap(); + } + if (isSpace && cursorX === left) continue; + + if (!isSpace) { + ctx.page.drawText(word.text, { + x: cursorX, + y: ctx.y, + size, + font: word.font, + color: word.color, + }); + } + cursorX += width; + } + + ctx.y -= lineHeight; +}; + +const drawBlocks = (ctx: Ctx, tokens: Token[], indent = 0) => { + for (const token of tokens) { + switch (token.type) { + case 'heading': { + const heading = token as Tokens.Heading; + const size = heading.depth === 1 ? 18 : heading.depth === 2 ? 15 : 13; + ctx.y -= 8; + drawRuns( + ctx, + toRuns(heading.tokens, { ...EMPTY_STYLE, bold: true }), + { size, indent, lineHeight: size + 6 }, + ); + break; + } + case 'paragraph': { + drawRuns(ctx, toRuns((token as Tokens.Paragraph).tokens, EMPTY_STYLE), { + size: BODY_SIZE, + indent, + }); + ctx.y -= 6; + break; + } + case 'list': { + const list = token as Tokens.List; + list.items.forEach((item, index) => { + const marker = list.ordered ? `${(list.start || 1) + index}.` : '•'; + ensureSpace(ctx, LINE_HEIGHT); + ctx.page.drawText(marker, { + x: MARGIN + indent + 8, + y: ctx.y, + size: BODY_SIZE, + font: ctx.fonts.regular, + color: MUTED, + }); + drawRuns(ctx, toRuns(item.tokens, EMPTY_STYLE), { + size: BODY_SIZE, + indent: indent + 28, + }); + }); + ctx.y -= 6; + break; + } + case 'blockquote': { + const startPage = ctx.page; + const startY = ctx.y; + drawBlocks(ctx, (token as Tokens.Blockquote).tokens, indent + 20); + const barX = MARGIN + indent; + + // Draw the accent bar on every page the quote occupies. On the first + // page it starts at `startY`; on later pages at the top margin. It ends + // at the final position on the last page, and at the bottom margin on + // earlier pages. Deriving each page's own coordinates avoids the + // cross-page/negative-height bug of reusing `startY` on a new page. + const pages = ctx.pdf.getPages(); + const startIndex = pages.indexOf(startPage); + const endIndex = pages.indexOf(ctx.page); + for (let index = startIndex; index <= endIndex; index += 1) { + const barTop = index === startIndex ? startY : PAGE_HEIGHT - MARGIN; + const barBottom = index === endIndex ? ctx.y : MARGIN; + if (barTop > barBottom) { + pages[index].drawRectangle({ + x: barX, + y: barBottom, + width: 3, + height: barTop - barBottom, + color: ACCENT, + }); + } + } + break; + } + case 'code': { + for (const line of (token as Tokens.Code).text.split('\n')) { + drawRuns(ctx, [{ ...EMPTY_STYLE, code: true, text: line || ' ' }], { + size: BODY_SIZE - 1, + indent: indent + 8, + lineHeight: 14, + }); + } + ctx.y -= 6; + break; + } + case 'hr': { + ensureSpace(ctx, 20); + ctx.y -= 8; + ctx.page.drawLine({ + start: { x: MARGIN, y: ctx.y }, + end: { x: PAGE_WIDTH - MARGIN, y: ctx.y }, + thickness: 1, + color: rgb(0.9, 0.92, 0.96), + }); + ctx.y -= 16; + break; + } + case 'space': + ctx.y -= 8; + break; + default: { + const text = (token as { text?: string }).text; + if (text) { + drawRuns(ctx, [{ ...EMPTY_STYLE, text }], { size: BODY_SIZE, indent }); + } + } + } + } +}; + +export const generateDocumentPdf = async ( + content: string, +): Promise => { + const pdf = await PDFDocument.create(); + const fonts: Fonts = { + regular: await pdf.embedFont(StandardFonts.Helvetica), + bold: await pdf.embedFont(StandardFonts.HelveticaBold), + italic: await pdf.embedFont(StandardFonts.HelveticaOblique), + boldItalic: await pdf.embedFont(StandardFonts.HelveticaBoldOblique), + mono: await pdf.embedFont(StandardFonts.Courier), + }; + + const ctx: Ctx = { pdf, page: pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]), y: 0, fonts }; + + // Render the template content only — no title header or footer. + ctx.y = PAGE_HEIGHT - MARGIN; + + // Match the HTML renderer (breaks: true) so a single newline in a template + // becomes a line break in the PDF too. + drawBlocks(ctx, marked.lexer(content, { breaks: true, gfm: true })); + + return pdf.save(); +}; diff --git a/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/load-record-values.ts b/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/load-record-values.ts new file mode 100644 index 0000000000..211a72d187 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/load-record-values.ts @@ -0,0 +1,92 @@ +import { type CoreApiClient } from 'twenty-client-sdk/core'; + +import { + TEMPLATE_TARGET_COMPANY, + TEMPLATE_TARGET_PERSON, +} from 'src/constants/universal-identifiers'; +import { flattenRecord } from 'src/logic-functions/utils/render-template'; + +export type LoadedRecord = { + found: boolean; + displayName: string; + values: Record; +}; + +// Loads a Person or Company by id and returns its fields flattened into the +// dot-path tokens that templates reference (e.g. `name.firstName`, `jobTitle`). +export const loadRecordValues = async ( + client: CoreApiClient, + target: string, + recordId: string, +): Promise => { + // Filtered list queries (not the singular lookup) so a missing record + // returns empty instead of throwing, letting the caller answer 404. + if (target === TEMPLATE_TARGET_COMPANY) { + const { companies } = await client.query({ + companies: { + __args: { filter: { id: { eq: recordId } }, first: 1 }, + edges: { + node: { + id: true, + name: true, + employees: true, + domainName: { primaryLinkUrl: true }, + address: { addressCity: true, addressCountry: true }, + }, + }, + }, + }); + + const company = companies?.edges?.[0]?.node; + + if (!company?.id) { + return { found: false, displayName: '', values: {} }; + } + + const { id: _id, ...fields } = company; + + return { + found: true, + displayName: company.name ?? 'Company', + values: flattenRecord(fields as Record), + }; + } + + const { people } = await client.query({ + people: { + __args: { filter: { id: { eq: recordId } }, first: 1 }, + edges: { + node: { + id: true, + jobTitle: true, + city: true, + name: { firstName: true, lastName: true }, + emails: { primaryEmail: true }, + phones: { primaryPhoneNumber: true }, + linkedinLink: { primaryLinkUrl: true }, + company: { name: true }, + }, + }, + }, + }); + + const person = people?.edges?.[0]?.node; + + if (!person?.id) { + return { found: false, displayName: '', values: {} }; + } + + const { id: _id, ...fields } = person; + const fullName = [person.name?.firstName, person.name?.lastName] + .filter(Boolean) + .join(' '); + + return { + found: true, + displayName: fullName.length > 0 ? fullName : 'Person', + values: flattenRecord(fields as Record), + }; +}; + +export const isSupportedTarget = (target: string): boolean => + target === TEMPLATE_TARGET_PERSON || target === TEMPLATE_TARGET_COMPANY; diff --git a/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/render-template.ts b/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/render-template.ts new file mode 100644 index 0000000000..ff6ef2817a --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/render-template.ts @@ -0,0 +1,58 @@ +// Flattens a nested record into dot-path keys, e.g. +// { name: { firstName: 'Ada' } } -> { 'name.firstName': 'Ada' }. +// Only string/number/boolean leaves are kept; nullish values become ''. +export const flattenRecord = ( + input: Record, + prefix = '', +): Record => { + return Object.entries(input).reduce>( + (accumulator, [key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + + if (value === null || value === undefined) { + accumulator[path] = ''; + } else if (typeof value === 'object' && !Array.isArray(value)) { + Object.assign( + accumulator, + flattenRecord(value as Record, path), + ); + } else if (typeof value !== 'object') { + accumulator[path] = String(value); + } + + return accumulator; + }, + {}, + ); +}; + +const PLACEHOLDER_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g; + +export type RenderResult = { + content: string; + missingTokens: string[]; +}; + +// Replaces every {{token}} in the template body with the matching value from +// the flattened record. Unknown tokens are rendered empty and reported so the +// caller can warn the user about placeholders that did not resolve. +export const renderTemplate = ( + body: string, + values: Record, +): RenderResult => { + const missingTokens = new Set(); + + const content = body.replace(PLACEHOLDER_PATTERN, (_match, token: string) => { + const value = values[token]; + + if (value === undefined || value === '') { + missingTokens.add(token); + + return ''; + } + + return value; + }); + + return { content, missingTokens: [...missingTokens] }; +}; diff --git a/packages/twenty-apps/examples/document-generator/src/logic-functions/view-document.ts b/packages/twenty-apps/examples/document-generator/src/logic-functions/view-document.ts new file mode 100644 index 0000000000..be0ae88fc8 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/logic-functions/view-document.ts @@ -0,0 +1,64 @@ +import { CoreApiClient } from 'twenty-client-sdk/core'; +import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define'; +import { Response } from 'twenty-sdk/logic-function'; + +import { VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; +import { documentHtmlPage } from 'src/utils/render-document'; + +const htmlResponse = (html: string, status = 200): Response => + new Response(html, { + status, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + +// Renders a generated document as a standalone, printable web page. +// Open it at: /s/documents/view?id= +const handler = async (event: RoutePayload): Promise => { + const documentId = event.queryStringParameters?.id; + + if (!documentId) { + return htmlResponse( + documentHtmlPage('Missing document id', 'Provide ?id=.'), + 400, + ); + } + + const client = new CoreApiClient(); + + // Filtered list query so an unknown id renders a clean 404 page instead of + // throwing. + const { documents } = await client.query({ + documents: { + __args: { filter: { id: { eq: documentId } }, first: 1 }, + edges: { + node: { id: true, name: true, content: true }, + }, + }, + }); + + const document = documents?.edges?.[0]?.node; + + if (!document?.id) { + return htmlResponse( + documentHtmlPage('Document not found', `No document with id ${documentId}.`), + 404, + ); + } + + return htmlResponse( + documentHtmlPage(document.name ?? 'Document', document.content ?? ''), + ); +}; + +export default defineLogicFunction({ + universalIdentifier: VIEW_DOCUMENT_ROUTE_UNIVERSAL_IDENTIFIER, + name: 'view-document', + description: 'Renders a generated document as a printable HTML page.', + timeoutSeconds: 15, + handler, + httpRouteTriggerSettings: { + path: '/documents/view', + httpMethod: 'GET', + isAuthRequired: false, + }, +}); diff --git a/packages/twenty-apps/examples/document-generator/src/navigation-menu-items/documents.navigation-menu-item.ts b/packages/twenty-apps/examples/document-generator/src/navigation-menu-items/documents.navigation-menu-item.ts new file mode 100644 index 0000000000..6878f2142f --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/navigation-menu-items/documents.navigation-menu-item.ts @@ -0,0 +1,19 @@ +import { + defineNavigationMenuItem, + NavigationMenuItemType, +} from 'twenty-sdk/define'; + +import { + DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, + DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineNavigationMenuItem({ + universalIdentifier: DOCUMENTS_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, + name: 'Documents', + icon: 'IconFile', + color: 'green', + position: 1, + type: NavigationMenuItemType.VIEW, + viewUniversalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/examples/document-generator/src/navigation-menu-items/templates.navigation-menu-item.ts b/packages/twenty-apps/examples/document-generator/src/navigation-menu-items/templates.navigation-menu-item.ts new file mode 100644 index 0000000000..d878feb180 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/navigation-menu-items/templates.navigation-menu-item.ts @@ -0,0 +1,19 @@ +import { + defineNavigationMenuItem, + NavigationMenuItemType, +} from 'twenty-sdk/define'; + +import { + TEMPLATES_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, + TEMPLATES_VIEW_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineNavigationMenuItem({ + universalIdentifier: TEMPLATES_NAVIGATION_MENU_ITEM_UNIVERSAL_IDENTIFIER, + name: 'Templates', + icon: 'IconFileText', + color: 'blue', + position: 0, + type: NavigationMenuItemType.VIEW, + viewUniversalIdentifier: TEMPLATES_VIEW_UNIVERSAL_IDENTIFIER, +}); diff --git a/packages/twenty-apps/examples/document-generator/src/objects/document-template.object.ts b/packages/twenty-apps/examples/document-generator/src/objects/document-template.object.ts new file mode 100644 index 0000000000..083faae856 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/objects/document-template.object.ts @@ -0,0 +1,69 @@ +import { defineObject, FieldType } from 'twenty-sdk/define'; + +import { + DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER, + TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER, + TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER, + TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER, + TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER, + TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER, + TEMPLATE_TARGET_COMPANY, + TEMPLATE_TARGET_PERSON, +} from 'src/constants/universal-identifiers'; + +export default defineObject({ + universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER, + nameSingular: 'documentTemplate', + namePlural: 'documentTemplates', + labelSingular: 'Document template', + labelPlural: 'Document templates', + description: + 'A reusable document with placeholders like {{name.firstName}} that get filled from a CRM record.', + icon: 'IconFileText', + labelIdentifierFieldMetadataUniversalIdentifier: + TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER, + fields: [ + { + universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.TEXT, + name: 'name', + label: 'Name', + description: 'Template name, e.g. "Sales proposal".', + icon: 'IconAbc', + }, + { + universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.RICH_TEXT, + name: 'body', + label: 'Body', + description: + 'Template edited with the rich-text editor. Type {{placeholders}} such as {{name.firstName}} or {{jobTitle}}; they are replaced with values from the selected record when a document is generated.', + icon: 'IconFileText', + }, + { + universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.SELECT, + name: 'target', + label: 'Target', + description: 'Which kind of CRM record this template is written for.', + icon: 'IconTarget', + defaultValue: `'${TEMPLATE_TARGET_PERSON}'`, + options: [ + { + id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER, + value: TEMPLATE_TARGET_PERSON, + label: 'Person', + color: 'blue', + position: 0, + }, + { + id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER, + value: TEMPLATE_TARGET_COMPANY, + label: 'Company', + color: 'green', + position: 1, + }, + ], + }, + ], +}); diff --git a/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts b/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts new file mode 100644 index 0000000000..875b66e6de --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts @@ -0,0 +1,77 @@ +import { defineObject, FieldType } from 'twenty-sdk/define'; + +import { + DOCUMENT_CONTENT_FIELD_UNIVERSAL_IDENTIFIER, + DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER, + DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER, + DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER, + DOCUMENT_STATUS_DRAFT, + DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER, + DOCUMENT_STATUS_GENERATED, + DOCUMENT_STATUS_OPTION_DRAFT_UNIVERSAL_IDENTIFIER, + DOCUMENT_STATUS_OPTION_GENERATED_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default defineObject({ + universalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER, + nameSingular: 'document', + namePlural: 'documents', + labelSingular: 'Document', + labelPlural: 'Documents', + description: 'A generated document produced from a template and a CRM record.', + icon: 'IconFile', + labelIdentifierFieldMetadataUniversalIdentifier: + DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER, + fields: [ + { + universalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.TEXT, + name: 'name', + label: 'Name', + description: 'Document name.', + icon: 'IconAbc', + }, + { + universalIdentifier: DOCUMENT_CONTENT_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.TEXT, + name: 'content', + label: 'Content', + description: 'The rendered document text, with all placeholders filled.', + icon: 'IconFileText', + }, + { + universalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.SELECT, + name: 'status', + label: 'Status', + description: 'Where this document is in its lifecycle.', + icon: 'IconProgress', + defaultValue: `'${DOCUMENT_STATUS_DRAFT}'`, + options: [ + { + id: DOCUMENT_STATUS_OPTION_DRAFT_UNIVERSAL_IDENTIFIER, + value: DOCUMENT_STATUS_DRAFT, + label: 'Draft', + color: 'gray', + position: 0, + }, + { + id: DOCUMENT_STATUS_OPTION_GENERATED_UNIVERSAL_IDENTIFIER, + value: DOCUMENT_STATUS_GENERATED, + label: 'Generated', + color: 'green', + position: 1, + }, + ], + }, + { + universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldType.FILES, + name: 'file', + label: 'File', + description: 'The generated document as a downloadable PDF.', + icon: 'IconFileTypePdf', + universalSettings: { maxNumberOfValues: 1 }, + }, + ], +}); diff --git a/packages/twenty-apps/examples/document-generator/src/page-layouts/document-record.page-layout.ts b/packages/twenty-apps/examples/document-generator/src/page-layouts/document-record.page-layout.ts new file mode 100644 index 0000000000..fa3479933f --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/page-layouts/document-record.page-layout.ts @@ -0,0 +1,61 @@ +import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define'; + +import { + DOCUMENT_FIELDS_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, + DOCUMENT_FIELDS_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, + DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER, + DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, + DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, + DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +// A "Fields" tab so the record's own fields always render, plus a "Preview" tab +// that renders the styled document via the document-viewer front component. An +// app-defined record page replaces the object's default layout, so it must +// carry its own fields tab or the record shows "No Data". +export default definePageLayout({ + universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + name: 'Document record page', + type: 'RECORD_PAGE', + objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER, + tabs: [ + { + universalIdentifier: DOCUMENT_FIELDS_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, + title: 'Fields', + position: 0, + icon: 'IconList', + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + widgets: [ + { + universalIdentifier: + DOCUMENT_FIELDS_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Document fields', + type: 'FIELDS', + configuration: { + configurationType: 'FIELDS', + }, + }, + ], + }, + { + universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, + title: 'Preview', + position: 50, + icon: 'IconEye', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: DOCUMENT_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Document preview', + type: 'FRONT_COMPONENT', + configuration: { + configurationType: 'FRONT_COMPONENT', + frontComponentUniversalIdentifier: + DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, + }, + }, + ], + }, + ], +}); diff --git a/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts b/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts new file mode 100644 index 0000000000..3e49a2f854 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts @@ -0,0 +1,76 @@ +import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define'; + +import { + DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER, + TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER, + TEMPLATE_FIELDS_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, + TEMPLATE_FIELDS_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, + TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, + TEMPLATE_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, + TIMELINE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, + TIMELINE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +export default definePageLayout({ + universalIdentifier: TEMPLATE_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER, + name: 'Template record page', + type: 'RECORD_PAGE', + objectUniversalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER, + tabs: [ + { + universalIdentifier: TEMPLATE_FIELDS_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, + title: 'Fields', + position: 0, + icon: 'IconList', + layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST, + widgets: [ + { + universalIdentifier: + TEMPLATE_FIELDS_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Fields', + type: 'FIELDS', + configuration: { + configurationType: 'FIELDS', + }, + }, + ], + }, + { + layoutMode: PageLayoutTabLayoutMode.GRID, + position: 1, + title: 'Template', + universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, + widgets: [ + { + universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Template', + type: 'FIELD', + gridPosition: { row: 0, column: 0, rowSpan: 12, columnSpan: 12 }, + configuration: { + configurationType: 'FIELD', + fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER, + fieldDisplayMode: 'EDITOR', + }, + }, + ], + }, + { + universalIdentifier: TIMELINE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER, + title: 'Timeline', + position: 100, + icon: 'IconTimelineEvent', + layoutMode: PageLayoutTabLayoutMode.CANVAS, + widgets: [ + { + universalIdentifier: TIMELINE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER, + title: 'Timeline', + type: 'TIMELINE', + configuration: { + configurationType: 'TIMELINE', + }, + }, + ], + }, + ], +}); diff --git a/packages/twenty-apps/examples/document-generator/src/roles/default-role.ts b/packages/twenty-apps/examples/document-generator/src/roles/default-role.ts new file mode 100644 index 0000000000..1520b82dcd --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/roles/default-role.ts @@ -0,0 +1,22 @@ +import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define'; + +import { + APP_DISPLAY_NAME, + DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, +} from 'src/constants/universal-identifiers'; + +// The role the app's logic functions and agent run as. Scoped to least +// privilege: it reads templates and CRM records, creates documents, and uploads +// the generated PDF — it never deletes records, so delete capabilities stay off. +export default defineApplicationRole({ + universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER, + label: `${APP_DISPLAY_NAME} default role`, + description: `${APP_DISPLAY_NAME} default role`, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: true, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canAccessAllTools: true, + canBeAssignedToAgents: true, + permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE], +}); diff --git a/packages/twenty-apps/examples/document-generator/src/skills/document-drafting.skill.ts b/packages/twenty-apps/examples/document-generator/src/skills/document-drafting.skill.ts new file mode 100644 index 0000000000..494760e267 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/skills/document-drafting.skill.ts @@ -0,0 +1,24 @@ +import { defineSkill } from 'twenty-sdk/define'; + +import { DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers'; + +export default defineSkill({ + universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER, + name: 'document-drafting', + label: 'Document drafting', + description: 'Knows how to turn templates and CRM records into documents.', + icon: 'IconFileText', + content: [ + 'You help users generate documents from templates.', + '', + 'To generate a document, call the `generate-document` tool with:', + '- `templateId`: the id of the document template to use.', + '- `recordId`: the id of the Person or Company the document is for.', + '', + 'Guidelines:', + '- If the user names a template or a person/company instead of an id, first find the matching record, then pass its id.', + '- If more than one record matches the name, list the candidates and ask the user to pick one before generating — never guess.', + '- Make sure the template target (person or company) matches the record type.', + '- After generating, report the document name and share that it can be opened from the Documents view.', + ].join('\n'), +}); diff --git a/packages/twenty-apps/examples/document-generator/src/utils/__tests__/render-document.test.ts b/packages/twenty-apps/examples/document-generator/src/utils/__tests__/render-document.test.ts new file mode 100644 index 0000000000..053125e623 --- /dev/null +++ b/packages/twenty-apps/examples/document-generator/src/utils/__tests__/render-document.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; + +import { documentContentToHtml, documentHtmlPage } from '../render-document'; + +describe('documentContentToHtml', () => { + it('should render markdown to html', () => { + const html = documentContentToHtml('# Hi\n\n**bold** and a list:\n\n- one\n- two'); + + expect(html).toContain('

Hi

'); + expect(html).toContain('bold'); + expect(html).toContain('
  • one
  • '); + }); + + it('should drop raw html to prevent injection', () => { + const html = documentContentToHtml('Hello '); + + expect(html).not.toContain('