Add Document Generator SDK app + step-by-step tutorial (#22522)
## What & why
This adds a **guided tutorial** that teaches the Twenty SDK by building
one real, useful app end to end — plus the finished app itself, ready
for the marketplace.
The app, **Document Generator**, turns reusable templates into
personalized documents using CRM data: write a template once with
`{{placeholders}}`, then generate a filled-in document for any Person or
Company from the command menu, an AI agent, or a workflow.
## Two parts
**1. The app — `packages/twenty-apps/public/document-generator`**
Each capability maps to one tutorial chapter:
- **Data:** `documentTemplate` + `document` objects, fields, and a
bidirectional relation
- **Logic:** a single `generate-document` handler exposed as an **AI
tool**, a **workflow action**, and an **HTTP POST route**; plus a public
**HTML view route**
- **UI:** two views + sidebar navigation, a **command-menu item** (on
Person selection) that opens a **React front component**
- **AI:** an agent + skill; a default application role; marketplace
metadata + logo
- **Tests:** unit tests for the template renderer + an install
integration test
**2. The tutorial —
`packages/twenty-docs/.../apps/tutorials/document-generator/`**
A six-chapter series under **Developers › Apps › Tutorial** (Overview →
Data model → Generating documents → HTTP routes → Building the UI → AI
agent → Publishing). Minimal prose, paste-ready code, inline links to
the matching reference pages, and real screenshots. Registers a new
"Tutorial" nav group and regenerates `docs.json` + the navigation
template.
## Verification
Validated against a running Twenty instance (`twenty-app-dev` on
`:2020`):
- `twenty dev --once` installs cleanly (28 metadata objects created)
- Generated a real document from a Person — placeholders resolved (name,
job title, `company.name`, email), zero missing tokens
- Command menu → front component → generate flow works in the UI
- Public HTML view route renders the document
- App gates green: `yarn lint` (0/0), `yarn typecheck`, `yarn test:unit`
(7/7)
All screenshots in the tutorial are captured from this run.
## Notes
- Left out per-app CI workflows (`.github/workflows`) to keep scope
tight — happy to add them if wanted.
https://claude.ai/code/session_012FoC76zPbPmpgtN7MXMPwy
---
_Generated by [Claude
Code](https://claude.ai/code/session_012FoC76zPbPmpgtN7MXMPwy)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22522?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: github-actions <github-actions@twenty.com>
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
24.5.0
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="14" fill="#1961ED"/>
|
||||
<path d="M22 16h14l10 10v22a2 2 0 0 1-2 2H22a2 2 0 0 1-2-2V18a2 2 0 0 1 2-2Z" fill="#fff"/>
|
||||
<path d="M36 16v9a1 1 0 0 0 1 1h9" fill="#C9DCFF"/>
|
||||
<rect x="26" y="32" width="14" height="2.4" rx="1.2" fill="#1961ED"/>
|
||||
<rect x="26" y="38" width="14" height="2.4" rx="1.2" fill="#1961ED"/>
|
||||
<rect x="26" y="44" width="9" height="2.4" rx="1.2" fill="#1961ED"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 522 B |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 124 KiB |
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(' '),
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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<string, CSSProperties> = {
|
||||
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<LoadedDocument | null>(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 <div style={styles.empty}>{loading ? 'Loading…' : 'Open a document to preview it here.'}</div>;
|
||||
}
|
||||
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{document.pdfUrl ? (
|
||||
<a style={styles.actionLink} href={document.pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
@@ -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<HTMLElement>): string | undefined => {
|
||||
const object = event as {
|
||||
detail?: { value?: string };
|
||||
target?: { value?: string };
|
||||
};
|
||||
|
||||
return object.detail?.value ?? object.target?.value;
|
||||
};
|
||||
|
||||
const callAppRoute = async <TResponse,>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST',
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<TResponse> => {
|
||||
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<TResponse>;
|
||||
};
|
||||
|
||||
const styles: Record<string, CSSProperties> = {
|
||||
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<Template[]>([]);
|
||||
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<GenerateResponse>(
|
||||
'/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 (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.title}>Generate document</h2>
|
||||
<p style={styles.subtitle}>
|
||||
Pick a template; it will be filled with this person's data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={styles.body}>
|
||||
<label htmlFor="template-select" style={styles.label}>
|
||||
Template
|
||||
</label>
|
||||
<select
|
||||
id="template-select"
|
||||
value={templateId}
|
||||
onChange={(event) => {
|
||||
const value = readValue(event);
|
||||
|
||||
if (typeof value === 'string') {
|
||||
setTemplateId(value);
|
||||
}
|
||||
}}
|
||||
style={styles.select}
|
||||
disabled={loading || templates.length === 0}
|
||||
>
|
||||
{templates.length === 0 ? (
|
||||
<option value="">No person templates yet</option>
|
||||
) : (
|
||||
templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
{recordId === null && (
|
||||
<span style={styles.helper}>
|
||||
Select a single person to generate a document.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.button, ...styles.secondary }}
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...styles.button,
|
||||
...styles.primary,
|
||||
...(canSubmit ? {} : styles.primaryDisabled),
|
||||
}}
|
||||
onClick={handleGenerate}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{submitting ? 'Generating…' : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
@@ -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<Response> => {
|
||||
const body = event.body as Record<string, unknown> | 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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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<void> => {
|
||||
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<GenerateDocumentResult> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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-');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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, 'text'>,
|
||||
): 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<typeof rgb> };
|
||||
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<typeof rgb>) => {
|
||||
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<Uint8Array> => {
|
||||
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();
|
||||
};
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
// 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<LoadedRecord> => {
|
||||
// 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<string, unknown>),
|
||||
};
|
||||
}
|
||||
|
||||
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<string, unknown>),
|
||||
};
|
||||
};
|
||||
|
||||
export const isSupportedTarget = (target: string): boolean =>
|
||||
target === TEMPLATE_TARGET_PERSON || target === TEMPLATE_TARGET_COMPANY;
|
||||
@@ -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<string, unknown>,
|
||||
prefix = '',
|
||||
): Record<string, string> => {
|
||||
return Object.entries(input).reduce<Record<string, string>>(
|
||||
(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<string, unknown>, 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<string, string>,
|
||||
): RenderResult => {
|
||||
const missingTokens = new Set<string>();
|
||||
|
||||
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] };
|
||||
};
|
||||
@@ -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: <server>/s/documents/view?id=<documentId>
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(
|
||||
documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'),
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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],
|
||||
});
|
||||
@@ -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'),
|
||||
});
|
||||
@@ -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('<h1>Hi</h1>');
|
||||
expect(html).toContain('<strong>bold</strong>');
|
||||
expect(html).toContain('<li>one</li>');
|
||||
});
|
||||
|
||||
it('should drop raw html to prevent injection', () => {
|
||||
const html = documentContentToHtml('Hello <script>alert(1)</script>');
|
||||
|
||||
expect(html).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('should neutralize unsafe link protocols', () => {
|
||||
const html = documentContentToHtml('[x](javascript:alert(1))');
|
||||
|
||||
expect(html).not.toContain('javascript:');
|
||||
});
|
||||
|
||||
it('should drop images so no external <img> is emitted', () => {
|
||||
const html = documentContentToHtml('');
|
||||
|
||||
expect(html).not.toContain('<img');
|
||||
expect(html).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it('should escape link href and text', () => {
|
||||
const html = documentContentToHtml('[a"b](https://example.com/?x="y"&z=1)');
|
||||
|
||||
expect(html).not.toContain('="y"');
|
||||
expect(html).toContain('"');
|
||||
expect(html).toContain('&');
|
||||
});
|
||||
});
|
||||
|
||||
describe('documentHtmlPage', () => {
|
||||
it('should wrap content in a titled, styled page', () => {
|
||||
const page = documentHtmlPage('Proposal', 'Body text');
|
||||
|
||||
expect(page).toContain('<!doctype html>');
|
||||
expect(page).toContain('<title>Proposal</title>');
|
||||
expect(page).toContain('doc-paper');
|
||||
});
|
||||
|
||||
it('should escape the title', () => {
|
||||
const page = documentHtmlPage('<script>', 'x');
|
||||
|
||||
expect(page).toContain('<script>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { type CSSProperties, type ReactNode } from 'react';
|
||||
import { marked, type Token, type Tokens } from 'marked';
|
||||
|
||||
// Front components run in a remote-DOM sandbox: raw HTML injection is not
|
||||
// allowed, so Markdown is rendered as real React elements with inline styles.
|
||||
|
||||
const INK = '#1f2430';
|
||||
const HEADING = '#10152a';
|
||||
const ACCENT = '#1961ed';
|
||||
|
||||
const styles: Record<string, CSSProperties> = {
|
||||
h1: { fontSize: '22px', fontWeight: 700, color: HEADING, margin: '28px 0 12px', lineHeight: 1.3 },
|
||||
h2: { fontSize: '18px', fontWeight: 700, color: HEADING, margin: '24px 0 10px', lineHeight: 1.3 },
|
||||
h3: { fontSize: '15px', fontWeight: 700, color: HEADING, margin: '20px 0 8px', lineHeight: 1.3 },
|
||||
p: { margin: '0 0 14px', lineHeight: 1.7, color: INK },
|
||||
ul: { margin: '0 0 14px', paddingLeft: '22px' },
|
||||
ol: { margin: '0 0 14px', paddingLeft: '22px' },
|
||||
li: { margin: '4px 0', lineHeight: 1.6, color: INK },
|
||||
a: { color: ACCENT, textDecoration: 'none' },
|
||||
code: {
|
||||
fontFamily: "'SFMono-Regular', Menlo, monospace",
|
||||
fontSize: '0.9em',
|
||||
background: '#f1f3f9',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
blockquote: {
|
||||
margin: '14px 0',
|
||||
padding: '4px 18px',
|
||||
borderLeft: `3px solid ${ACCENT}`,
|
||||
background: '#f6f8fd',
|
||||
color: '#47506a',
|
||||
},
|
||||
hr: { border: 0, borderTop: '1px solid #e6e9f2', margin: '24px 0' },
|
||||
pre: {
|
||||
background: '#f1f3f9',
|
||||
padding: '14px 16px',
|
||||
borderRadius: '6px',
|
||||
overflowX: 'auto',
|
||||
fontFamily: "'SFMono-Regular', Menlo, monospace",
|
||||
fontSize: '12px',
|
||||
lineHeight: 1.5,
|
||||
margin: '0 0 14px',
|
||||
},
|
||||
};
|
||||
|
||||
const BLOCK_TOKEN_TYPES = new Set([
|
||||
'list',
|
||||
'paragraph',
|
||||
'code',
|
||||
'blockquote',
|
||||
'heading',
|
||||
'hr',
|
||||
'space',
|
||||
]);
|
||||
|
||||
const renderInline = (tokens: Token[] | undefined, keyPrefix: string): ReactNode[] => {
|
||||
if (!tokens) return [];
|
||||
|
||||
return tokens.map((token, index): ReactNode => {
|
||||
const key = `${keyPrefix}-${index}`;
|
||||
switch (token.type) {
|
||||
case 'strong':
|
||||
return <strong key={key}>{renderInline((token as Tokens.Strong).tokens, key)}</strong>;
|
||||
case 'em':
|
||||
return <em key={key}>{renderInline((token as Tokens.Em).tokens, key)}</em>;
|
||||
case 'codespan':
|
||||
return <code key={key} style={styles.code}>{(token as Tokens.Codespan).text}</code>;
|
||||
case 'br':
|
||||
return <br key={key} />;
|
||||
case 'link': {
|
||||
const link = token as Tokens.Link;
|
||||
const safe = /^(https?:|mailto:)/i.test(link.href ?? '');
|
||||
return safe ? (
|
||||
<a key={key} href={link.href} style={styles.a} target="_blank" rel="noopener noreferrer">
|
||||
{renderInline(link.tokens, key)}
|
||||
</a>
|
||||
) : (
|
||||
<span key={key}>{renderInline(link.tokens, key)}</span>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
const nested = (token as Tokens.Text).tokens;
|
||||
if (Array.isArray(nested) && nested.length > 0) {
|
||||
return <span key={key}>{renderInline(nested, key)}</span>;
|
||||
}
|
||||
return <span key={key}>{(token as Tokens.Text).text ?? ''}</span>;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const renderBlocks = (tokens: Token[]): ReactNode[] =>
|
||||
tokens.map((token, index): ReactNode => {
|
||||
const key = `b-${index}`;
|
||||
switch (token.type) {
|
||||
case 'heading': {
|
||||
const heading = token as Tokens.Heading;
|
||||
const Tag = (['h1', 'h2', 'h3', 'h3'][heading.depth - 1] ?? 'h3') as 'h1' | 'h2' | 'h3';
|
||||
return (
|
||||
<Tag key={key} style={styles[Tag]}>
|
||||
{renderInline(heading.tokens, key)}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
case 'paragraph':
|
||||
return <p key={key} style={styles.p}>{renderInline((token as Tokens.Paragraph).tokens, key)}</p>;
|
||||
case 'list': {
|
||||
const list = token as Tokens.List;
|
||||
const items = list.items.map((item, itemIndex) => {
|
||||
// List items can hold block-level tokens (nested lists, extra
|
||||
// paragraphs, code). Render those as blocks; keep simple items inline.
|
||||
const hasBlock = item.tokens?.some((child) => BLOCK_TOKEN_TYPES.has(child.type));
|
||||
return (
|
||||
<li key={`${key}-${itemIndex}`} style={styles.li}>
|
||||
{hasBlock
|
||||
? renderBlocks(item.tokens)
|
||||
: renderInline(item.tokens, `${key}-${itemIndex}`)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
return list.ordered ? (
|
||||
<ol key={key} style={styles.ol}>{items}</ol>
|
||||
) : (
|
||||
<ul key={key} style={styles.ul}>{items}</ul>
|
||||
);
|
||||
}
|
||||
case 'blockquote':
|
||||
return (
|
||||
<blockquote key={key} style={styles.blockquote}>
|
||||
{renderBlocks((token as Tokens.Blockquote).tokens)}
|
||||
</blockquote>
|
||||
);
|
||||
case 'code':
|
||||
return <pre key={key} style={styles.pre}>{(token as Tokens.Code).text}</pre>;
|
||||
case 'hr':
|
||||
return <hr key={key} style={styles.hr} />;
|
||||
case 'space':
|
||||
return null;
|
||||
case 'text': {
|
||||
// Loose list items expose their content as a `text` block token with
|
||||
// nested inline tokens — render those so bold/italic/links survive.
|
||||
const textToken = token as Tokens.Text;
|
||||
return (
|
||||
<span key={key}>
|
||||
{textToken.tokens
|
||||
? renderInline(textToken.tokens, key)
|
||||
: textToken.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
const text = (token as { text?: string }).text;
|
||||
return text ? <p key={key} style={styles.p}>{text}</p> : null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const Markdown = ({ content }: { content: string }): ReactNode => (
|
||||
<div>{renderBlocks(marked.lexer(content))}</div>
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Marked } from 'marked';
|
||||
|
||||
// A Markdown renderer hardened for untrusted template content: raw HTML is
|
||||
// dropped and only http(s)/mailto links survive, so the output is safe to
|
||||
// inject into the page or the front-end viewer.
|
||||
const escapeHtml = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
const markdown = new Marked({ async: false, gfm: true, breaks: true });
|
||||
|
||||
markdown.use({
|
||||
renderer: {
|
||||
html() {
|
||||
return '';
|
||||
},
|
||||
// Drop images entirely — they would emit <img> tags pointing at arbitrary
|
||||
// external URLs, which the "no raw HTML" contract is meant to prevent.
|
||||
image() {
|
||||
return '';
|
||||
},
|
||||
link({ href, text }: { href: string; text: string }) {
|
||||
const isSafe = /^(https?:|mailto:)/i.test(href ?? '');
|
||||
// Escape both values: href lands in an attribute and text in element
|
||||
// content, so unescaped input would otherwise inject markup.
|
||||
const safeText = escapeHtml(text ?? '');
|
||||
|
||||
return isSafe
|
||||
? `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${safeText}</a>`
|
||||
: safeText;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const documentContentToHtml = (content: string): string =>
|
||||
markdown.parse(content) as string;
|
||||
|
||||
// The "paper" styling only (no `body` rules), so it is safe to inject inside a
|
||||
// front component without leaking styles onto the host page.
|
||||
export const DOCUMENT_PAPER_CSS = `
|
||||
.doc-paper {
|
||||
color: #1f2430;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif;
|
||||
line-height: 1.7;
|
||||
max-width: 720px;
|
||||
margin: 48px auto;
|
||||
background: #ffffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(24, 39, 75, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
.doc-body { padding: 64px 72px 72px; }
|
||||
.doc-body h1, .doc-body h2, .doc-body h3 { color: #10152a; line-height: 1.3; margin: 32px 0 12px; }
|
||||
.doc-body h1 { font-size: 24px; } .doc-body h2 { font-size: 20px; } .doc-body h3 { font-size: 16px; }
|
||||
.doc-body p { margin: 0 0 16px; }
|
||||
.doc-body ul, .doc-body ol { margin: 0 0 16px; padding-left: 24px; }
|
||||
.doc-body li { margin: 4px 0; }
|
||||
.doc-body a { color: #1961ed; text-decoration: none; }
|
||||
.doc-body a:hover { text-decoration: underline; }
|
||||
.doc-body blockquote {
|
||||
margin: 16px 0; padding: 4px 20px; border-left: 3px solid #1961ed;
|
||||
color: #47506a; background: #f6f8fd;
|
||||
}
|
||||
.doc-body code {
|
||||
font-family: 'SFMono-Regular', Menlo, monospace; font-size: 0.9em;
|
||||
background: #f1f3f9; padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
.doc-body hr { border: 0; border-top: 1px solid #e6e9f2; margin: 32px 0; }
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
.doc-paper { box-shadow: none; margin: 0; border-radius: 0; max-width: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
// Inner markup for the styled "paper": renders the template content only, with
|
||||
// no title header or footer chrome.
|
||||
export const documentPaperHtml = (content: string): string =>
|
||||
`<article class="doc-paper">
|
||||
<div class="doc-body">
|
||||
${documentContentToHtml(content)}
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
const PAGE_BODY_CSS = `
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: #eef1f6;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif;
|
||||
}
|
||||
`;
|
||||
|
||||
// A complete, printable HTML page for the public view route.
|
||||
export const documentHtmlPage = (title: string, content: string): string => `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>${PAGE_BODY_CSS}${DOCUMENT_PAPER_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
${documentPaperHtml(content)}
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 280,
|
||||
},
|
||||
{
|
||||
universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
size: 120,
|
||||
},
|
||||
{
|
||||
universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2,
|
||||
isVisible: true,
|
||||
size: 200,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
import {
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
TEMPLATES_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
TEMPLATES_VIEW_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
TEMPLATES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
} from 'src/constants/universal-identifiers';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: TEMPLATES_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All templates',
|
||||
objectUniversalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFileText',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{
|
||||
universalIdentifier: TEMPLATES_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0,
|
||||
isVisible: true,
|
||||
size: 240,
|
||||
},
|
||||
{
|
||||
universalIdentifier: TEMPLATES_VIEW_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1,
|
||||
isVisible: true,
|
||||
size: 140,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": ".",
|
||||
"jsx": "react-jsx",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"allowUnreachableCode": false,
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"lib": ["es2020", "dom"],
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"paths": {
|
||||
"src/*": ["./src/*"],
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts",
|
||||
"**/*.integration-test.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL ?? 'http://localhost:2020';
|
||||
const TWENTY_API_KEY =
|
||||
process.env.TWENTY_API_KEY ??
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIyMDIwMjAyMC0xYzI1LTRkMDItYmYyNS02YWVjY2Y3ZWE0MTkiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiMjAyMDIwMjAtMWMyNS00ZDAyLWJmMjUtNmFlY2NmN2VhNDE5IiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjQ4OTE0NDk2MDAsImp0aSI6IjIwMjAyMDIwLWY0MDEtNGQ4YS1hNzMxLTY0ZDAwN2MyN2JhZCJ9.bfQjfyN0NEtTCLE_xPyNcwonDzlSXFoP8kdCQTdnuDc';
|
||||
|
||||
// Make env vars available to globalSetup (test.env only applies to workers)
|
||||
process.env.TWENTY_API_URL = TWENTY_API_URL;
|
||||
process.env.TWENTY_API_KEY = TWENTY_API_KEY;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: ['tsconfig.spec.json'],
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 120_000,
|
||||
fileParallelism: false,
|
||||
include: ['src/**/*.integration-test.ts'],
|
||||
globalSetup: ['src/__tests__/global-setup.ts'],
|
||||
env: {
|
||||
TWENTY_API_URL,
|
||||
TWENTY_API_KEY,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: ['tsconfig.spec.json'],
|
||||
ignoreConfigErrors: true,
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
title: 5. An AI agent
|
||||
icon: "robot"
|
||||
description: Let an agent generate documents from a chat, using your tool.
|
||||
---
|
||||
|
||||
Because `generate-document` is exposed as a **tool**, an AI agent can call it.
|
||||
Let's add an agent and a skill so users can just say *"generate a proposal for
|
||||
Jeffery Griffin"*.
|
||||
|
||||
## The skill
|
||||
|
||||
A [skill](/developers/extend/apps/logic/skills-and-agents) is reusable
|
||||
instructions — knowledge you attach to agents. Ours teaches the model how to use
|
||||
the tool.
|
||||
|
||||
```ts filename="src/skills/document-drafting.skill.ts"
|
||||
import { defineSkill } from 'twenty-sdk/define';
|
||||
|
||||
export default defineSkill({
|
||||
universalIdentifier: DOCUMENT_SKILL_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-drafting',
|
||||
label: 'Document drafting',
|
||||
icon: 'IconFileText',
|
||||
content: [
|
||||
'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.',
|
||||
'',
|
||||
'If the user names a template or person instead of an id, find the record first,',
|
||||
'then pass its id. Make sure the template target matches the record type.',
|
||||
].join('\n'),
|
||||
});
|
||||
```
|
||||
|
||||
## The agent
|
||||
|
||||
An [agent](/developers/extend/apps/logic/skills-and-agents) pairs a prompt with a
|
||||
model. Set `responseFormat` explicitly to avoid a build warning.
|
||||
|
||||
```ts filename="src/agents/document-assistant.agent.ts"
|
||||
import { defineAgent } from 'twenty-sdk/define';
|
||||
|
||||
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 from reusable templates',
|
||||
'and the data already in their CRM. Use the generate-document tool, and',
|
||||
'always confirm what you created.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
The agent can only call the tool if its role allows it. We already set
|
||||
`canAccessAllTools: true` and `canBeAssignedToAgents: true` on the app's role in
|
||||
[Chapter 2](/developers/extend/apps/tutorials/document-generator/generating-documents#grant-it-access).
|
||||
</Note>
|
||||
|
||||
## Try it
|
||||
|
||||
Open a chat with **Document Assistant** and ask it to draft a document for a
|
||||
person in your CRM. It finds the record, calls `generate-document`, and reports
|
||||
back the document it created — which now appears in your **Documents** view,
|
||||
exactly like the command-menu and workflow paths.
|
||||
|
||||
That's the payoff of exposing logic as a tool: **one function, many front doors** —
|
||||
command menu, HTTP, workflow step, and now natural language.
|
||||
|
||||
**After this step:** the app is feature-complete and genuinely useful. Time to
|
||||
ship it.
|
||||
|
||||
<Card title="Next: publishing →" icon="rocket" href="/developers/extend/apps/tutorials/document-generator/publishing">
|
||||
Add marketplace metadata and publish.
|
||||
</Card>
|
||||
@@ -0,0 +1,305 @@
|
||||
---
|
||||
title: 4. Building the UI
|
||||
icon: "table-columns"
|
||||
description: Views, sidebar navigation, a command, and front components.
|
||||
---
|
||||
|
||||
Right now the objects are only reachable through Settings. Let's give the app a
|
||||
real presence in the UI: list views, sidebar entries, a one-click
|
||||
**Generate document** command, a record-page front component to **preview** a
|
||||
document, and a native rich-text **editor** tab for templates.
|
||||
|
||||
## Views and navigation
|
||||
|
||||
A [view](/developers/extend/apps/layout/views) is a saved list of a given object.
|
||||
A [navigation menu item](/developers/extend/apps/layout/navigation-menu-items)
|
||||
puts that view in the sidebar.
|
||||
|
||||
```ts filename="src/views/documents.view.ts"
|
||||
import { defineView, ViewKey } from 'twenty-sdk/define';
|
||||
|
||||
export default defineView({
|
||||
universalIdentifier: DOCUMENTS_VIEW_UNIVERSAL_IDENTIFIER,
|
||||
name: 'All documents',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
icon: 'IconFile',
|
||||
key: ViewKey.INDEX,
|
||||
position: 0,
|
||||
fields: [
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 0, isVisible: true, size: 280 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_STATUS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 1, isVisible: true, size: 120 },
|
||||
{ universalIdentifier: DOCUMENTS_VIEW_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldMetadataUniversalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
position: 2, isVisible: true, size: 200 },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```ts filename="src/navigation-menu-items/documents.navigation-menu-item.ts"
|
||||
import { defineNavigationMenuItem, NavigationMenuItemType } from 'twenty-sdk/define';
|
||||
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
Add the same pair for templates. Both now show in the sidebar:
|
||||
|
||||
<Frame caption="Documents and Templates in the sidebar, with the generated document listed.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/04-documents-view.png" alt="Documents view with a generated document" />
|
||||
</Frame>
|
||||
|
||||
## A front component
|
||||
|
||||
A [front component](/developers/extend/apps/layout/front-components) is a React
|
||||
component sandboxed inside Twenty. Ours reads the selected record, loads the
|
||||
person templates via `CoreApiClient`, and POSTs to the route from the last
|
||||
chapter.
|
||||
|
||||
```tsx filename="src/front-components/generate-document-form.front-component.tsx"
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { enqueueSnackbar, useSelectedRecordIds } from 'twenty-sdk/front-component';
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
new CoreApiClient()
|
||||
.query({ documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } } } })
|
||||
.then(({ documentTemplates }) => {
|
||||
const list = documentTemplates?.edges?.map((e) => e.node) ?? [];
|
||||
setTemplates(list);
|
||||
if (list[0]) setTemplateId(list[0].id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const generate = async () => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token = process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
const res = await fetch(`${apiBaseUrl}/s/documents/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ templateId, recordId }),
|
||||
}).then((r) => r.json());
|
||||
await enqueueSnackbar({
|
||||
message: res.success ? 'Document generated.' : 'Generation failed.',
|
||||
variant: res.success ? 'success' : 'error',
|
||||
});
|
||||
};
|
||||
|
||||
// ...render a <select> of templates and a Generate button
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Style with inline CSS variables (`var(--t-color-blue)`), not values imported from
|
||||
`twenty-ui`. The SDK mocks that package during build, so module-level imports of
|
||||
theme constants would be `undefined`. See the
|
||||
[full component](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/front-components/generate-document-form.front-component.tsx).
|
||||
</Warning>
|
||||
|
||||
## A command to open it
|
||||
|
||||
A [command menu item](/developers/extend/apps/layout/command-menu-items) with
|
||||
`availabilityType: 'RECORD_SELECTION'` shows up when a Person is selected, and
|
||||
opens the component in the side panel.
|
||||
|
||||
```ts filename="src/command-menu-items/generate-document.command-menu-item.ts"
|
||||
import { defineCommandMenuItem, STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS } from 'twenty-sdk/define';
|
||||
|
||||
export default defineCommandMenuItem({
|
||||
universalIdentifier: GENERATE_DOCUMENT_COMMAND_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Generate document',
|
||||
availabilityType: 'RECORD_SELECTION',
|
||||
availabilityObjectUniversalIdentifier:
|
||||
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.person.universalIdentifier,
|
||||
frontComponentUniversalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
```
|
||||
|
||||
## Try the whole flow
|
||||
|
||||
Open **People**, tick a person, and press <kbd>⌘K</kbd> / <kbd>Ctrl K</kbd>.
|
||||
"Generate document" appears, tagged with your app:
|
||||
|
||||
<Frame caption="The command shows up when a Person is selected.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06-command-menu.png" alt="Command menu with Generate document" />
|
||||
</Frame>
|
||||
|
||||
Run it — your component opens in the side panel. Pick a template, click
|
||||
**Generate**, and a new record lands in **Documents**.
|
||||
|
||||
<Frame caption="The front component, loading templates and generating on click.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/06b-front-component.png" alt="Generate document side panel" />
|
||||
</Frame>
|
||||
|
||||
Every generated document records your app as its author:
|
||||
|
||||
<Frame caption="Created by Document Generator, status Generated.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/05-document-record.png" alt="A generated document record" />
|
||||
</Frame>
|
||||
|
||||
## Preview a document on its record page
|
||||
|
||||
A front component isn't only for command menus — you can mount one as a **tab on a
|
||||
record page**. Let's add a *Preview* tab to the document record that renders the
|
||||
Markdown body as a polished, printable page.
|
||||
|
||||
The component reads the current record id from its execution context, loads the
|
||||
document, and renders it. Front components run in a **sandbox** that only allows a
|
||||
whitelist of HTML tags — raw HTML injection (`dangerouslySetInnerHTML`) and
|
||||
`<style>` are blocked — so we render the Markdown as React elements with inline
|
||||
styles via a small [`Markdown`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/markdown-to-react.tsx)
|
||||
helper.
|
||||
|
||||
```tsx filename="src/front-components/document-viewer.front-component.tsx"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useFrontComponentExecutionContext((c) => c.recordId ?? null);
|
||||
// ...load { content, file } for recordId, then derive the links:
|
||||
const pdfUrl = document.file?.[0]?.url;
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
// Render the template body, plus quick links to the web page and the PDF.
|
||||
// Links open in a new tab so they don't navigate the embedded component.
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{pdfUrl ? (
|
||||
<a style={styles.actionLink} href={pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
```
|
||||
|
||||
Mount it with a [page layout](/developers/extend/apps/layout/page-layouts). A
|
||||
`RECORD_PAGE` layout adds tabs to an object's record view; a `FRONT_COMPONENT`
|
||||
widget in a `CANVAS` tab hosts the component:
|
||||
|
||||
```ts filename="src/page-layouts/document-record.page-layout.ts"
|
||||
import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'Document record page',
|
||||
type: 'RECORD_PAGE',
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
tabs: [{
|
||||
universalIdentifier: DOCUMENT_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Preview',
|
||||
icon: 'IconEye',
|
||||
position: 50,
|
||||
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,
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
```
|
||||
|
||||
Open any document — a **Preview** tab renders it beautifully, with links to the
|
||||
shareable web page and the PDF:
|
||||
|
||||
<Frame caption="The Preview tab renders the document with inline styles, plus quick links.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/09-document-viewer.png" alt="Document viewer front component in a record-page tab" />
|
||||
</Frame>
|
||||
|
||||
## Edit a template with the rich-text editor
|
||||
|
||||
Templates don't need a custom component at all. Because the `body` is a
|
||||
`RICH_TEXT` field, Twenty already provides a full rich-text editor for it — the
|
||||
same one the standard Note and Task objects use. We just surface it on the
|
||||
template record page.
|
||||
|
||||
Add a tab with a `FIELD` widget in `EDITOR` display mode, pointing at the `body`
|
||||
field via `fieldMetadataId`:
|
||||
|
||||
```ts filename="src/page-layouts/template-record.page-layout.ts"
|
||||
{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_TAB_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
position: 1,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
widgets: [{
|
||||
universalIdentifier: TEMPLATE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
title: 'Template',
|
||||
type: 'FIELD',
|
||||
gridPosition: { row: 0, column: 0, rowSpan: 6, columnSpan: 12 },
|
||||
configuration: {
|
||||
configurationType: 'FIELD',
|
||||
fieldMetadataId: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fieldDisplayMode: 'EDITOR',
|
||||
},
|
||||
}],
|
||||
}
|
||||
```
|
||||
|
||||
A `RICH_TEXT` field stores both the editor's block JSON and a Markdown
|
||||
projection. The generation pipeline reads that Markdown projection, so
|
||||
placeholders, the PDF, and the shareable web page all keep working unchanged —
|
||||
see the full
|
||||
[`template-record.page-layout.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/page-layouts/template-record.page-layout.ts).
|
||||
Now editors write templates in a proper rich-text editor:
|
||||
|
||||
<Frame caption="The Template tab: Twenty's native rich-text editor bound to the body field.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/10-template-editor.png" alt="Template record with the native rich-text editor tab" />
|
||||
</Frame>
|
||||
|
||||
**After this step:** documents preview beautifully and templates are editable
|
||||
in-app. Next, let an AI agent generate them from a chat.
|
||||
|
||||
<Card title="Next: an AI agent →" icon="robot" href="/developers/extend/apps/tutorials/document-generator/ai-agent">
|
||||
Add an agent and a skill that call your tool.
|
||||
</Card>
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: 1. Data model
|
||||
icon: "database"
|
||||
description: Model documents and templates with objects, fields, and a relation.
|
||||
---
|
||||
|
||||
Our app needs two custom objects: **document templates** (what to write) and
|
||||
**documents** (the generated result). Let's define them.
|
||||
|
||||
Scaffold each entity file with the CLI — it generates a valid UUID and the right
|
||||
folder for you:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:add object
|
||||
```
|
||||
|
||||
Below we show the finished files.
|
||||
|
||||
<Note>
|
||||
Every `*_UNIVERSAL_IDENTIFIER` constant lives in
|
||||
`src/constants/universal-identifiers.ts` and is imported where used. The snippets
|
||||
below omit those imports for brevity — keep them in your own files.
|
||||
</Note>
|
||||
|
||||
## The template object
|
||||
|
||||
A template has a `name`, a `body` with `{{placeholders}}`, and a `target` that
|
||||
says whether it's written for a Person or a Company. The `body` is a
|
||||
`RICH_TEXT` field, so Twenty gives it a full rich-text editor.
|
||||
|
||||
```ts filename="src/objects/document-template.object.ts"
|
||||
import { defineObject, FieldType } from 'twenty-sdk/define';
|
||||
|
||||
export default defineObject({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'documentTemplate',
|
||||
namePlural: 'documentTemplates',
|
||||
labelSingular: 'Document template',
|
||||
labelPlural: 'Document templates',
|
||||
icon: 'IconFileText',
|
||||
labelIdentifierFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
fields: [
|
||||
{ universalIdentifier: TEMPLATE_NAME_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.TEXT, name: 'name', label: 'Name', icon: 'IconAbc' },
|
||||
{ universalIdentifier: TEMPLATE_BODY_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RICH_TEXT, name: 'body', label: 'Body', icon: 'IconFileText',
|
||||
description: 'Use {{placeholders}} like {{name.firstName}} or {{jobTitle}}.' },
|
||||
{ universalIdentifier: TEMPLATE_TARGET_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.SELECT, name: 'target', label: 'Target', icon: 'IconTarget',
|
||||
defaultValue: `'PERSON'`,
|
||||
options: [
|
||||
{ id: TEMPLATE_TARGET_OPTION_PERSON_UNIVERSAL_IDENTIFIER,
|
||||
value: 'PERSON', label: 'Person', color: 'blue', position: 0 },
|
||||
{ id: TEMPLATE_TARGET_OPTION_COMPANY_UNIVERSAL_IDENTIFIER,
|
||||
value: 'COMPANY', label: 'Company', color: 'green', position: 1 },
|
||||
] },
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Warning>
|
||||
`SELECT` option **values** must be `UPPER_CASE` (`PERSON`, not `person`), and the
|
||||
`defaultValue` is wrapped in extra quotes: `` `'PERSON'` ``. The `label` is what
|
||||
users see.
|
||||
</Warning>
|
||||
|
||||
## The document object
|
||||
|
||||
The generated document stores the rendered `content` and a `status`. Define it
|
||||
the same way, with a `status` select of `DRAFT` / `GENERATED`. Full file:
|
||||
[`document.object.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/objects/document.object.ts).
|
||||
|
||||
## Linking them with a relation
|
||||
|
||||
Each document should point back to the template it came from. Relations are
|
||||
**bidirectional** — you define both sides, each in its own field file.
|
||||
|
||||
```ts filename="src/fields/document-template-relation.field.ts"
|
||||
import { defineField, FieldType, OnDeleteAction, RelationType } from 'twenty-sdk/define';
|
||||
|
||||
// The "many" side: each document belongs to one template.
|
||||
export default defineField({
|
||||
universalIdentifier: DOCUMENT_TEMPLATE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: DOCUMENT_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.RELATION,
|
||||
name: 'template',
|
||||
label: 'Template',
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
DOCUMENT_TEMPLATE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetFieldMetadataUniversalIdentifier:
|
||||
TEMPLATE_DOCUMENTS_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
universalSettings: {
|
||||
relationType: RelationType.MANY_TO_ONE,
|
||||
onDelete: OnDeleteAction.SET_NULL,
|
||||
joinColumnName: 'templateId',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The other side (`template-documents-relation.field.ts`) is a
|
||||
`RelationType.ONE_TO_MANY` field named `documents` that points the opposite way.
|
||||
See [Relations](/developers/extend/apps/data/relations) for the full pattern.
|
||||
|
||||
## See it in Twenty
|
||||
|
||||
With `yarn twenty dev` running, open **Settings → Data model**. Both objects
|
||||
appear, tagged with your app.
|
||||
|
||||
<Frame caption="Both custom objects, owned by the Document Generator app.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/01-data-model.png" alt="Data model settings showing Documents and Document templates" />
|
||||
</Frame>
|
||||
|
||||
Create one template to test with — name it *Sales proposal*, set **Target** to
|
||||
*Person*, and paste a body with a few placeholders:
|
||||
|
||||
```text
|
||||
Dear {{name.firstName}} {{name.lastName}},
|
||||
|
||||
As {{jobTitle}} at {{company.name}}, we think you'll love our product.
|
||||
|
||||
Best,
|
||||
The Team
|
||||
```
|
||||
|
||||
<Frame caption="A template record. The body keeps its placeholders until a document is generated.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/03-template-record.png" alt="A Sales proposal template record with placeholder body" />
|
||||
</Frame>
|
||||
|
||||
**After this step:** you have `documentTemplate` and `document` objects, linked by
|
||||
a relation, and one template to generate from. Next, the logic that fills it in.
|
||||
|
||||
<Card title="Next: generating documents →" icon="bolt" href="/developers/extend/apps/tutorials/document-generator/generating-documents">
|
||||
Write the logic function that fills the template.
|
||||
</Card>
|
||||
@@ -0,0 +1,239 @@
|
||||
---
|
||||
title: 2. Generating documents
|
||||
icon: "bolt"
|
||||
description: One logic function, exposed as an AI tool and a workflow action.
|
||||
---
|
||||
|
||||
Now the core: a [logic function](/developers/extend/apps/logic/logic-functions)
|
||||
that loads a template and a record, fills the placeholders, and saves a new
|
||||
document.
|
||||
|
||||
We'll write the business logic once as a **handler**, then expose it through
|
||||
several triggers. This chapter wires up two of them — an **AI tool** and a
|
||||
**workflow action**.
|
||||
|
||||
## The rendering helper
|
||||
|
||||
Keep pure logic in its own file so it's easy to unit-test. This flattens a record
|
||||
into `{{dot.path}}` tokens and substitutes them.
|
||||
|
||||
```ts filename="src/logic-functions/utils/render-template.ts"
|
||||
const PLACEHOLDER_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g;
|
||||
|
||||
export const renderTemplate = (body: string, values: Record<string, string>) => {
|
||||
const missingTokens = new Set<string>();
|
||||
const content = body.replace(PLACEHOLDER_PATTERN, (_m, token: string) => {
|
||||
const value = values[token];
|
||||
if (value === undefined || value === '') { missingTokens.add(token); return ''; }
|
||||
return value;
|
||||
});
|
||||
return { content, missingTokens: [...missingTokens] };
|
||||
};
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Because this file has no side effects, you can cover it with fast unit tests
|
||||
(`yarn test:unit`). See [Testing](/developers/extend/apps/operations/testing).
|
||||
</Tip>
|
||||
|
||||
## The handler
|
||||
|
||||
The handler uses the generated [`CoreApiClient`](/developers/extend/apps/logic/logic-functions)
|
||||
to read and write CRM data. It loads the template, loads the target record, fills
|
||||
the body, and creates a `document`.
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { loadRecordValues } from 'src/logic-functions/utils/load-record-values';
|
||||
import { renderTemplate } from 'src/logic-functions/utils/render-template';
|
||||
|
||||
export const generateDocumentHandler = async (
|
||||
input: { templateId: string; recordId: string },
|
||||
) => {
|
||||
const client = new CoreApiClient();
|
||||
|
||||
// Use a filtered list query, not the singular lookup: the singular query
|
||||
// throws when nothing matches, which would become a 500 instead of a 404.
|
||||
const { documentTemplates } = await client.query({
|
||||
documentTemplates: {
|
||||
__args: { filter: { id: { eq: input.templateId } }, first: 1 },
|
||||
edges: { node: { id: true, name: true, body: true, target: true } },
|
||||
},
|
||||
});
|
||||
const documentTemplate = documentTemplates?.edges?.[0]?.node;
|
||||
if (!documentTemplate?.id) return { success: false, status: 404, message: 'Template not found.' };
|
||||
|
||||
const record = await loadRecordValues(client, documentTemplate.target, input.recordId);
|
||||
if (!record.found) return { success: false, status: 404, message: 'Record not found.' };
|
||||
|
||||
const { content, missingTokens } = renderTemplate(documentTemplate.body ?? '', record.values);
|
||||
|
||||
const { createDocument } = await client.mutation({
|
||||
createDocument: {
|
||||
__args: { data: {
|
||||
name: `${documentTemplate.name} — ${record.displayName}`,
|
||||
content, status: 'GENERATED', templateId: documentTemplate.id,
|
||||
} },
|
||||
id: true, name: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, documentId: createDocument.id, content, missingTokens };
|
||||
};
|
||||
```
|
||||
|
||||
`loadRecordValues` runs a different query for a Person vs. a Company and flattens
|
||||
the result — see
|
||||
[`load-record-values.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/load-record-values.ts).
|
||||
|
||||
## Expose it as a tool and a workflow action
|
||||
|
||||
A single `defineLogicFunction` can carry several triggers. Here, `toolTriggerSettings`
|
||||
makes it callable by AI agents, and `workflowActionTriggerSettings` turns it into a
|
||||
step in the visual workflow builder. Both describe their input with a JSON schema.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document.ts"
|
||||
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';
|
||||
|
||||
export default defineLogicFunction({
|
||||
universalIdentifier: GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document',
|
||||
description: 'Generate a document from a template and a CRM record.',
|
||||
timeoutSeconds: 30,
|
||||
toolTriggerSettings: {
|
||||
inputSchema: generateDocumentInputSchema,
|
||||
},
|
||||
workflowActionTriggerSettings: {
|
||||
label: 'Generate Document',
|
||||
icon: 'IconFileText',
|
||||
inputSchema: jsonSchemaToInputSchema(generateDocumentInputSchema),
|
||||
outputSchema: [{ type: 'object', properties: {
|
||||
success: { type: 'boolean' }, documentId: { type: 'string' },
|
||||
} }],
|
||||
},
|
||||
handler: generateDocumentHandler,
|
||||
});
|
||||
```
|
||||
|
||||
The input schema is a plain JSON schema describing `templateId` and `recordId` —
|
||||
see [`generate-document-input.schema.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/schemas/generate-document-input.schema.ts).
|
||||
|
||||
## Grant it access
|
||||
|
||||
Logic functions run as the app's role. It needs to read templates and records
|
||||
and create documents, so allow that in `src/roles/default-role.ts`:
|
||||
|
||||
```ts
|
||||
export default defineApplicationRole({
|
||||
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
|
||||
label: 'Document Generator default role',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canAccessAllTools: true,
|
||||
canBeAssignedToAgents: true,
|
||||
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
|
||||
});
|
||||
```
|
||||
|
||||
`UPLOAD_FILE` lets the function upload the generated PDF in the next section.
|
||||
See [Roles](/developers/extend/apps/config/roles) for finer-grained permissions.
|
||||
|
||||
## Attach a real PDF file
|
||||
|
||||
A rendered text field is useful, but users want a real document. Let's generate a
|
||||
**PDF** and store it on the record as a downloadable file.
|
||||
|
||||
First, give the `document` object a `FILES` field to hold the PDF. Apps upload
|
||||
into their **own** files fields, so this field is what routes the upload:
|
||||
|
||||
```ts filename="src/objects/document.object.ts"
|
||||
{
|
||||
universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
type: FieldType.FILES,
|
||||
name: 'file',
|
||||
label: 'File',
|
||||
icon: 'IconFileTypePdf',
|
||||
universalSettings: { maxNumberOfValues: 1 },
|
||||
}
|
||||
```
|
||||
|
||||
Now render that PDF. An app is a real Node project, so you can add any npm
|
||||
package you need and import it like anywhere else. We use **[pdf-lib](https://pdf-lib.js.org/)**
|
||||
to draw the PDF and **[marked](https://marked.js.org/)** to parse the Markdown
|
||||
body — the CLI installs them into the function's runtime for you:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn add pdf-lib marked
|
||||
```
|
||||
|
||||
The full helper is
|
||||
[`generate-document-pdf.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/logic-functions/utils/generate-document-pdf.ts).
|
||||
It parses the Markdown into tokens with `marked.lexer`, then lays them out with
|
||||
pdf-lib: real headings, **bold**/*italic* runs, bullet and numbered lists,
|
||||
blockquotes and rules — a polished, multi-page A4 rendering of the template
|
||||
itself, rather than a wall of text.
|
||||
|
||||
<Frame caption="The generated PDF: real typography and Markdown formatting, rendering the template body.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07b-generated-pdf.png" alt="A polished, marketable generated PDF" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
pdf-lib's built-in fonts use WinAnsi encoding, so Western-European accents render
|
||||
out of the box; the helper maps smart quotes and dashes and drops characters it
|
||||
can't encode. Rendering non-Latin scripts (Chinese, Arabic, Cyrillic) would mean
|
||||
embedding a Unicode font.
|
||||
</Note>
|
||||
|
||||
Then upload it and store the reference on the record. `uploadFile` routes bytes
|
||||
to your app-owned files field; the returned `id` is what you save:
|
||||
|
||||
```ts filename="src/logic-functions/handlers/generate-document-handler.ts"
|
||||
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
|
||||
import { generateDocumentPdf } from 'src/logic-functions/utils/generate-document-pdf';
|
||||
|
||||
const documentName = `${documentTemplate.name} — ${record.displayName}`;
|
||||
const bytes = await generateDocumentPdf(documentName, content);
|
||||
const fileName = 'proposal.pdf';
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The generated document now carries a downloadable PDF:
|
||||
|
||||
<Frame caption="The generated PDF, stored on the document's File field.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/08-document-with-pdf.png" alt="A document record with a generated PDF file" />
|
||||
</Frame>
|
||||
|
||||
<Note>
|
||||
`uploadFile` only targets **app-owned** files fields (so uploads always require an
|
||||
app that owns the field, plus the `UPLOAD_FILE` role flag). That's why the PDF
|
||||
lands on the record's own `file` field — the same pattern the
|
||||
[call-recorder app](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/public/call-recorder)
|
||||
uses for recordings.
|
||||
</Note>
|
||||
|
||||
**After this step:** each generated document has a real, downloadable PDF. But
|
||||
nothing can *call* the generator from the UI yet — for that we need an HTTP route.
|
||||
|
||||
<Card title="Next: HTTP routes →" icon="globe" href="/developers/extend/apps/tutorials/document-generator/http-routes">
|
||||
Serve the function over HTTP and render documents as web pages.
|
||||
</Card>
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
title: 3. HTTP routes
|
||||
icon: "globe"
|
||||
description: Trigger the function over HTTP and render documents as web pages.
|
||||
---
|
||||
|
||||
The same handler can also answer HTTP requests. We'll add two routes:
|
||||
|
||||
- a **POST** endpoint the UI calls to generate a document, and
|
||||
- a public **GET** endpoint that renders a document as a printable web page.
|
||||
|
||||
Both use `httpRouteTriggerSettings`. App routes are served under `/s` on your
|
||||
Twenty server (e.g. `http://localhost:2020/s/documents/generate`).
|
||||
|
||||
## POST route — generate on demand
|
||||
|
||||
This reuses `generateDocumentHandler`, so there's no logic to repeat — just a thin
|
||||
adapter that reads the request body.
|
||||
|
||||
```ts filename="src/logic-functions/generate-document-route.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const body = event.body as Record<string, unknown> | null;
|
||||
|
||||
const result = await generateDocumentHandler({
|
||||
templateId: (body?.templateId as string) ?? '',
|
||||
recordId: (body?.recordId as string) ?? '',
|
||||
});
|
||||
|
||||
// Map the handler's failure reason onto a real HTTP status (400/404/500)
|
||||
// instead of always returning 200.
|
||||
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',
|
||||
timeoutSeconds: 30,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/generate',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The shared handler returns a suggested `status` on failure, so the route can
|
||||
answer with a proper `4xx`/`5xx` code. `isAuthRequired: true` means the caller
|
||||
must present a valid token — the front component in the next chapter passes the
|
||||
user's access token automatically.
|
||||
|
||||
## GET route — render as a web page
|
||||
|
||||
To return HTML instead of JSON, wrap the body in a `Response` with a
|
||||
`Content-Type` header. This route is public (`isAuthRequired: false`) so a
|
||||
generated document can be shared as a link.
|
||||
|
||||
```ts filename="src/logic-functions/view-document.ts"
|
||||
import { defineLogicFunction, type RoutePayload } from 'twenty-sdk/define';
|
||||
import { Response } from 'twenty-sdk/logic-function';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
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' } });
|
||||
|
||||
const handler = async (event: RoutePayload): Promise<Response> => {
|
||||
const documentId = event.queryStringParameters?.id;
|
||||
|
||||
if (!documentId) {
|
||||
return htmlResponse(documentHtmlPage('Missing document id', 'Provide ?id=<documentId>.'), 400);
|
||||
}
|
||||
|
||||
// Filtered list query so an unknown id renders a clean 404 page instead of throwing.
|
||||
const { documents } = await new CoreApiClient().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',
|
||||
timeoutSeconds: 15,
|
||||
handler,
|
||||
httpRouteTriggerSettings: {
|
||||
path: '/documents/view',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`documentHtmlPage` renders the Markdown body to HTML (with [marked](https://marked.js.org/),
|
||||
sanitized) and drops it into a clean, printable page that shows just the template
|
||||
content — the same look as the PDF and the in-app preview.
|
||||
[See the helper](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/examples/document-generator/src/utils/render-document.ts).
|
||||
|
||||
## Try it
|
||||
|
||||
With a template and a Person in your workspace, call the route (grab a token from
|
||||
**Settings → APIs & Webhooks**):
|
||||
|
||||
```bash filename="Terminal"
|
||||
curl -X POST http://localhost:2020/s/documents/generate \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"templateId":"<templateId>","recordId":"<personId>"}'
|
||||
# → {"success":true,"documentId":"...","content":"Dear Jeffery Griffin, ..."}
|
||||
```
|
||||
|
||||
Open the returned document in your browser:
|
||||
|
||||
```
|
||||
http://localhost:2020/s/documents/view?id=<documentId>
|
||||
```
|
||||
|
||||
<Frame caption="The public GET route renders the document as a printable page.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="A rendered document web page" />
|
||||
</Frame>
|
||||
|
||||
<Tip>
|
||||
You can also stream a function's logs while testing with
|
||||
`yarn twenty dev:function:logs`, or invoke it directly with
|
||||
`yarn twenty dev:function:exec`.
|
||||
</Tip>
|
||||
|
||||
**After this step:** the app can generate documents over HTTP and serve them as
|
||||
web pages. Now let's make it usable without `curl`.
|
||||
|
||||
<Card title="Next: building the UI →" icon="table-columns" href="/developers/extend/apps/tutorials/document-generator/building-the-ui">
|
||||
Views, navigation, a command, and a front component.
|
||||
</Card>
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Tutorial: Document Generator"
|
||||
icon: "wand-magic-sparkles"
|
||||
description: Build a real Twenty app that generates personalized documents from your CRM data.
|
||||
---
|
||||
|
||||
In this tutorial you'll build **Document Generator** — an app that turns reusable
|
||||
templates into personalized documents using the data already in your CRM.
|
||||
|
||||
Write a template once with `{{placeholders}}`, then generate a filled-in
|
||||
document for any Person or Company in one click — from the command menu, from an
|
||||
AI agent, or from a workflow.
|
||||
|
||||
<Frame caption="One template, generated for a specific person, opened as a printable page.">
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/07-rendered-document.png" alt="A generated Sales proposal document" />
|
||||
</Frame>
|
||||
|
||||
## What you'll learn
|
||||
|
||||
Each chapter adds one capability. By the end you'll have touched most of the SDK.
|
||||
|
||||
| Chapter | Capability | Reference |
|
||||
|---|---|---|
|
||||
| [1. Data model](/developers/extend/apps/tutorials/document-generator/data-model) | Objects, fields, and a relation | [Data](/developers/extend/apps/data/overview) |
|
||||
| [2. Generating documents](/developers/extend/apps/tutorials/document-generator/generating-documents) | A logic function (AI tool + workflow action) that fills a Markdown template and attaches a polished PDF | [Logic functions](/developers/extend/apps/logic/logic-functions) |
|
||||
| [3. HTTP routes](/developers/extend/apps/tutorials/document-generator/http-routes) | Serving JSON and a shareable HTML page from routes | [Logic functions](/developers/extend/apps/logic/logic-functions) |
|
||||
| [4. Building the UI](/developers/extend/apps/tutorials/document-generator/building-the-ui) | Views, navigation, command menu, and front components that preview a document and edit a template | [Layout](/developers/extend/apps/layout/overview) |
|
||||
| [5. An AI agent](/developers/extend/apps/tutorials/document-generator/ai-agent) | Agent + skill | [Skills & agents](/developers/extend/apps/logic/skills-and-agents) |
|
||||
| [6. Publishing](/developers/extend/apps/tutorials/document-generator/publishing) | Ship it to the marketplace | [Publishing](/developers/extend/apps/operations/publishing) |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You should have finished the [Quick Start](/developers/extend/apps/getting-started/quick-start):
|
||||
a local Twenty server running on port `2020` and the CLI authenticated to it.
|
||||
|
||||
If not, scaffold and start one now:
|
||||
|
||||
```bash filename="Terminal"
|
||||
npx create-twenty-app@latest document-generator
|
||||
cd document-generator
|
||||
yarn twenty dev
|
||||
```
|
||||
|
||||
<Note>
|
||||
Prefer to read the finished code? The complete app lives in
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
Every snippet below is copied from it.
|
||||
</Note>
|
||||
|
||||
## How the app fits together
|
||||
|
||||
<Frame>
|
||||
<img src="/images/docs/developers/extends/apps/document-generator/how-it-fits.svg" alt="A template with placeholders is generated into a polished document with a PDF, triggered from the command menu, an AI agent, a workflow, or a shareable link" />
|
||||
</Frame>
|
||||
|
||||
You write a **template** once in a rich-text editor, with `{{placeholders}}`. Picking a
|
||||
template and a CRM record fills the placeholders and stores a polished
|
||||
**document** (with a PDF file). Everything else — the command menu, the AI agent,
|
||||
the workflow step, the shareable link — is just a different way to trigger that
|
||||
one generator.
|
||||
|
||||
## Keep this loop running
|
||||
|
||||
Leave `yarn twenty dev` running in a terminal for the whole tutorial. Every time
|
||||
you add or edit a file under `src/`, it re-syncs to your server within a few
|
||||
seconds, so you can watch each capability appear in the UI as you build it.
|
||||
|
||||
<Card title="Start building →" icon="database" href="/developers/extend/apps/tutorials/document-generator/data-model">
|
||||
Chapter 1: model documents and templates.
|
||||
</Card>
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: 6. Publishing
|
||||
icon: "rocket"
|
||||
description: Add marketplace metadata and publish your app.
|
||||
---
|
||||
|
||||
Your app works. The last step is to describe it for the marketplace and publish.
|
||||
|
||||
## Add marketplace metadata
|
||||
|
||||
The [application config](/developers/extend/apps/config/application) carries the
|
||||
identity that shows up in the marketplace: author, category, logo, and support
|
||||
links. Put a logo in `public/` and reference it with `logoUrl`.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
import { defineApplication } from 'twenty-sdk/define';
|
||||
|
||||
export default defineApplication({
|
||||
universalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
displayName: 'Document Generator',
|
||||
description:
|
||||
'Create reusable document templates and generate personalized documents from your CRM records.',
|
||||
logoUrl: 'public/document-generator.svg',
|
||||
author: 'Twenty',
|
||||
category: 'Productivity',
|
||||
websiteUrl: 'https://docs.twenty.com/developers/extend/apps',
|
||||
termsUrl: 'https://www.twenty.com/terms',
|
||||
emailSupport: 'contact@twenty.com',
|
||||
issueReportUrl: 'https://github.com/twentyhq/twenty/issues',
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
The default role is declared with `defineApplicationRole()` in its own file — you
|
||||
don't pass `defaultRoleUniversalIdentifier` here anymore.
|
||||
</Tip>
|
||||
|
||||
Also add the `twenty-app` keyword to `package.json` so the app is discoverable:
|
||||
|
||||
```json filename="package.json"
|
||||
{ "keywords": ["twenty-app"] }
|
||||
```
|
||||
|
||||
## Add gallery screenshots
|
||||
|
||||
A marketplace listing sells itself with screenshots. Drop a few PNGs in
|
||||
`public/gallery/` and reference them with `screenshots` — they render as a gallery
|
||||
on the listing page.
|
||||
|
||||
```ts filename="src/application-config.ts"
|
||||
export default defineApplication({
|
||||
// ...identity from above
|
||||
screenshots: [
|
||||
'public/gallery/01-generated-document.png',
|
||||
'public/gallery/02-command-menu.png',
|
||||
'public/gallery/03-template-editor.png',
|
||||
'public/gallery/04-documents.png',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Lead with the payoff: make the first screenshot the finished result (a generated
|
||||
document), then show how it's triggered and authored. Use crisp, high-resolution
|
||||
captures — they're the first thing a user sees.
|
||||
</Tip>
|
||||
|
||||
Give `README.md` the same treatment — it's the front page on npm and GitHub.
|
||||
Open with the value proposition and a screenshot, list the headline features,
|
||||
then keep the build details below the fold.
|
||||
|
||||
## Check before you ship
|
||||
|
||||
Run the same gates CI does:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn lint # oxlint
|
||||
yarn typecheck # tsgo
|
||||
yarn test:unit # unit tests
|
||||
yarn twenty dev --once --dry-run # preview the metadata diff
|
||||
```
|
||||
|
||||
The dry run prints exactly what would change on the server without applying it —
|
||||
a good final sanity check. See
|
||||
[Testing](/developers/extend/apps/operations/testing) and
|
||||
[Syncing & recovery](/developers/extend/apps/operations/sync-and-recovery).
|
||||
|
||||
## Publish
|
||||
|
||||
```bash filename="Terminal"
|
||||
# Public app → npm (default)
|
||||
yarn twenty app:publish
|
||||
|
||||
# Or deploy privately to a specific server's registry
|
||||
yarn twenty app:publish --private -r <remote>
|
||||
```
|
||||
|
||||
`app:publish` builds and publishes to npm by default; `--private` uploads a
|
||||
tarball to a Twenty server's private registry instead. To surface a published app
|
||||
in an instance's marketplace, trigger a catalog sync:
|
||||
|
||||
```bash filename="Terminal"
|
||||
yarn twenty dev:catalog-sync -r <remote>
|
||||
```
|
||||
|
||||
Full details and the release checklist:
|
||||
[Publishing](/developers/extend/apps/operations/publishing).
|
||||
|
||||
## You built an app 🎉
|
||||
|
||||
In six chapters you used most of the SDK surface:
|
||||
|
||||
- **Objects, fields, and a relation** to model the data
|
||||
- A **logic function** exposed as an **AI tool**, a **workflow action**, and **HTTP routes**
|
||||
- **Views, navigation, a command, and a front component** for the UI
|
||||
- An **agent + skill** for natural-language generation
|
||||
- **Marketplace metadata** and the publish flow
|
||||
|
||||
The finished app is at
|
||||
[`packages/twenty-apps/examples/document-generator`](https://github.com/twentyhq/twenty/tree/main/packages/twenty-apps/examples/document-generator).
|
||||
|
||||
## Where to go next
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Data reference" icon="database" href="/developers/extend/apps/data/overview">
|
||||
Every field type, relation, and index option.
|
||||
</Card>
|
||||
<Card title="Logic reference" icon="bolt" href="/developers/extend/apps/logic/overview">
|
||||
Cron and database-event triggers, the key-value store, OAuth connections.
|
||||
</Card>
|
||||
<Card title="Layout reference" icon="table-columns" href="/developers/extend/apps/layout/overview">
|
||||
Page layouts, dashboard widgets, and more UI surfaces.
|
||||
</Card>
|
||||
<Card title="Operations" icon="rocket" href="/developers/extend/apps/operations/overview">
|
||||
CLI, testing, remotes, and CI.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
@@ -385,6 +385,18 @@
|
||||
"developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Tutorial",
|
||||
"pages": [
|
||||
"developers/extend/apps/tutorials/document-generator/overview",
|
||||
"developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Config",
|
||||
"pages": [
|
||||
|
||||
|
After Width: | Height: | Size: 258 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 579 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 343 KiB |
@@ -0,0 +1,76 @@
|
||||
<svg width="900" height="380" viewBox="0 0 900 380" fill="none" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif">
|
||||
<defs>
|
||||
<linearGradient id="band" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="#1961ED"/>
|
||||
<stop offset="1" stop-color="#6B9BFF"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||
<feDropShadow dx="0" dy="6" stdDeviation="10" flood-color="#18274B" flood-opacity="0.10"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Template card -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="40" y="70" width="200" height="180" rx="14" fill="#FFFFFF" stroke="#E4E9F4"/>
|
||||
<rect x="40" y="70" width="200" height="8" rx="4" fill="#C4A2E0"/>
|
||||
<text x="64" y="112" font-size="15" font-weight="700" fill="#10152A">Template</text>
|
||||
<text x="64" y="140" font-size="12" fill="#7A46C6" font-family="monospace">Dear {{firstName}},</text>
|
||||
<rect x="64" y="152" width="150" height="9" rx="4.5" fill="#EEF1F6"/>
|
||||
<rect x="64" y="170" width="120" height="9" rx="4.5" fill="#EEF1F6"/>
|
||||
<text x="64" y="200" font-size="12" fill="#7A46C6" font-family="monospace">{{company.name}}</text>
|
||||
<rect x="64" y="212" width="140" height="9" rx="4.5" fill="#EEF1F6"/>
|
||||
</g>
|
||||
<text x="140" y="278" font-size="12.5" fill="#6B7280" text-anchor="middle">Write once, with placeholders</text>
|
||||
|
||||
<!-- Generate hub -->
|
||||
<g>
|
||||
<circle cx="450" cy="160" r="46" fill="#1961ED"/>
|
||||
<circle cx="450" cy="160" r="46" fill="none" stroke="#1961ED" stroke-opacity="0.25" stroke-width="10"/>
|
||||
<path d="M436 160l9 9 18-19" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<text x="450" y="230" font-size="14" font-weight="700" fill="#10152A" text-anchor="middle">Generate</text>
|
||||
</g>
|
||||
|
||||
<!-- arrows -->
|
||||
<path d="M248 160H392" stroke="#C4C9D6" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M384 154l10 6-10 6" fill="#C4C9D6"/>
|
||||
<path d="M504 160H648" stroke="#C4C9D6" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<path d="M640 154l10 6-10 6" fill="#C4C9D6"/>
|
||||
|
||||
<!-- Document card -->
|
||||
<g filter="url(#shadow)">
|
||||
<rect x="660" y="60" width="200" height="200" rx="14" fill="#FFFFFF" stroke="#E4E9F4"/>
|
||||
<rect x="660" y="60" width="200" height="8" rx="4" fill="url(#band)"/>
|
||||
<text x="684" y="102" font-size="15" font-weight="700" fill="#10152A">Dear Ada,</text>
|
||||
<rect x="684" y="116" width="150" height="9" rx="4.5" fill="#EEF1F6"/>
|
||||
<rect x="684" y="134" width="150" height="9" rx="4.5" fill="#EEF1F6"/>
|
||||
<rect x="684" y="152" width="110" height="9" rx="4.5" fill="#EEF1F6"/>
|
||||
<rect x="684" y="178" width="60" height="9" rx="4.5" fill="#EEF1F6"/>
|
||||
<rect x="684" y="196" width="140" height="9" rx="4.5" fill="#EEF1F6"/>
|
||||
<g>
|
||||
<rect x="756" y="216" width="80" height="26" rx="13" fill="#FDECEC"/>
|
||||
<text x="796" y="233" font-size="12" font-weight="700" fill="#D6455D" text-anchor="middle">PDF file</text>
|
||||
</g>
|
||||
</g>
|
||||
<text x="760" y="288" font-size="12.5" fill="#6B7280" text-anchor="middle">A polished, shareable document</text>
|
||||
|
||||
<!-- Trigger pills -->
|
||||
<text x="450" y="312" font-size="12.5" fill="#6B7280" text-anchor="middle">Trigger it from anywhere</text>
|
||||
<g font-size="12.5" font-weight="600" fill="#3A4256">
|
||||
<g>
|
||||
<rect x="196" y="330" width="120" height="34" rx="17" fill="#F4F6FB" stroke="#E4E9F4"/>
|
||||
<text x="256" y="352" text-anchor="middle">Command menu</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="330" y="330" width="92" height="34" rx="17" fill="#F4F6FB" stroke="#E4E9F4"/>
|
||||
<text x="376" y="352" text-anchor="middle">AI agent</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="436" y="330" width="128" height="34" rx="17" fill="#F4F6FB" stroke="#E4E9F4"/>
|
||||
<text x="500" y="352" text-anchor="middle">Workflow step</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="578" y="330" width="126" height="34" rx="17" fill="#F4F6FB" stroke="#E4E9F4"/>
|
||||
<text x="641" y="352" text-anchor="middle">Shareable link</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -382,6 +382,19 @@
|
||||
"developers/extend/apps/getting-started/troubleshooting"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "appsTutorial",
|
||||
"label": "Tutorial",
|
||||
"pages": [
|
||||
"developers/extend/apps/tutorials/document-generator/overview",
|
||||
"developers/extend/apps/tutorials/document-generator/data-model",
|
||||
"developers/extend/apps/tutorials/document-generator/generating-documents",
|
||||
"developers/extend/apps/tutorials/document-generator/http-routes",
|
||||
"developers/extend/apps/tutorials/document-generator/building-the-ui",
|
||||
"developers/extend/apps/tutorials/document-generator/ai-agent",
|
||||
"developers/extend/apps/tutorials/document-generator/publishing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "appsConfig",
|
||||
"label": "Config",
|
||||
|
||||
@@ -160,6 +160,9 @@
|
||||
"appsGettingStarted": {
|
||||
"label": "Getting Started"
|
||||
},
|
||||
"appsTutorial": {
|
||||
"label": "Tutorial"
|
||||
},
|
||||
"appsConfig": {
|
||||
"label": "Config"
|
||||
},
|
||||
|
||||
@@ -77,6 +77,20 @@ export const DOCUMENTATION_PATHS = {
|
||||
'/developers/extend/apps/operations/testing',
|
||||
DEVELOPERS_EXTEND_APPS_TRANSLATIONS_OVERVIEW:
|
||||
'/developers/extend/apps/translations/overview',
|
||||
DEVELOPERS_EXTEND_APPS_TUTORIALS_DOCUMENT_GENERATOR_AI_AGENT:
|
||||
'/developers/extend/apps/tutorials/document-generator/ai-agent',
|
||||
DEVELOPERS_EXTEND_APPS_TUTORIALS_DOCUMENT_GENERATOR_BUILDING_THE_UI:
|
||||
'/developers/extend/apps/tutorials/document-generator/building-the-ui',
|
||||
DEVELOPERS_EXTEND_APPS_TUTORIALS_DOCUMENT_GENERATOR_DATA_MODEL:
|
||||
'/developers/extend/apps/tutorials/document-generator/data-model',
|
||||
DEVELOPERS_EXTEND_APPS_TUTORIALS_DOCUMENT_GENERATOR_GENERATING_DOCUMENTS:
|
||||
'/developers/extend/apps/tutorials/document-generator/generating-documents',
|
||||
DEVELOPERS_EXTEND_APPS_TUTORIALS_DOCUMENT_GENERATOR_HTTP_ROUTES:
|
||||
'/developers/extend/apps/tutorials/document-generator/http-routes',
|
||||
DEVELOPERS_EXTEND_APPS_TUTORIALS_DOCUMENT_GENERATOR_OVERVIEW:
|
||||
'/developers/extend/apps/tutorials/document-generator/overview',
|
||||
DEVELOPERS_EXTEND_APPS_TUTORIALS_DOCUMENT_GENERATOR_PUBLISHING:
|
||||
'/developers/extend/apps/tutorials/document-generator/publishing',
|
||||
DEVELOPERS_EXTEND_OAUTH: '/developers/extend/oauth',
|
||||
DEVELOPERS_EXTEND_WEBHOOKS: '/developers/extend/webhooks',
|
||||
DEVELOPERS_INTRODUCTION: '/developers/introduction',
|
||||
|
||||