Zum Hauptinhalt springen
Jetzt der Kern: eine Logikfunktion , die eine Vorlage und einen Datensatz lädt, die Platzhalter füllt und ein neues Dokument speichert. Wir werden die Geschäftslogik einmal als Handler schreiben und sie dann durch mehrere Trigger ausblenden. Dieses Kapitel verbindet zwei davon – ein AI-Tool und eine Workflow-Aktion.

Der Rendering-Helfer

Behalten Sie die reine Logik in ihrer eigenen Datei, so dass es leicht zu Unit-Test ist. Dies flattert einen Eintrag in {{dot.path}} Token und ersetzt diese.
const PLACEHOLDER_PATTERN = /\{\{\s*([\w.]+)\s*\}\}/g;

export const renderTemplate = (body: string, values: Record<string, string>) => {
  const missingTokens = new Set<string>();
  const content = body.replace(PLACEHOLDER_PATTERN, (_m, token: string) => {
    const value = values[token];
    if (value === undefined || value === '') { missingTokens.add(token); return ''; }
    return value;
  });
  return { content, missingTokens: [...missingTokens] };
};
Da diese Datei keine Nebenwirkungen hat, können Sie sie mit schnellen Einheitstests (Garn test:unit) bedecken. Siehe Testing.

Der Handler

Der Handler verwendet die generierte CoreApiClient zum Lesen und Schreiben von CRM-Daten. Es lädt die Vorlage, lädt den Zieldatensatz, füllt den Körper und erstellt ein “Dokument”.
import { CoreApiClient } from 'twenty-client-sdk/core';
import { loadRecordValues } from 'src/logic-functions/utils/load-record-values';
import { renderTemplate } from 'src/logic-functions/utils/render-template';

export const generateDocumentHandler = async (
  input: { templateId: string; recordId: string },
) => {
  const client = new CoreApiClient();

  // Use a filtered list query, not the singular lookup: the singular query
  // throws when nothing matches, which would become a 500 instead of a 404.
  const { documentTemplates } = await client.query({
    documentTemplates: {
      __args: { filter: { id: { eq: input.templateId } }, first: 1 },
      edges: { node: { id: true, name: true, body: true, target: true } },
    },
  });
  const documentTemplate = documentTemplates?.edges?.[0]?.node;
  if (!documentTemplate?.id) return { success: false, status: 404, message: 'Template not found.' };

  const record = await loadRecordValues(client, documentTemplate.target, input.recordId);
  if (!record.found) return { success: false, status: 404, message: 'Record not found.' };

  const { content, missingTokens } = renderTemplate(documentTemplate.body ?? '', record.values);

  const { createDocument } = await client.mutation({
    createDocument: {
      __args: { data: {
        name: `${documentTemplate.name}${record.displayName}`,
        content, status: 'GENERATED', templateId: documentTemplate.id,
      } },
      id: true, name: true,
    },
  });

  return { success: true, documentId: createDocument.id, content, missingTokens };
};
loadRecordValues führt eine andere Abfrage für eine Person gegen eine Firma aus und flattert das Ergebnis — siehe load-record-values.ts.

Als Werkzeug und Workflow-Aktion anzeigen

