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>
This commit is contained in:
+35
@@ -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,
|
||||
},
|
||||
});
|
||||
+38
@@ -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,
|
||||
});
|
||||
+198
@@ -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,
|
||||
};
|
||||
};
|
||||
+20
@@ -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,
|
||||
};
|
||||
+43
@@ -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-');
|
||||
});
|
||||
});
|
||||
+64
@@ -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']);
|
||||
});
|
||||
});
|
||||
+327
@@ -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();
|
||||
};
|
||||
+92
@@ -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;
|
||||
+58
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user