Passer au contenu principal
Maintenant le cœur : une fonction logique qui charge un modèle et un enregistrement, remplit les marqueurs et enregistre un nouveau document . Nous allons écrire la logique commerciale une fois en tant que gestionnaire, puis l’exposer à travers plusieurs déclencheurs. Ce chapitre connecte deux d’entre eux — un outil d’IA et une action de flux de travail.

L’assistant de rendu

Gardez une logique pure dans son propre fichier donc il est facile de le tester. Cela aplanit un record en jetons {{dot.path}} et les substitue.
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] };
};
Comme ce fichier n’a pas d’effets secondaires, vous pouvez le couvrir avec des tests unitaires rapides (yarn test:unit). Voir Testing.

Le gestionnaire

Le gestionnaire utilise le CoreApiClient généré pour lire et écrire les données CRM. Il charge le modèle, charge l’enregistrement cible, remplit le corps, et crée un document.
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 exécute une requête différente pour une personne par rapport à une entreprise et aplatit le résultat — voir load-record-values.ts.

Exposer comme un outil et une action de workflow

Une seule defineLogicFunction peut contenir plusieurs déclencheurs. Ici, toolTriggerSettings le rend appelable par les agents AI, et workflowActionTriggerSettings le transforme en une étape dans le constructeur de workflow visuel. Les deux décrivent leur entrée avec un schéma JSON.
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,
});
Le schéma d’entrée est un schéma JSON simple décrivant templateId et recordId — voir generate-document-input.schema.ts.

Accorder l’accès

Les fonctions logiques s’exécutent en tant que rôle de l’application. Il a besoin de lire les modèles et les enregistrements et de créer des documents, donc permettez cela dans 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 permet à la fonction d’envoyer le PDF généré dans la section suivante. Voir Roles pour des autorisations plus fines.

Joindre un vrai fichier PDF

Un champ de texte rendu est utile, mais les utilisateurs veulent un vrai document. Nous allons générer un PDF et le stocker dans l’enregistrement en tant que fichier téléchargeable. Premièrement, donnez à l’objet document un champ FILES pour contenir le PDF. Les applications téléchargent dans leurs champs propres de fichiers, donc ce champ permet de router le téléchargement:
{
  universalIdentifier: DOCUMENT_FILE_FIELD_UNIVERSAL_IDENTIFIER,
  type: FieldType.FILES,
  name: 'file',
  label: 'File',
  icon: 'IconFileTypePdf',
  universalSettings: { maxNumberOfValues: 1 },
}
Maintenant, renvoie ce PDF. Une application est un vrai projet Node, vous pouvez donc ajouter n’importe quel package npm dont vous avez besoin et l’importer n’importe où ailleurs. Nous utilisons pdf-lib pour dessiner le PDF et marked pour analyser le corps du Markdown — le CLI les installe dans le runtime de la fonction pour vous :
yarn add pdf-lib marked
L’aide complète est generate-document-pdf.ts. Il analyse le Markdown en jetons avec marked. exer, puis les pose avec pdf-lib: de vraies rubriques, gras/italique exécute, puces et listes numérotées, blockquotes et règles — un rendu A4 polyvalent et polyvalent du modèle lui-même, plutôt qu’un mur de texte.
Un PDF brossé et commercialisable généré
Les polices intégrées de pdf-lib’s utilisent l’encodage WinAnsi, de sorte que les accents WesternEuropean rendent hors de la boite; l’aide mappe les guillemets intelligents et les tirets et drops les caractères qu’elle ne peut pas encoder. Le rendu de scripts non latins (chinois, arabe, cyrillique) impliquerait d’intégrer une police Unicode.
Ensuite téléchargez-le et stockez la référence sur l’enregistrement. uploadFile achemine les octets vers votre champ de fichiers appartenant à l’application; le id retourné est ce que vous sauvegardez:
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,
  },
});
Le document généré possède maintenant un PDF téléchargeable:
Un enregistrement de document avec un fichier PDF généré
uploadFile ne cible que les champs de fichiers app-owned (donc les téléchargements nécessitent toujours une application qui possède le champ, plus le paramètre UPLOAD_FILE). C’est pourquoi le PDF se trouve sur le propre champ file de l’enregistrement — le même motif que l’application call-recorder app utilise pour les enregistrements.
Après cette étape: chaque document généré a un PDF réel, téléchargeable. Mais rien ne peut appeler le générateur de l’interface utilisateur — pour cela nous avons besoin d’une route HTTP.

Suivant : Routes HTTP →

Servez la fonction via HTTP et rendez les documents en tant que pages Web.