Eine einzelne defineLogicFunction kann mehrere Trigger tragen. Hier macht toolTriggerSettings es von KI-Agenten aufrufbar und workflowActionTriggerSettings verwandelt es in einen Schritt im visuellen Workflow-Builder. Beide beschreiben ihre Eingabe mit einem JSON-Schema.
import { defineLogicFunction } from 'twenty-sdk/define';
import { jsonSchemaToInputSchema } from 'twenty-sdk/logic-function';
import { GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { generateDocumentHandler } from 'src/logic-functions/handlers/generate-document-handler';
import { generateDocumentInputSchema } from 'src/logic-functions/schemas/generate-document-input.schema';

export default defineLogicFunction({
  universalIdentifier: GENERATE_DOCUMENT_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
  name: 'generate-document',
  description: 'Generate a document from a template and a CRM record.',
  timeoutSeconds: 30,
  toolTriggerSettings: {
    inputSchema: generateDocumentInputSchema,
  },
  workflowActionTriggerSettings: {
    label: 'Generate Document',
    icon: 'IconFileText',
    inputSchema: jsonSchemaToInputSchema(generateDocumentInputSchema),
    outputSchema: [{ type: 'object', properties: {
      success: { type: 'boolean' }, documentId: { type: 'string' },
    } }],
  },
  handler: generateDocumentHandler,
});
Das Eingabeschema ist ein einfaches JSON-Schema, das templateId und recordId — siehe generate-document-input.schema.ts.

Zugriff gewähren

Die logischen Funktionen laufen als Rolle der App. Es muss Vorlagen lesen und Datensätze und Dokumente erstellen, also erlauben Sie dies in src/roles/default-role.ts:
export default defineApplicationRole({
  universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
  label: 'Document Generator default role',
  canReadAllObjectRecords: true,
  canUpdateAllObjectRecords: true,
  canAccessAllTools: true,
  canBeAssignedToAgents: true,
  permissionFlagUniversalIdentifiers: [SystemPermissionFlag.UPLOAD_FILE],
});
UPLOAD_FILE lässt die Funktion das generierte PDF im nächsten Abschnitt hochladen. Siehe Roles für feinkörnige Berechtigungen.

Eine echte PDF-Datei anhängen

Ein gerendertes Textfeld ist nützlich, aber Benutzer wollen ein echtes Dokument. Generieren wir ein PDF und speichern es als herunterladbare Datei. Gib zuerst das document Objekt ein FILES-Feld um die PDF zu halten. Apps laden in ihre eigenen Datei-Felder hoch. Daher ist dieses Feld was das Hochladen leitet:
{
  universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
  type: FieldType.FILES,
  name: 'file',
  label: 'File',
  icon: 'IconFileTypePdf',
  universalSettings: { maxNumberOfValues: 1 },
}
Rendern Sie nun diese PDF. Eine App ist ein echtes Knoten-Projekt, so dass Sie jedes npm Paket hinzufügen und es wie überall sonst importieren können. Wir verwenden pdf-lib um die PDF zu zeichnen und marked um den Markdown Körper zu analysieren — der CLI installiert sie in die Laufzeit der Funktion für Sie:
yarn add pdf-lib marked
Der ganze Helfer ist generate-document-pdf.ts. Es analysiert Markdown in Tokens mit markiert. exer, legt sie dann mit pdf-lib: echte Überschriften, fett/kursiv läuft, Kugel und nummerierte Listen, Blockzitate und Regeln — eine polierte, mehrseitige A4 Darstellung der Vorlage selbst statt einer Wand aus Text.
Ein poliertes, markierbares PDF
Die in pdf-lib integrierten Schriftarten verwenden die WinAnsi-Codierung, sodass westeuropäische Akzentzeichen standardmäßig korrekt dargestellt werden; die Hilfsfunktion ordnet typografische Anführungszeichen und Gedankenstriche zu und verwirft Zeichen, die nicht codiert werden können. Das Rendern nicht-lateinischer Skripte (chinesisch, arabisch, kyrillisch) würde bedeuten, dass eine Unicode-Schriftart einbettet.
Dann laden Sie es hoch und speichern Sie die Referenz auf dem Datensatz. uploadFile ruft Bytes in dein app-owned Datei-Feld zurück; die zurückgegebene id ist, was du speicherst:
import { MetadataApiClient } from 'twenty-client-sdk/metadata';
import { generateDocumentPdf } from 'src/logic-functions/utils/generate-document-pdf';

const documentName = `${documentTemplate.name}${record.displayName}`;
const bytes = await generateDocumentPdf(documentName, content);
const fileName = 'proposal.pdf';

const uploaded = await new MetadataApiClient().uploadFile(
  Buffer.from(bytes),
  fileName,
  'application/pdf',
  DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
);

await client.mutation({
  updateDocument: {
    __args: {
      id: documentId,
      data: { file: [{ fileId: uploaded.id, label: fileName }] },
    },
    id: true,
  },
});
Das generierte Dokument enthält jetzt ein herunterladbares PDF:
Ein Datensatz mit einer generierten PDF-Datei
uploadFile zielt nur auf app-besitzer Datei-Felder (Uploads erfordern also immer eine -App, die das Feld besitzt, plus das UPLOAD_FILE Rollenflag). Aus diesem Grund landet das PDF auf das eigene Feld file des Datensatzes — das gleiche Muster wie die call-recorder app verwendet für Aufnahmen.
Nach diesem Schritt: Jedes generierte Dokument hat eine echte, herunterladbare PDF. Aber nichts kann den Generator noch von der Oberfläche aufrufen – dafür benötigen wir eine HTTP-Route.

Weiter: HTTP-Routen →

Servieren Sie die Funktion über HTTP und rendern Sie Dokumente als Webseiten.