snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -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

View File

@@ -0,0 +1 @@
24.5.0

View File

@@ -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"
}
}

View File

@@ -0,0 +1 @@
nodeLinker: node-modules

View File

@@ -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.

View File

@@ -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.31.0",
"twenty-sdk": "2.31.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"
}
}

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -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();
});
});

View File

@@ -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'}`,
);
}
}

View File

@@ -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(' '),
});

View File

@@ -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,
logo: 'public/document-generator.svg',
galleryImages: [
'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',
});

View File

@@ -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,
});

View File

@@ -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';

View File

@@ -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',
},
});

View File

@@ -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,
},
});

View File

@@ -0,0 +1,127 @@
import { type CSSProperties, useEffect, useState } from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { RestApiClient } from 'twenty-client-sdk/rest';
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 = new RestApiClient().resolveUrl('/s/documents/view', {
query: { 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,
});

View File

@@ -0,0 +1,318 @@
import {
type CSSProperties,
type SyntheticEvent,
useCallback,
useEffect,
useState,
} from 'react';
import { CoreApiClient } from 'twenty-client-sdk/core';
import { RestApiClient } from 'twenty-client-sdk/rest';
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 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 new RestApiClient().post<GenerateResponse>(
'/s/documents/generate',
{ 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,
});

View File

@@ -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,
},
});

View File

@@ -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,
});

View File

@@ -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,
};
};

View File

@@ -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,
};

View File

@@ -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-');
});
});

View File

@@ -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']);
});
});

View File

@@ -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();
};

View File

@@ -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;

View File

@@ -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] };
};

View File

@@ -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,
},
});

View File

@@ -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,
});

View File

@@ -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,
});

View File

@@ -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,
},
],
},
],
});

View File

@@ -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 },
},
],
});

View File

@@ -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,
},
},
],
},
],
});

View File

@@ -0,0 +1,82 @@
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',
position: {
layoutMode: PageLayoutTabLayoutMode.GRID,
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.VERTICAL_LIST,
widgets: [
{
universalIdentifier: TIMELINE_PAGE_LAYOUT_WIDGET_UNIVERSAL_IDENTIFIER,
title: 'Timeline',
type: 'TIMELINE',
configuration: {
configurationType: 'TIMELINE',
},
},
],
},
],
});

View File

@@ -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],
});

View 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'),
});

View File

@@ -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('![alt](https://evil.example/x.png)');
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('&quot;');
expect(html).toContain('&amp;');
});
});
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('&lt;script&gt;');
});
});

View File

@@ -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>
);

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
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>`;

View File

@@ -0,0 +1,46 @@
import { defineView } 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',
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,
},
],
});

View File

@@ -0,0 +1,36 @@
import { defineView } 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',
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,
},
],
});

View File

@@ -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"
}
]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"composite": true,
"types": ["vitest/globals", "node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -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,
},
},
});

View File

@@ -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'],
},
});

File diff suppressed because it is too large Load Diff