feat: implement skills system for AI agents (#16865)

## Summary
This PR introduces a Skills system for AI agents, inspired by the [Agent
Skills specification](https://agentskills.io/specification).

## Changes

### Backend
- **SkillEntity**: New database entity with migration for storing skills
- **V2 Sync Mechanism**: Implemented FlatSkill, builders, validators,
and action handlers following the v2 flat entity pattern
- **Standard Skills**: Pre-defined skills (workflow-building,
data-manipulation, dashboard-building, metadata-building, research,
code-interpreter, xlsx, pdf, docx, pptx)
- **GraphQL API**: CRUD operations for skills with proper guards and
permissions
- **Workspace Cache**: Integrated skills into the workspace cache system

### Frontend  
- **Skills Table**: Searchable table in AI settings showing all skills
- **Skill Form**: Create/edit page with Label (primary), Description,
and Content (markdown editor)
- **API Name**: Following existing patterns, name is derived from label
with advanced settings toggle for custom API names
- **Standard vs Custom**: Standard skills are read-only, custom skills
can be edited/deleted

## Key Design Decisions
- Skills are stored in the database (Salesforce-like approach) rather
than files
- Name is derived from Label by default (isLabelSyncedWithName pattern)
- Skills reference functions/files via @ mentions in markdown content
rather than explicit relations
- Standard skills are synced from code, custom skills are created via UI

## Screenshots
Skills table and form UI follow existing settings patterns.

## Testing
- [x] Lint passes
- [x] Typecheck passes
- [ ] CI tests
This commit is contained in:
Félix Malfait
2026-01-02 15:22:01 +01:00
committed by GitHub
parent 2a3fd788ae
commit 21ff42074d
146 changed files with 6964 additions and 1282 deletions
@@ -1,6 +0,0 @@
export type SkillDefinition = {
name: string;
label: string;
description: string;
content: string;
};
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { SkillsService } from './skills.service';
@Module({
providers: [SkillsService],
exports: [SkillsService],
})
export class SkillsModule {}
@@ -1,66 +0,0 @@
import { Injectable } from '@nestjs/common';
import { SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
import { CODE_INTERPRETER_SKILL } from 'src/engine/core-modules/skills/skills/code-interpreter.skill';
import { DASHBOARD_BUILDING_SKILL } from 'src/engine/core-modules/skills/skills/dashboard-building.skill';
import { DATA_MANIPULATION_SKILL } from 'src/engine/core-modules/skills/skills/data-manipulation.skill';
import { DOCX_SKILL } from 'src/engine/core-modules/skills/skills/docx.skill';
import { METADATA_BUILDING_SKILL } from 'src/engine/core-modules/skills/skills/metadata-building.skill';
import { PDF_SKILL } from 'src/engine/core-modules/skills/skills/pdf.skill';
import { PPTX_SKILL } from 'src/engine/core-modules/skills/skills/pptx.skill';
import { RESEARCH_SKILL } from 'src/engine/core-modules/skills/skills/research.skill';
import { WORKFLOW_BUILDING_SKILL } from 'src/engine/core-modules/skills/skills/workflow-building.skill';
import { XLSX_SKILL } from 'src/engine/core-modules/skills/skills/xlsx.skill';
const SKILL_DEFINITIONS: SkillDefinition[] = [
WORKFLOW_BUILDING_SKILL,
DATA_MANIPULATION_SKILL,
DASHBOARD_BUILDING_SKILL,
METADATA_BUILDING_SKILL,
RESEARCH_SKILL,
CODE_INTERPRETER_SKILL,
XLSX_SKILL,
PDF_SKILL,
DOCX_SKILL,
PPTX_SKILL,
];
export type Skill = {
name: string;
label: string;
description: string;
content: string;
};
@Injectable()
export class SkillsService {
getAllSkills(): Skill[] {
return SKILL_DEFINITIONS.map((skill) => ({
name: skill.name,
label: skill.label,
description: skill.description,
content: skill.content,
}));
}
getSkillByName(name: string): Skill | undefined {
const skillDef = SKILL_DEFINITIONS.find((skill) => skill.name === name);
if (!skillDef) {
return undefined;
}
return {
name: skillDef.name,
label: skillDef.label,
description: skillDef.description,
content: skillDef.content,
};
}
getSkillsByNames(names: string[]): Skill[] {
return names
.map((name) => this.getSkillByName(name))
.filter((skill): skill is Skill => skill !== undefined);
}
}
@@ -1,105 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const CODE_INTERPRETER_SKILL: SkillDefinition = {
name: 'code-interpreter',
label: 'Code Interpreter',
description:
'Python code execution for data analysis, complex multi-step operations, and efficient bulk processing via MCP bridge',
content: `# Code Interpreter Skill
You have access to the \`code_interpreter\` tool to execute Python code in a sandboxed environment.
## How to Use
Call the \`code_interpreter\` tool with your Python code. The tool will execute the code and return stdout, stderr, and any generated files.
## Capabilities
- Analyze CSV, Excel, and JSON data files
- Create charts and visualizations (matplotlib, seaborn)
- Generate reports (PDF, PPTX, Excel)
- Perform calculations and data transformations
## Pre-installed Libraries
pandas, numpy, matplotlib, seaborn, scikit-learn, openpyxl, python-pptx
## Input Files
- User-uploaded files are available at \`/home/user/{filename}\`
- Always check the file exists before processing
## Output Files
- Charts: Save to \`/home/user/output/\` directory - these are automatically returned as downloadable URLs
- For matplotlib: \`plt.savefig('/home/user/output/chart.png')\`
- Generated files: Save to \`/home/user/output/{filename}\`
## Example: Create a Bar Chart
\`\`\`python
import matplotlib.pyplot as plt
import os
# Data
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']
sales = [100, 150, 200, 175, 250, 300]
# Create chart
plt.figure(figsize=(10, 6))
plt.bar(months, sales, color='skyblue')
plt.title('Monthly Sales')
plt.xlabel('Month')
plt.ylabel('Sales')
plt.tight_layout()
# Save to output directory
os.makedirs('/home/user/output', exist_ok=True)
plt.savefig('/home/user/output/sales_chart.png')
print('Chart saved!')
\`\`\`
## Example: Analyze CSV
\`\`\`python
import pandas as pd
import matplotlib.pyplot as plt
import os
# Load data
df = pd.read_csv('/home/user/data.csv')
print(f"Loaded {len(df)} rows")
# Create visualization
plt.figure(figsize=(10, 6))
df.groupby('category')['value'].mean().plot(kind='bar')
plt.title('Average Value by Category')
plt.tight_layout()
os.makedirs('/home/user/output', exist_ok=True)
plt.savefig('/home/user/output/analysis.png')
print('Analysis complete!')
\`\`\`
## Calling Twenty Tools from Python (MCP Bridge)
A \`twenty\` helper is automatically available in your code. Use it to call any Twenty tool directly from Python:
\`\`\`python
# Find records
people = twenty.call_tool('find_person_records', {'limit': 10})
print(f"Found {len(people['edges'])} people")
# Create a record
result = twenty.call_tool('create_company_record', {
'data': {'name': 'Acme Corp', 'domainName': {'primaryLinkUrl': 'acme.com'}}
})
print(f"Created company: {result['id']}")
# Update a record
twenty.call_tool('update_person_record', {
'id': 'person-uuid',
'data': {'jobTitle': 'CEO'}
})
# List available tools
tools = twenty.list_tools()
for tool in tools:
print(f"- {tool['name']}: {tool['description']}")
\`\`\`
This allows you to orchestrate complex multi-step operations in a single code execution, which is more efficient than multiple tool calls.`,
};
@@ -1,78 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const DASHBOARD_BUILDING_SKILL: SkillDefinition = {
name: 'dashboard-building',
label: 'Dashboard Building',
description: 'Creating and managing dashboards with widgets and layouts',
content: `# Dashboard Building Skill
You help users create and manage dashboards with widgets.
## CRITICAL: Creating GRAPH Widgets
Before creating any GRAPH widget, you MUST:
1. Use list_object_metadata_items to get the objectMetadataId (e.g., for "opportunity", "company")
2. From the response, get the field IDs you need (aggregateFieldMetadataId, primaryAxisGroupByFieldMetadataId)
GRAPH widgets require real UUIDs from the workspace metadata, NOT made-up values.
## Widget Configuration
### GRAPH - AGGREGATE (KPI numbers)
Shows a single aggregated value (count, sum, average).
Required:
- objectMetadataId: UUID of the object (e.g., opportunity)
- configuration.graphType: "AGGREGATE"
- configuration.aggregateFieldMetadataId: UUID of field to aggregate
- configuration.aggregateOperation: "COUNT", "SUM", "AVG", "MIN", "MAX"
### GRAPH - BAR/LINE Charts
Shows data grouped by a dimension.
Required:
- objectMetadataId: UUID of the object
- configuration.graphType: "VERTICAL_BAR", "HORIZONTAL_BAR", or "LINE"
- configuration.aggregateFieldMetadataId: field to aggregate
- configuration.aggregateOperation: aggregation type
- configuration.primaryAxisGroupByFieldMetadataId: field to group by (x-axis)
### GRAPH - PIE Charts
Shows data distribution as slices.
Required:
- objectMetadataId: UUID of the object
- configuration.graphType: "PIE"
- configuration.aggregateFieldMetadataId: field to aggregate
- configuration.aggregateOperation: aggregation type
- configuration.groupByFieldMetadataId: field to slice by
### IFRAME
Embeds external content:
- configuration.url: "https://..."
### STANDALONE_RICH_TEXT
Text content widget:
- configuration.body: "Your text here"
## Grid System
- 12 columns (0-11)
- KPI widgets: rowSpan 2-4, columnSpan 3-4
- Charts: rowSpan 6-8, columnSpan 6-12
- Common layouts:
- 4 KPIs in a row: each { columnSpan: 3 }
- 2 charts side by side: each { columnSpan: 6 }
- Full width chart: { column: 0, columnSpan: 12 }
## Workflow
1. Ask user what data they want to visualize
2. Load list_object_metadata_items to discover available objects and fields
3. Create dashboard with appropriate widgets using real field IDs
4. Use get_dashboard to verify creation
## Best Practices
- Place KPIs at the top (row 0)
- Group related charts together
- Use consistent heights within rows
- Start simple, add complexity as needed`,
};
@@ -1,44 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const DATA_MANIPULATION_SKILL: SkillDefinition = {
name: 'data-manipulation',
label: 'Data Manipulation',
description:
'Searching, filtering, creating, and updating records across all objects',
content: `# Data Manipulation Skill
You explore and manage data across companies, people, opportunities, tasks, notes, and custom objects.
## Capabilities
- Search, filter, sort, create, update records
- Manage relationships between records
- Bulk operations and data analysis
## Constraints
- READ and WRITE access to all objects
- CANNOT delete records or access workflow objects
- CANNOT modify workspace settings
## Multi-step Approach
- Chain queries to solve complex requests (e.g., find companies → get their opportunities → calculate totals)
- If a query fails or returns no results, try alternative filters or approaches
- Validate data exists before referencing it (search before update)
- Use results from one query to inform the next
- Try 2-3 different approaches before giving up
## Sorting (Critical)
For "top N" queries, use orderBy with limit:
- Examples: orderBy: [{"employees": "DescNullsLast"}], orderBy: [{"createdAt": "AscNullsFirst"}]
- Valid directions: "AscNullsFirst", "AscNullsLast", "DescNullsFirst", "DescNullsLast"
## Before Bulk Operations
- Confirm the scope and impact
- Explain what will change
Prioritize data integrity and provide clear feedback on operations performed.`,
};
@@ -1,152 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const DOCX_SKILL: SkillDefinition = {
name: 'docx',
label: 'Word Documents',
description:
'Word document creation, editing, template processing, and OOXML manipulation',
content: `# Word Document Processing Skill
**IMPORTANT**: Save all output files to \`/home/user/output/\` for them to be downloadable.
## Pre-installed Scripts (OOXML Editing)
- \`python /home/user/scripts/docx/unpack.py <docx_file> <output_dir>\` - Unpack .docx to XML files for direct editing
- \`python /home/user/scripts/docx/pack.py <input_dir> <docx_file>\` - Repack XML files into .docx
- \`python /home/user/scripts/docx/validate.py <docx_file>\` - Validate document structure
### Validation Scripts
- \`/home/user/scripts/docx/validation/docx.py\` - DOCX validation module
- \`/home/user/scripts/docx/validation/redlining.py\` - Track changes/redline validation
## High-Level API (python-docx)
### Reading Documents
\`\`\`python
from docx import Document
doc = Document('document.docx')
# Read paragraphs
for para in doc.paragraphs:
print(para.text)
# Read tables
for table in doc.tables:
for row in table.rows:
for cell in row.cells:
print(cell.text)
\`\`\`
### Creating Documents
\`\`\`python
from docx import Document
from docx.shared import Inches, Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
doc = Document()
# Add heading
doc.add_heading('Document Title', 0)
# Add paragraph with formatting
para = doc.add_paragraph('Normal text. ')
run = para.add_run('Bold text.')
run.bold = True
# Add table
table = doc.add_table(rows=2, cols=2)
table.cell(0, 0).text = 'Header 1'
table.cell(0, 1).text = 'Header 2'
# Add image
doc.add_picture('image.png', width=Inches(4))
doc.save('/home/user/output/output.docx')
\`\`\`
## Low-Level OOXML Editing
For complex edits (tracked changes, custom XML), use the unpack/edit/pack workflow:
### Step 1: Unpack
\`\`\`bash
python /home/user/scripts/docx/unpack.py document.docx ./unpacked/
\`\`\`
### Step 2: Edit XML directly
\`\`\`python
import xml.etree.ElementTree as ET
tree = ET.parse('./unpacked/word/document.xml')
root = tree.getroot()
# Edit XML...
# Namespaces: w = http://schemas.openxmlformats.org/wordprocessingml/2006/main
tree.write('./unpacked/word/document.xml', xml_declaration=True, encoding='UTF-8')
\`\`\`
### Step 3: Validate & Repack
\`\`\`bash
python /home/user/scripts/docx/validate.py ./unpacked/
python /home/user/scripts/docx/pack.py ./unpacked/ /home/user/output/output.docx
\`\`\`
## Template Processing
### Find and Replace
\`\`\`python
from docx import Document
doc = Document('template.docx')
for para in doc.paragraphs:
if '{{name}}' in para.text:
para.text = para.text.replace('{{name}}', 'John Doe')
doc.save('/home/user/output/filled.docx')
\`\`\`
### Preserve Formatting During Replace
\`\`\`python
def replace_in_paragraph(para, old_text, new_text):
"""Replace text while preserving formatting"""
for run in para.runs:
if old_text in run.text:
run.text = run.text.replace(old_text, new_text)
for para in doc.paragraphs:
replace_in_paragraph(para, '{{name}}', 'John Doe')
\`\`\`
## Working with Styles
\`\`\`python
from docx.shared import Pt, RGBColor
# Set font
run.font.name = 'Arial'
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0, 0, 0)
# Paragraph formatting
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
para.paragraph_format.space_before = Pt(12)
para.paragraph_format.space_after = Pt(12)
\`\`\`
## Quick Reference
| Task | Tool | Example |
|------|------|---------|
| Read document | python-docx | \`Document('file.docx')\` |
| Create document | python-docx | \`Document()\` |
| Add heading | python-docx | \`doc.add_heading('Title', 0)\` |
| Add table | python-docx | \`doc.add_table(rows=2, cols=2)\` |
| Unpack for editing | script | \`python unpack.py doc.docx ./out/\` |
| Repack | script | \`python pack.py ./out/ doc.docx\` |
| Validate | script | \`python validate.py doc.docx\` |`,
};
@@ -1,64 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const METADATA_BUILDING_SKILL: SkillDefinition = {
name: 'metadata-building',
label: 'Metadata Building',
description:
'Managing the data model: creating objects, fields, and relations',
content: `# Metadata Building Skill
You help users manage their workspace data model by creating, updating, and organizing custom objects and fields.
## Capabilities
- Create new custom objects with appropriate naming and configuration
- Add fields to existing objects (text, number, date, select, relation, etc.)
- Update object and field properties (labels, descriptions, icons)
- Manage field settings (required, unique, default values)
- Create relations between objects
## Key Concepts
- **Objects**: Represent entities in the data model (e.g., Company, Person, Opportunity)
- **Fields**: Properties of objects with specific types (TEXT, NUMBER, DATE_TIME, SELECT, RELATION, etc.)
- **Relations**: Links between objects (one-to-many, many-to-one)
- **Labels vs Names**: Labels are for display, names are internal identifiers (camelCase)
## Field Types Available
- **TEXT**: Simple text fields
- **NUMBER**: Numeric values (integers or decimals)
- **BOOLEAN**: True/false values
- **DATE_TIME**: Date and time values
- **DATE**: Date only values
- **SELECT**: Single choice from options
- **MULTI_SELECT**: Multiple choices from options
- **LINK**: URL fields
- **LINKS**: Multiple URL fields
- **EMAIL**: Email address fields
- **EMAILS**: Multiple email fields
- **PHONE**: Phone number fields
- **PHONES**: Multiple phone fields
- **CURRENCY**: Monetary values
- **RATING**: Star ratings
- **RELATION**: Links to other objects
- **RICH_TEXT**: Formatted text content
## Best Practices
- Use clear, descriptive names for objects and fields
- Follow naming conventions: singular for object names, camelCase for field names
- Add helpful descriptions to objects and fields
- Choose appropriate field types for the data being stored
- Consider relationships between objects when designing the data model
## Approach
- Ask clarifying questions to understand the user's data modeling needs
- Suggest best practices for naming and organization
- Explain the impact of changes to the data model
- Verify object and field existence before making updates
- Provide clear feedback on operations performed
Prioritize data model integrity and user understanding.`,
};
@@ -1,131 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const PDF_SKILL: SkillDefinition = {
name: 'pdf',
label: 'PDF Processing',
description:
'PDF form filling, field extraction, table parsing, and validation',
content: `# PDF Processing Skill
**IMPORTANT**: Save all output files to \`/home/user/output/\` for them to be downloadable.
## Pre-installed Scripts
### Field Extraction
- \`python /home/user/scripts/pdf/extract_form_field_info.py <pdf_file>\` - Extract all fillable field names and types (JSON output)
- \`python /home/user/scripts/pdf/check_fillable_fields.py <pdf_file>\` - Check if PDF has fillable fields
### Form Filling
- \`python /home/user/scripts/pdf/fill_fillable_fields.py <pdf_file> <json_data> <output_file>\` - Fill PDF form fields
- \`python /home/user/scripts/pdf/fill_pdf_form_with_annotations.py <pdf_file> <json_data> <output_file>\` - Fill with annotation support
### Validation
- \`python /home/user/scripts/pdf/create_validation_image.py <pdf_file>\` - Create validation image of filled PDF
- \`python /home/user/scripts/pdf/check_bounding_boxes.py <pdf_file>\` - Check field boundaries
- \`python /home/user/scripts/pdf/convert_pdf_to_images.py <pdf_file>\` - Convert PDF pages to images
## Reading PDFs
\`\`\`python
import fitz # PyMuPDF
# Open PDF
doc = fitz.open('document.pdf')
# Extract text from all pages
for page in doc:
text = page.get_text()
print(text)
# Extract text from specific page
page = doc[0] # First page
text = page.get_text()
\`\`\`
## Extracting Tables
\`\`\`python
import pdfplumber
with pdfplumber.open('document.pdf') as pdf:
for page in pdf.pages:
tables = page.extract_tables()
for table in tables:
for row in table:
print(row)
\`\`\`
## Filling PDF Forms
### Step 1: Extract field information
\`\`\`bash
python /home/user/scripts/pdf/extract_form_field_info.py form.pdf > fields.json
\`\`\`
### Step 2: Create fill data JSON
\`\`\`json
{
"field_name_1": "value1",
"field_name_2": "value2",
"checkbox_field": true
}
\`\`\`
### Step 3: Fill the form
\`\`\`bash
python /home/user/scripts/pdf/fill_fillable_fields.py form.pdf fill_data.json /home/user/output/output.pdf
\`\`\`
### Step 4: Validate the output
\`\`\`bash
python /home/user/scripts/pdf/create_validation_image.py /home/user/output/output.pdf
\`\`\`
## Creating PDFs
\`\`\`python
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
c = canvas.Canvas('/home/user/output/output.pdf', pagesize=letter)
c.drawString(100, 750, 'Hello World!')
c.save()
\`\`\`
## Merging PDFs
\`\`\`python
from PyPDF2 import PdfMerger
merger = PdfMerger()
merger.append('file1.pdf')
merger.append('file2.pdf')
merger.write('/home/user/output/merged.pdf')
merger.close()
\`\`\`
## Splitting PDFs
\`\`\`python
from PyPDF2 import PdfReader, PdfWriter
reader = PdfReader('document.pdf')
# Extract specific pages
writer = PdfWriter()
writer.add_page(reader.pages[0]) # First page
writer.write('/home/user/output/page1.pdf')
\`\`\`
## Quick Reference
| Task | Tool | Command/Example |
|------|------|-----------------|
| Extract text | PyMuPDF | \`page.get_text()\` |
| Extract tables | pdfplumber | \`page.extract_tables()\` |
| List form fields | script | \`python extract_form_field_info.py form.pdf\` |
| Fill form | script | \`python fill_fillable_fields.py form.pdf data.json out.pdf\` |
| Validate fill | script | \`python create_validation_image.py filled.pdf\` |
| Create PDF | reportlab | \`canvas.Canvas('out.pdf')\` |
| Merge PDFs | PyPDF2 | \`PdfMerger()\` |`,
};
@@ -1,170 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const PPTX_SKILL: SkillDefinition = {
name: 'pptx',
label: 'PowerPoint',
description:
'PowerPoint creation, editing, templates, thumbnails, and slide manipulation',
content: `# PowerPoint Processing Skill
**IMPORTANT**: Save all output files to \`/home/user/output/\` for them to be downloadable.
## Pre-installed Scripts
- \`python /home/user/scripts/pptx/thumbnail.py <pptx_file> [output_dir]\` - Generate slide thumbnails
- \`python /home/user/scripts/pptx/rearrange.py <pptx_file> <slide_order_json> <output_file>\` - Reorder slides
- \`python /home/user/scripts/pptx/inventory.py <pptx_file>\` - List all slides and their content
- \`python /home/user/scripts/pptx/replace.py <pptx_file> <replacements_json> <output_file>\` - Find/replace text
## Reading Presentations
\`\`\`python
from pptx import Presentation
prs = Presentation('presentation.pptx')
# Iterate through slides
for slide in prs.slides:
for shape in slide.shapes:
if shape.has_text_frame:
print(shape.text)
\`\`\`
## Creating Presentations
\`\`\`python
from pptx import Presentation
from pptx.util import Inches, Pt
prs = Presentation()
# Add title slide
slide_layout = prs.slide_layouts[0] # Title layout
slide = prs.slides.add_slide(slide_layout)
title = slide.shapes.title
subtitle = slide.placeholders[1]
title.text = "Presentation Title"
subtitle.text = "Subtitle goes here"
# Add content slide
slide_layout = prs.slide_layouts[1] # Title and content
slide = prs.slides.add_slide(slide_layout)
title = slide.shapes.title
body = slide.placeholders[1]
title.text = "Slide Title"
tf = body.text_frame
tf.text = "First bullet"
p = tf.add_paragraph()
p.text = "Second bullet"
p.level = 1
prs.save('/home/user/output/output.pptx')
\`\`\`
## Adding Images
\`\`\`python
from pptx.util import Inches
slide = prs.slides.add_slide(prs.slide_layouts[6]) # Blank layout
slide.shapes.add_picture(
'image.png',
left=Inches(1),
top=Inches(1),
width=Inches(5)
)
\`\`\`
## Adding Tables
\`\`\`python
from pptx.util import Inches
slide = prs.slides.add_slide(prs.slide_layouts[6])
table = slide.shapes.add_table(
rows=3, cols=3,
left=Inches(1), top=Inches(1),
width=Inches(8), height=Inches(2)
).table
# Set cell values
table.cell(0, 0).text = "Header 1"
table.cell(0, 1).text = "Header 2"
table.cell(1, 0).text = "Data 1"
\`\`\`
## Adding Charts
\`\`\`python
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE
from pptx.util import Inches
chart_data = CategoryChartData()
chart_data.categories = ['East', 'West', 'Midwest']
chart_data.add_series('Series 1', (19.2, 21.4, 16.7))
slide = prs.slides.add_slide(prs.slide_layouts[6])
chart = slide.shapes.add_chart(
XL_CHART_TYPE.COLUMN_CLUSTERED,
Inches(1), Inches(1), Inches(8), Inches(5),
chart_data
).chart
\`\`\`
## Using Scripts
### Generate Thumbnails
\`\`\`bash
python /home/user/scripts/pptx/thumbnail.py presentation.pptx ./thumbnails/
# Creates: thumbnails/slide_1.png, slide_2.png, etc.
\`\`\`
### Get Slide Inventory
\`\`\`bash
python /home/user/scripts/pptx/inventory.py presentation.pptx
# Returns JSON with all slide content and shapes
\`\`\`
### Reorder Slides
\`\`\`bash
# Order: [3, 1, 2] means slide 3 becomes first, slide 1 second, etc.
python /home/user/scripts/pptx/rearrange.py input.pptx '[3, 1, 2]' output.pptx
\`\`\`
### Find and Replace Text
\`\`\`bash
python /home/user/scripts/pptx/replace.py input.pptx '{"{{company}}": "Acme Corp", "{{date}}": "2024"}' output.pptx
\`\`\`
## Template Processing Workflow
1. **Generate thumbnails** to understand slide structure:
\`\`\`bash
python /home/user/scripts/pptx/thumbnail.py template.pptx ./preview/
\`\`\`
2. **Get inventory** to find placeholder text:
\`\`\`bash
python /home/user/scripts/pptx/inventory.py template.pptx
\`\`\`
3. **Replace placeholders**:
\`\`\`bash
python /home/user/scripts/pptx/replace.py template.pptx '{"{{title}}": "Q4 Report"}' output.pptx
\`\`\`
## Quick Reference
| Task | Tool | Example |
|------|------|---------|
| Read presentation | python-pptx | \`Presentation('file.pptx')\` |
| Create presentation | python-pptx | \`Presentation()\` |
| Add slide | python-pptx | \`prs.slides.add_slide(layout)\` |
| Generate thumbnails | script | \`python thumbnail.py pres.pptx ./out/\` |
| Get slide inventory | script | \`python inventory.py pres.pptx\` |
| Reorder slides | script | \`python rearrange.py pres.pptx '[2,1,3]' out.pptx\` |
| Find/replace | script | \`python replace.py pres.pptx '{...}' out.pptx\` |`,
};
@@ -1,34 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const RESEARCH_SKILL: SkillDefinition = {
name: 'research',
label: 'Research',
description: 'Finding information and gathering facts from the web',
content: `# Research Skill
You find information and gather facts from the web.
## Capabilities
- Search for current information and facts
- Research companies, people, technologies, trends
- Gather competitive intelligence and market data
- Find contact details and verify information
## Research Strategy
- Try multiple search queries from different angles
- If initial searches fail, use alternative search terms
- Cross-reference information when possible
- Cite sources and provide context
## Present Findings
- Be thorough but concise
- Organize information logically
- Distinguish facts from speculation
- Note if information might be outdated
- Include relevant sources
Be persistent in finding accurate information.`,
};
@@ -1,62 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const WORKFLOW_BUILDING_SKILL: SkillDefinition = {
name: 'workflow-building',
label: 'Workflow Building',
description:
'Creating and managing automation workflows with triggers and steps',
content: `# Workflow Building Skill
You help users create and manage automation workflows.
## Capabilities
- Create workflows from scratch
- Modify existing workflows (add, remove, update steps)
- Explain workflow structure and suggest improvements
## Key Concepts
- **Triggers**: DATABASE_EVENT, MANUAL, CRON, WEBHOOK
- **Steps**: CREATE_RECORD, SEND_EMAIL, CODE, etc.
- **Data flow**: Use {{stepId.fieldName}} to reference previous step outputs
- **Relationships**: Use nested objects like {"company": {"id": "{{reference}}"}}
## CRON Trigger Settings Schema
For CRON triggers, settings.type must be one of these exact values:
1. **DAYS** - Daily schedule
- Requires: schedule: { day: number (1+), hour: number (0-23), minute: number (0-59) }
- Example: { type: "DAYS", schedule: { day: 1, hour: 9, minute: 0 }, outputSchema: {} }
2. **HOURS** - Hourly schedule (USE THIS FOR "EVERY HOUR")
- Requires: schedule: { hour: number (1+), minute: number (0-59) }
- Example: { type: "HOURS", schedule: { hour: 1, minute: 0 }, outputSchema: {} }
- This runs every X hours at Y minutes past the hour
3. **MINUTES** - Minute-based schedule
- Requires: schedule: { minute: number (1+) }
- Example: { type: "MINUTES", schedule: { minute: 15 }, outputSchema: {} }
4. **CUSTOM** - Custom cron pattern
- Requires: pattern: string (cron expression)
- Example: { type: "CUSTOM", pattern: "0 * * * *", outputSchema: {} }
## Critical Notes
Always rely on tool schema definitions:
- The workflow creation tool provides comprehensive schemas with examples
- Follow schema definitions exactly for field names, types, and structures
- Schema includes validation rules and common patterns
## Approach
- Ask clarifying questions to understand user needs
- Suggest appropriate actions for the use case
- Explain each step and why it's needed
- For modifications, understand current structure first
- Ensure workflow logic remains coherent
Prioritize user understanding and workflow effectiveness.`,
};
@@ -1,131 +0,0 @@
import { type SkillDefinition } from 'src/engine/core-modules/skills/skill-definition.type';
export const XLSX_SKILL: SkillDefinition = {
name: 'xlsx',
label: 'Excel & Spreadsheets',
description:
'Excel/spreadsheet creation, editing, and analysis with formulas, formatting, and visualization',
content: `# Excel Processing Skill
**IMPORTANT**: Save all output files to \`/home/user/output/\` for them to be downloadable.
## Pre-installed Scripts
- \`python /home/user/scripts/xlsx/recalc.py <excel_file> [timeout]\` - Recalculate formulas using LibreOffice
## Requirements
### Zero Formula Errors
Every Excel model MUST be delivered with ZERO formula errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?)
### Use Formulas, Not Hardcoded Values
**Always use Excel formulas instead of calculating values in Python and hardcoding them.**
\`\`\`python
# ❌ WRONG - Hardcoding
total = df['Sales'].sum()
sheet['B10'] = total
# ✅ CORRECT - Using formulas
sheet['B10'] = '=SUM(B2:B9)'
\`\`\`
## Reading and Analyzing Data
\`\`\`python
import pandas as pd
# Read Excel
df = pd.read_excel('file.xlsx')
all_sheets = pd.read_excel('file.xlsx', sheet_name=None) # All sheets as dict
# Analyze
df.head()
df.info()
df.describe()
\`\`\`
## Creating New Excel Files
\`\`\`python
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
wb = Workbook()
sheet = wb.active
# Add data
sheet['A1'] = 'Hello'
sheet.append(['Row', 'of', 'data'])
# Add formula
sheet['B2'] = '=SUM(A1:A10)'
# Formatting
sheet['A1'].font = Font(bold=True)
sheet['A1'].fill = PatternFill('solid', start_color='FFFF00')
sheet['A1'].alignment = Alignment(horizontal='center')
# Column width
sheet.column_dimensions['A'].width = 20
wb.save('/home/user/output/output.xlsx')
\`\`\`
## Editing Existing Files
\`\`\`python
from openpyxl import load_workbook
wb = load_workbook('existing.xlsx')
sheet = wb.active
# Modify cells
sheet['A1'] = 'New Value'
sheet.insert_rows(2)
wb.save('/home/user/output/modified.xlsx')
\`\`\`
## Recalculating Formulas (MANDATORY)
After creating/editing files with formulas, run:
\`\`\`bash
python /home/user/scripts/xlsx/recalc.py /home/user/output/output.xlsx
\`\`\`
The script returns JSON with error details:
\`\`\`json
{
"status": "success",
"total_errors": 0,
"total_formulas": 42,
"error_summary": {}
}
\`\`\`
If errors found, fix them and recalculate again.
## Financial Model Color Coding
- **Blue text**: Hardcoded inputs
- **Black text**: Formulas and calculations
- **Green text**: Links from other worksheets
- **Yellow background**: Key assumptions needing attention
## Number Formatting
- Years: Format as text ("2024" not "2,024")
- Currency: Use $#,##0 format
- Percentages: 0.0% format
- Negatives: Use parentheses (123) not minus -123
## Quick Reference
| Task | Tool | Example |
|------|------|---------|
| Read Excel | pandas | \`pd.read_excel('file.xlsx')\` |
| Create Excel | openpyxl | \`Workbook()\` |
| Add formula | openpyxl | \`sheet['B2'] = '=SUM(A1:A10)'\` |
| Recalculate | script | \`python /home/user/scripts/xlsx/recalc.py file.xlsx\` |`,
};
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { type Skill } from 'src/engine/core-modules/skills/skills.service';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
export const LOAD_SKILL_TOOL_NAME = 'load_skill';
@@ -25,7 +25,7 @@ export type LoadSkillResult = {
message: string;
};
export type LoadSkillFunction = (names: string[]) => Skill[];
export type LoadSkillFunction = (names: string[]) => Promise<FlatSkill[]>;
export const createLoadSkillTool = (loadSkills: LoadSkillFunction) => ({
description:
@@ -36,7 +36,7 @@ export const createLoadSkillTool = (loadSkills: LoadSkillFunction) => ({
}): Promise<LoadSkillResult> => {
const { skillNames } = parameters.input;
const skills = loadSkills(skillNames);
const skills = await loadSkills(skillNames);
if (skills.length === 0) {
return {
@@ -1,2 +1,2 @@
// Configuration: $0.00001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000; // 1 / 0.000001 = 1 000 000 credits per dollar
// Configuration: $0.000_001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000; // 1 / 0.000_001 = 1_000_000 credits per dollar
@@ -2,7 +2,7 @@ import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/metadata-modules/ai/ai-b
// Converts cost in cents to cost in credits
// Formula: credits = (cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER
// Where DOLLAR_TO_CREDIT_MULTIPLIER = 1000000 (so $0.00001 = 1 credit)
// Example: 1 cent = (1 / 100) * 1000000 = 10000 credits
// Where DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000 (so $0.000_001 = 1 credit)
// Example: 1 cent = (1 / 100) * 1_000_000 = 10_000 credits
export const convertCentsToBillingCredits = (cents: number): number =>
(cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER;
@@ -8,7 +8,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { SkillsModule } from 'src/engine/core-modules/skills/skills.module';
import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -44,7 +44,7 @@ import { ChatExecutionService } from './services/chat-execution.service';
FileUploadModule,
FileModule,
PermissionsModule,
SkillsModule,
SkillModule,
WorkspaceCacheStorageModule,
WorkspaceCacheModule,
WorkspaceDomainsModule,
@@ -10,6 +10,12 @@ Tool usage strategy:
- Don't give up after first failure - be persistent
- Validate assumptions before making changes
Database vs HTTP tools:
- Use database tools (find_*, create_*, update_*, delete_*) for ALL Twenty CRM data operations
- NEVER guess or construct API URLs - always use the appropriate database tool
- The \`http_request\` tool is ONLY for external third-party APIs (not for Twenty's own data)
- If you need to look up a record, load and use the corresponding find_one_* or find_many_* tool
Error recovery:
- Analyze error messages to understand what went wrong
- Adjust parameters or try different tools
@@ -17,7 +17,6 @@ import { getAppPath } from 'twenty-shared/utils';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { SkillsService } from 'src/engine/core-modules/skills/skills.service';
import {
type ToolIndexEntry,
ToolRegistryService,
@@ -46,6 +45,8 @@ import {
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
export type ChatExecutionOptions = {
workspace: WorkspaceEntity;
@@ -61,7 +62,7 @@ export type ChatExecutionResult = {
modelConfig: AIModelConfig;
};
const COMMON_PRELOAD_TOOLS = ['http_request', 'search_help_center'];
const COMMON_PRELOAD_TOOLS = ['search_help_center'];
@Injectable()
export class ChatExecutionService {
@@ -69,7 +70,7 @@ export class ChatExecutionService {
constructor(
private readonly toolRegistry: ToolRegistryService,
private readonly skillsService: SkillsService,
private readonly skillService: SkillService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly aiBillingService: AIBillingService,
private readonly agentActorContextService: AgentActorContextService,
@@ -108,7 +109,9 @@ export class ChatExecutionService {
{ userId, userWorkspaceId },
);
const skillCatalog = this.skillsService.getAllSkills();
const skillCatalog = await this.skillService.findAllFlatSkills(
workspace.id,
);
this.logger.log(
`Built tool catalog with ${toolCatalog.length} tools, ${skillCatalog.length} skills available`,
@@ -150,7 +153,7 @@ export class ChatExecutionService {
},
),
[LOAD_SKILL_TOOL_NAME]: createLoadSkillTool((skillNames) =>
this.skillsService.getSkillsByNames(skillNames),
this.skillService.findFlatSkillsByNames(skillNames, workspace.id),
),
};
@@ -283,7 +286,7 @@ export class ChatExecutionService {
private buildSystemPrompt(
toolCatalog: ToolIndexEntry[],
skillCatalog: Array<{ name: string; label: string; description: string }>,
skillCatalog: FlatSkill[],
preloadedTools: string[],
contextString?: string,
storedFiles?: Array<{ filename: string; storagePath: string; url: string }>,
@@ -333,15 +336,15 @@ ${filesJson}
In your Python code, access files at \`/home/user/{filename}\`.`;
}
private buildSkillCatalogSection(
skillCatalog: Array<{ name: string; label: string; description: string }>,
): string {
private buildSkillCatalogSection(skillCatalog: FlatSkill[]): string {
if (skillCatalog.length === 0) {
return '';
}
const skillsList = skillCatalog
.map((skill) => `- \`${skill.name}\`: ${skill.description}`)
.map(
(skill) => `- \`${skill.name}\`: ${skill.description ?? skill.label}`,
)
.join('\n');
return `
@@ -1,2 +1,2 @@
// Configuration: $0.00001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000000; // 1 / 0.000001 = 1000000 credits per dollar
// Configuration: $0.00_001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1_000_000; // 1 / 0.00_0001 = 1_000_000 credits per dollar
@@ -13,6 +13,7 @@ import { FLAT_ROLE_TARGET_EDITABLE_PROPERTIES } from 'src/engine/metadata-module
import { FLAT_ROLE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-role/constants/flat-role-editable-properties.constant';
import { FLAT_ROW_LEVEL_PERMISSION_PREDICATE_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-row-level-permission-predicate-group/constants/flat-row-level-permission-predicate-group-editable-properties.constant';
import { FLAT_ROW_LEVEL_PERMISSION_PREDICATE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-row-level-permission-predicate/constants/flat-row-level-permission-predicate-editable-properties.constant';
import { FLAT_SKILL_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-skill/constants/flat-skill-editable-properties.constant';
import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant';
import { FLAT_VIEW_FILTER_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-filter-group/constants/flat-view-filter-group-editable-properties.constant';
import { FLAT_VIEW_FILTER_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-filter/constants/flat-view-filter-editable-properties.constant';
@@ -135,6 +136,10 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = {
],
propertiesToStringify: [],
},
skill: {
propertiesToCompare: [...FLAT_SKILL_EDITABLE_PROPERTIES],
propertiesToStringify: [],
},
rowLevelPermissionPredicate: {
propertiesToCompare: [
...FLAT_ROW_LEVEL_PERMISSION_PREDICATE_EDITABLE_PROPERTIES,
@@ -113,6 +113,7 @@ export const ALL_METADATA_RELATED_METADATA_BY_FOREIGN_KEY = {
},
},
agent: {},
skill: {},
pageLayout: {},
pageLayoutWidget: {
pageLayoutTabId: {
@@ -8,6 +8,10 @@ export const ALL_METADATA_RELATION_PROPERTIES = {
workspace: true,
application: true,
},
skill: {
workspace: true,
application: true,
},
fieldMetadata: {
relationTargetFieldMetadata: true,
relationTargetObjectMetadata: true,
@@ -62,6 +62,7 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
agent: {
role: true,
},
skill: {},
pageLayout: {},
pageLayoutTab: {
pageLayout: true,
@@ -13,6 +13,7 @@ import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page
import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type';
import { type FlatRoleTarget } from 'src/engine/metadata-modules/flat-role-target/types/flat-role-target.type';
import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { type FlatViewFilterGroup } from 'src/engine/metadata-modules/flat-view-filter-group/types/flat-view-filter-group.type';
import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type';
@@ -33,6 +34,7 @@ import { type FlatRowLevelPermissionPredicateGroup } from 'src/engine/metadata-m
import { type FlatRowLevelPermissionPredicate } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate.type';
import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
import { type SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { type ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { type ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { type ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
@@ -113,6 +115,11 @@ import {
type DeleteServerlessFunctionAction,
type UpdateServerlessFunctionAction,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/serverless-function/types/workspace-migration-serverless-function-action-v2.type';
import {
type CreateSkillAction,
type DeleteSkillAction,
type UpdateSkillAction,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/types/workspace-migration-v2-skill-action.type';
import {
type CreateViewFieldAction,
type DeleteViewFieldAction,
@@ -295,6 +302,15 @@ export type AllFlatEntityTypesByMetadataName = {
flatEntity: FlatAgent;
entity: AgentEntity;
};
skill: {
actions: {
created: CreateSkillAction;
updated: UpdateSkillAction;
deleted: DeleteSkillAction;
};
flatEntity: FlatSkill;
entity: SkillEntity;
};
pageLayout: {
actions: {
created: CreatePageLayoutAction;
@@ -0,0 +1,10 @@
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
export const FLAT_SKILL_EDITABLE_PROPERTIES = [
'name',
'label',
'icon',
'description',
'content',
'isActive',
] as const satisfies (keyof FlatSkill)[];
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { WorkspaceFlatSkillMapCacheService } from 'src/engine/metadata-modules/flat-skill/services/workspace-flat-skill-map-cache.service';
import { SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
@Module({
imports: [
TypeOrmModule.forFeature([SkillEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [WorkspaceFlatSkillMapCacheService],
exports: [WorkspaceFlatSkillMapCacheService],
})
export class FlatSkillModule {}
@@ -0,0 +1,44 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { type FlatSkillMaps } from 'src/engine/metadata-modules/flat-skill/types/flat-skill-maps.type';
import { transformSkillEntityToFlatSkill } from 'src/engine/metadata-modules/flat-skill/utils/transform-skill-entity-to-flat-skill.util';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration-v2/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
@Injectable()
@WorkspaceCache('flatSkillMaps')
export class WorkspaceFlatSkillMapCacheService extends WorkspaceCacheProvider<FlatSkillMaps> {
constructor(
@InjectRepository(SkillEntity)
private readonly skillRepository: Repository<SkillEntity>,
) {
super();
}
async computeForCache(workspaceId: string): Promise<FlatSkillMaps> {
const skills = await this.skillRepository.find({
where: { workspaceId },
withDeleted: true,
});
const flatSkillMaps = createEmptyFlatEntityMaps();
for (const skillEntity of skills) {
const flatSkill = transformSkillEntityToFlatSkill(skillEntity);
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: flatSkill,
flatEntityMapsToMutate: flatSkillMaps,
});
}
return flatSkillMaps;
}
}
@@ -0,0 +1,4 @@
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
export type FlatSkillMaps = FlatEntityMaps<FlatSkill>;
@@ -0,0 +1,4 @@
import { type SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type';
export type FlatSkill = FlatEntityFrom<SkillEntity>;
@@ -0,0 +1,45 @@
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type CreateSkillInput } from 'src/engine/metadata-modules/skill/dtos/create-skill.input';
export const fromCreateSkillInputToFlatSkillToCreate = ({
createSkillInput,
workspaceId,
applicationId,
}: {
createSkillInput: CreateSkillInput;
workspaceId: string;
applicationId: string;
}): FlatSkill => {
const now = new Date().toISOString();
const { name, label, icon, description } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
createSkillInput,
['name', 'label', 'icon', 'description'],
);
// Content is markdown - only trim, don't collapse whitespace (preserve newlines)
const content = createSkillInput.content.trim();
const id = v4();
return {
id,
standardId: null,
name,
label,
icon: icon ?? null,
description: description ?? null,
content,
isCustom: true,
isActive: true,
workspaceId,
createdAt: now,
updatedAt: now,
universalIdentifier: id,
applicationId,
};
};
@@ -0,0 +1,38 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import {
SkillException,
SkillExceptionCode,
} from 'src/engine/metadata-modules/skill/skill.exception';
export const fromDeleteSkillInputToFlatSkillOrThrow = ({
flatSkillMaps,
skillId,
}: {
flatSkillMaps: FlatEntityMaps<FlatSkill>;
skillId: string;
}): FlatSkill => {
const existingFlatSkill = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: skillId,
flatEntityMaps: flatSkillMaps,
});
if (!isDefined(existingFlatSkill)) {
throw new SkillException(
'Skill not found',
SkillExceptionCode.SKILL_NOT_FOUND,
);
}
if (!existingFlatSkill.isCustom) {
throw new SkillException(
'Cannot delete standard skill',
SkillExceptionCode.SKILL_IS_STANDARD,
);
}
return existingFlatSkill;
};
@@ -0,0 +1,18 @@
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type SkillDTO } from 'src/engine/metadata-modules/skill/dtos/skill.dto';
export const fromFlatSkillToSkillDto = (flatSkill: FlatSkill): SkillDTO => ({
id: flatSkill.id,
standardId: flatSkill.standardId,
name: flatSkill.name,
label: flatSkill.label,
icon: flatSkill.icon ?? undefined,
description: flatSkill.description ?? undefined,
content: flatSkill.content,
isCustom: flatSkill.isCustom,
isActive: flatSkill.isActive,
workspaceId: flatSkill.workspaceId,
applicationId: flatSkill.applicationId ?? undefined,
createdAt: new Date(flatSkill.createdAt),
updatedAt: new Date(flatSkill.updatedAt),
});
@@ -0,0 +1,50 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FLAT_SKILL_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-skill/constants/flat-skill-editable-properties.constant';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type UpdateSkillInput } from 'src/engine/metadata-modules/skill/dtos/update-skill.input';
import {
SkillException,
SkillExceptionCode,
} from 'src/engine/metadata-modules/skill/skill.exception';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export const fromUpdateSkillInputToFlatSkillToUpdateOrThrow = ({
flatSkillMaps,
updateSkillInput,
}: {
flatSkillMaps: FlatEntityMaps<FlatSkill>;
updateSkillInput: UpdateSkillInput;
}): FlatSkill => {
const existingFlatSkill = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: updateSkillInput.id,
flatEntityMaps: flatSkillMaps,
});
if (!isDefined(existingFlatSkill)) {
throw new SkillException(
'Skill not found',
SkillExceptionCode.SKILL_NOT_FOUND,
);
}
if (!existingFlatSkill.isCustom) {
throw new SkillException(
'Cannot update standard skill',
SkillExceptionCode.SKILL_IS_STANDARD,
);
}
const { id: _id, ...updates } = updateSkillInput;
return {
...mergeUpdateInExistingRecord({
existing: existingFlatSkill,
properties: [...FLAT_SKILL_EDITABLE_PROPERTIES],
update: updates,
}),
updatedAt: new Date().toISOString(),
};
};
@@ -0,0 +1,23 @@
import { type SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
export const transformSkillEntityToFlatSkill = (
skillEntity: SkillEntity,
): FlatSkill => {
return {
createdAt: skillEntity.createdAt.toISOString(),
updatedAt: skillEntity.updatedAt.toISOString(),
id: skillEntity.id,
standardId: skillEntity.standardId,
name: skillEntity.name,
label: skillEntity.label,
icon: skillEntity.icon,
description: skillEntity.description,
content: skillEntity.content,
workspaceId: skillEntity.workspaceId,
isCustom: skillEntity.isCustom,
isActive: skillEntity.isActive,
universalIdentifier: skillEntity.standardId || skillEntity.id,
applicationId: skillEntity.applicationId,
};
};
@@ -15,6 +15,7 @@ import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/ro
import { SearchFieldMetadataModule } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.module';
import { ServerlessFunctionLayerModule } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { SkillModule } from 'src/engine/metadata-modules/skill/skill.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
@@ -27,6 +28,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
SearchFieldMetadataModule,
ServerlessFunctionModule,
ServerlessFunctionLayerModule,
SkillModule,
AiAgentModule,
AiAgentMonitorModule,
AiChatModule,
@@ -47,6 +49,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
ObjectMetadataModule,
SearchFieldMetadataModule,
ServerlessFunctionModule,
SkillModule,
AiAgentModule,
AiChatModule,
ViewModule,
@@ -0,0 +1,31 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
@InputType()
export class CreateSkillInput {
@IsString()
@IsNotEmpty()
@Field()
name: string;
@IsString()
@IsNotEmpty()
@Field()
label: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsNotEmpty()
@Field()
content: string;
}
@@ -0,0 +1,65 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsDateString,
IsNotEmpty,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('Skill')
export class SkillDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@Field(() => UUIDScalarType, { nullable: true })
standardId?: string | null;
@IsString()
@Field()
name: string;
@IsString()
@Field()
label: string;
@IsString()
@Field({ nullable: true })
icon?: string;
@IsString()
@Field({ nullable: true })
description?: string;
@IsString()
@IsNotEmpty()
@Field()
content: string;
@IsBoolean()
@Field()
isCustom: boolean;
@IsBoolean()
@Field()
isActive: boolean;
@HideField()
workspaceId: string;
@Field(() => UUIDScalarType, { nullable: true })
applicationId?: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}
@@ -0,0 +1,49 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateSkillInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
name?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
label?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
content?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isActive?: boolean;
}
@@ -0,0 +1,54 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/types/syncable-entity.interface';
@Entity('skill')
@Index('IDX_SKILL_ID_IS_ACTIVE', ['id', 'isActive'])
@Index('IDX_SKILL_NAME_WORKSPACE_ID_UNIQUE', ['name', 'workspaceId'], {
unique: true,
where: '"isActive" = true',
})
export class SkillEntity
extends SyncableEntity
implements Required<SkillEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'uuid' })
standardId: string | null;
@Column({ nullable: false })
name: string;
@Column({ nullable: false })
label: string;
@Column({ nullable: true, type: 'varchar' })
icon: string | null;
@Column({ nullable: true, type: 'text' })
description: string | null;
@Column({ nullable: false, type: 'text' })
content: string;
@Column({ default: false })
isCustom: boolean;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,20 @@
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { type Observable, catchError } from 'rxjs';
import { skillGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/skill/utils/skill-graphql-api-exception-handler.util';
@Injectable()
export class SkillGraphqlApiExceptionInterceptor implements NestInterceptor {
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next.handle().pipe(catchError(skillGraphqlApiExceptionHandler));
}
}
@@ -0,0 +1,40 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum SkillExceptionCode {
SKILL_NOT_FOUND = 'SKILL_NOT_FOUND',
SKILL_ALREADY_EXISTS = 'SKILL_ALREADY_EXISTS',
SKILL_IS_STANDARD = 'SKILL_IS_STANDARD',
INVALID_SKILL_INPUT = 'INVALID_SKILL_INPUT',
}
const getSkillExceptionUserFriendlyMessage = (code: SkillExceptionCode) => {
switch (code) {
case SkillExceptionCode.SKILL_NOT_FOUND:
return msg`Skill not found.`;
case SkillExceptionCode.SKILL_ALREADY_EXISTS:
return msg`A skill with this name already exists.`;
case SkillExceptionCode.SKILL_IS_STANDARD:
return msg`Standard skills cannot be modified.`;
case SkillExceptionCode.INVALID_SKILL_INPUT:
return msg`Invalid skill input.`;
default:
assertUnreachable(code);
}
};
export class SkillException extends CustomException<SkillExceptionCode> {
constructor(
message: string,
code: SkillExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getSkillExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FlatSkillModule } from 'src/engine/metadata-modules/flat-skill/flat-skill.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { SkillGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/skill/interceptors/skill-graphql-api-exception.interceptor';
import { SkillResolver } from 'src/engine/metadata-modules/skill/skill.resolver';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
@Module({
imports: [
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceMigrationV2Module,
ApplicationModule,
PermissionsModule,
FlatSkillModule,
],
providers: [
SkillService,
SkillResolver,
SkillGraphqlApiExceptionInterceptor,
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
],
exports: [SkillService],
})
export class SkillModule {}
@@ -0,0 +1,81 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateSkillInput } from 'src/engine/metadata-modules/skill/dtos/create-skill.input';
import { SkillDTO } from 'src/engine/metadata-modules/skill/dtos/skill.dto';
import { UpdateSkillInput } from 'src/engine/metadata-modules/skill/dtos/update-skill.input';
import { SkillGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/skill/interceptors/skill-graphql-api-exception.interceptor';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI))
@UseInterceptors(
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
SkillGraphqlApiExceptionInterceptor,
)
@Resolver(() => SkillDTO)
export class SkillResolver {
constructor(private readonly skillService: SkillService) {}
@Query(() => [SkillDTO])
async skills(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO[]> {
return this.skillService.findAll(workspace.id);
}
@Query(() => SkillDTO, { nullable: true })
async skill(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO | null> {
return this.skillService.findById(id, workspace.id);
}
@Mutation(() => SkillDTO)
async createSkill(
@Args('input') input: CreateSkillInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO> {
return this.skillService.create(input, workspace.id);
}
@Mutation(() => SkillDTO)
async updateSkill(
@Args('input') input: UpdateSkillInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO> {
return this.skillService.update(input, workspace.id);
}
@Mutation(() => SkillDTO)
async deleteSkill(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO> {
return this.skillService.delete(id, workspace.id);
}
@Mutation(() => SkillDTO)
async activateSkill(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO> {
return this.skillService.activate(id, workspace.id);
}
@Mutation(() => SkillDTO)
async deactivateSkill(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<SkillDTO> {
return this.skillService.deactivate(id, workspace.id);
}
}
@@ -0,0 +1,381 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { fromCreateSkillInputToFlatSkillToCreate } from 'src/engine/metadata-modules/flat-skill/utils/from-create-skill-input-to-flat-skill-to-create.util';
import { fromDeleteSkillInputToFlatSkillOrThrow } from 'src/engine/metadata-modules/flat-skill/utils/from-delete-skill-input-to-flat-skill-or-throw.util';
import { fromFlatSkillToSkillDto } from 'src/engine/metadata-modules/flat-skill/utils/from-flat-skill-to-skill-dto.util';
import { fromUpdateSkillInputToFlatSkillToUpdateOrThrow } from 'src/engine/metadata-modules/flat-skill/utils/from-update-skill-input-to-flat-skill-to-update-or-throw.util';
import { type CreateSkillInput } from 'src/engine/metadata-modules/skill/dtos/create-skill.input';
import { type SkillDTO } from 'src/engine/metadata-modules/skill/dtos/skill.dto';
import { type UpdateSkillInput } from 'src/engine/metadata-modules/skill/dtos/update-skill.input';
import {
SkillException,
SkillExceptionCode,
} from 'src/engine/metadata-modules/skill/skill.exception';
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class SkillService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
) {}
async findAll(workspaceId: string): Promise<SkillDTO[]> {
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return Object.values(flatSkillMaps.byId)
.filter(isDefined)
.sort((a, b) => a.label.localeCompare(b.label))
.map(fromFlatSkillToSkillDto);
}
async findById(id: string, workspaceId: string): Promise<SkillDTO | null> {
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
const flatSkill = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: flatSkillMaps,
});
if (!isDefined(flatSkill)) {
return null;
}
return fromFlatSkillToSkillDto(flatSkill);
}
async create(
input: CreateSkillInput,
workspaceId: string,
): Promise<SkillDTO> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const flatSkillToCreate = fromCreateSkillInputToFlatSkillToCreate({
createSkillInput: input,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
skill: {
flatEntityToCreate: [flatSkillToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while creating skill',
);
}
const { flatSkillMaps: recomputedFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return fromFlatSkillToSkillDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatSkillToCreate.id,
flatEntityMaps: recomputedFlatSkillMaps,
}),
);
}
async update(
input: UpdateSkillInput,
workspaceId: string,
): Promise<SkillDTO> {
const { flatSkillMaps: existingFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
const flatSkillToUpdate = fromUpdateSkillInputToFlatSkillToUpdateOrThrow({
flatSkillMaps: existingFlatSkillMaps,
updateSkillInput: input,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
skill: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatSkillToUpdate],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while updating skill',
);
}
const { flatSkillMaps: recomputedFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return fromFlatSkillToSkillDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: input.id,
flatEntityMaps: recomputedFlatSkillMaps,
}),
);
}
async delete(id: string, workspaceId: string): Promise<SkillDTO> {
const { flatSkillMaps: existingFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
const flatSkillToDelete = fromDeleteSkillInputToFlatSkillOrThrow({
flatSkillMaps: existingFlatSkillMaps,
skillId: id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
skill: {
flatEntityToCreate: [],
flatEntityToDelete: [flatSkillToDelete],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while deleting skill',
);
}
return fromFlatSkillToSkillDto(flatSkillToDelete);
}
async findAllFlatSkills(workspaceId: string): Promise<FlatSkill[]> {
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return Object.values(flatSkillMaps.byId)
.filter(isDefined)
.filter((flatSkill) => flatSkill.isActive)
.sort((a, b) => a.label.localeCompare(b.label));
}
async findFlatSkillsByNames(
names: string[],
workspaceId: string,
): Promise<FlatSkill[]> {
if (names.length === 0) {
return [];
}
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return Object.values(flatSkillMaps.byId)
.filter(isDefined)
.filter(
(flatSkill) => names.includes(flatSkill.name) && flatSkill.isActive,
);
}
async activate(id: string, workspaceId: string): Promise<SkillDTO> {
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
const existingFlatSkill = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: flatSkillMaps,
});
const flatSkillToUpdate: FlatSkill = {
...existingFlatSkill,
isActive: true,
updatedAt: new Date().toISOString(),
};
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
skill: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatSkillToUpdate],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while activating skill',
);
}
const { flatSkillMaps: recomputedFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return fromFlatSkillToSkillDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatSkillMaps,
}),
);
}
async deactivate(id: string, workspaceId: string): Promise<SkillDTO> {
const { flatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
const existingFlatSkill = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: flatSkillMaps,
});
const flatSkillToUpdate: FlatSkill = {
...existingFlatSkill,
isActive: false,
updatedAt: new Date().toISOString(),
};
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
skill: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatSkillToUpdate],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderExceptionV2(
validateAndBuildResult,
'Multiple validation errors occurred while deactivating skill',
);
}
const { flatSkillMaps: recomputedFlatSkillMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatSkillMaps'],
},
);
return fromFlatSkillToSkillDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatSkillMaps,
}),
);
}
async findByIdOrThrow(id: string, workspaceId: string): Promise<SkillDTO> {
const skill = await this.findById(id, workspaceId);
if (!isDefined(skill)) {
throw new SkillException(
'Skill not found',
SkillExceptionCode.SKILL_NOT_FOUND,
);
}
return skill;
}
}
@@ -0,0 +1,32 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
ConflictError,
ForbiddenError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
SkillException,
SkillExceptionCode,
} from 'src/engine/metadata-modules/skill/skill.exception';
export const skillGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof SkillException) {
switch (error.code) {
case SkillExceptionCode.SKILL_NOT_FOUND:
throw new NotFoundError(error);
case SkillExceptionCode.INVALID_SKILL_INPUT:
throw new UserInputError(error);
case SkillExceptionCode.SKILL_ALREADY_EXISTS:
throw new ConflictError(error);
case SkillExceptionCode.SKILL_IS_STANDARD:
throw new ForbiddenError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -31,6 +31,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = {
flatRoleTargetMaps: 'flat-maps:role-target',
ORMEntityMetadatas: 'orm:entity-metadatas',
flatAgentMaps: 'flat-maps:agent',
flatSkillMaps: 'flat-maps:skill',
flatRoleTargetByAgentIdMaps: 'flat-maps:flatRoleTargetByAgentId',
flatPageLayoutMaps: 'flat-maps:page-layout',
flatPageLayoutWidgetMaps: 'flat-maps:page-layout-widget',
@@ -0,0 +1,37 @@
export const STANDARD_SKILL = {
'workflow-building': {
universalIdentifier: '20202020-6155-838a-b64e-44a791fbdc13',
},
'data-manipulation': {
universalIdentifier: '20202020-e225-f5c7-3d56-45feaa36f2e6',
},
'dashboard-building': {
universalIdentifier: '20202020-398f-0d7a-82db-4f43bc7e7044',
},
'metadata-building': {
universalIdentifier: '20202020-c66a-5fed-4a74-46e0b42a6332',
},
research: {
universalIdentifier: '20202020-db75-4fca-6813-4c7db0f964a0',
},
'code-interpreter': {
universalIdentifier: '20202020-5eb9-e775-cf4e-4f22be7be362',
},
xlsx: {
universalIdentifier: '20202020-2c7f-5b77-dfa4-494b84752ab7',
},
pdf: {
universalIdentifier: '20202020-c3d1-e0c9-2f93-45648b8bbd26',
},
docx: {
universalIdentifier: '20202020-6f15-2432-0537-4e23a2efd1cb',
},
pptx: {
universalIdentifier: '20202020-c81b-baf8-5255-4c34bd0eac9b',
},
} as const satisfies Record<
string,
{
universalIdentifier: string;
}
>;
@@ -10,4 +10,5 @@ export const TWENTY_STANDARD_ALL_METADATA_NAME = [
'view',
'role',
'agent',
'skill',
] as const satisfies AllMetadataName[];
@@ -0,0 +1,3 @@
import type { STANDARD_SKILL } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant';
export type AllStandardSkillName = keyof typeof STANDARD_SKILL;
@@ -0,0 +1,25 @@
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { type CreateStandardSkillArgs } from 'src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-skill-flat-metadata.util';
import { STANDARD_FLAT_SKILL_METADATA_BUILDERS_BY_SKILL_NAME } from 'src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util';
export const buildStandardFlatSkillMetadataMaps = (
args: Omit<CreateStandardSkillArgs, 'context'>,
): FlatEntityMaps<FlatSkill> => {
const allSkillMetadatas: FlatSkill[] = Object.values(
STANDARD_FLAT_SKILL_METADATA_BUILDERS_BY_SKILL_NAME,
).map((builder) => builder(args));
let flatSkillMetadataMaps = createEmptyFlatEntityMaps();
for (const skillMetadata of allSkillMetadatas) {
flatSkillMetadataMaps = addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: skillMetadata,
flatEntityMaps: flatSkillMetadataMaps,
});
}
return flatSkillMetadataMaps;
};
@@ -0,0 +1,46 @@
import { v4 } from 'uuid';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { STANDARD_SKILL } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-skill.constant';
import { type AllStandardSkillName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-skill-name.type';
import { type StandardBuilderArgs } from 'src/engine/workspace-manager/twenty-standard-application/types/metadata-standard-buillder-args.type';
export type CreateStandardSkillContext = {
skillName: AllStandardSkillName;
name: string;
label: string;
icon: string | null;
description: string | null;
content: string;
isCustom: boolean;
};
export type CreateStandardSkillArgs = StandardBuilderArgs<'skill'> & {
context: CreateStandardSkillContext;
};
export const createStandardSkillFlatMetadata = ({
context: { skillName, name, label, icon, description, content, isCustom },
workspaceId,
twentyStandardApplicationId,
now,
}: CreateStandardSkillArgs): FlatSkill => {
const universalIdentifier = STANDARD_SKILL[skillName].universalIdentifier;
return {
id: v4(),
universalIdentifier,
standardId: universalIdentifier,
name,
label,
icon,
description,
content,
isCustom,
isActive: true,
workspaceId,
applicationId: twentyStandardApplicationId,
createdAt: now,
updatedAt: now,
};
};
@@ -1,6 +1,7 @@
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type TwentyStandardAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/types/twenty-standard-all-flat-entity-maps.type';
import { buildStandardFlatAgentMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/agent-metadata/build-standard-flat-agent-metadata-maps.util';
import { buildStandardFlatSkillMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/build-standard-flat-skill-metadata-maps.util';
import { buildStandardFlatFieldMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/build-standard-flat-field-metadata-maps.util';
import { getStandardObjectMetadataRelatedEntityIds } from 'src/engine/workspace-manager/twenty-standard-application/utils/get-standard-object-metadata-related-entity-ids.util';
import { buildStandardFlatIndexMetadataMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/build-standard-flat-index-metadata-maps.util';
@@ -122,6 +123,14 @@ export const computeTwentyStandardApplicationAllFlatEntityMaps = ({
},
});
const flatSkillMaps = buildStandardFlatSkillMetadataMaps({
now,
workspaceId,
twentyStandardApplicationId,
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps: undefined,
});
return {
flatViewFieldMaps,
flatViewFilterMaps,
@@ -132,5 +141,6 @@ export const computeTwentyStandardApplicationAllFlatEntityMaps = ({
flatObjectMetadataMaps,
flatRoleMaps,
flatAgentMaps,
flatSkillMaps,
};
};
@@ -18,6 +18,7 @@ export const fromWorkspaceMigrationBuilderExceptionToMetadataValidationResponseE
return {
summary: {
invalidAgent: 0,
invalidSkill: 0,
invalidViewFilter: 0,
invalidViewFilterGroup: 0,
invalidObjectMetadata: 0,
@@ -29,6 +29,7 @@ import { WorkspaceMigrationV2RouteTriggerActionsBuilderService } from 'src/engin
import { WorkspaceMigrationV2RowLevelPermissionPredicateGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/row-level-permission-predicate-group/workspace-migration-v2-row-level-permission-predicate-group-actions-builder.service';
import { WorkspaceMigrationV2RowLevelPermissionPredicateActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/row-level-permission-predicate/workspace-migration-v2-row-level-permission-predicate-actions-builder.service';
import { WorkspaceMigrationV2ServerlessFunctionActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/serverless-function/workspace-migration-v2-serverless-function-actions-builder.service';
import { WorkspaceMigrationV2SkillActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/workspace-migration-v2-skill-actions-builder.service';
import { WorkspaceMigrationV2ViewFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/view-field/workspace-migration-v2-view-field-actions-builder.service';
import { WorkspaceMigrationV2ViewFilterGroupActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/view-filter-group/workspace-migration-v2-view-filter-group-actions-builder.service';
import { WorkspaceMigrationV2ViewFilterActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/view-filter/workspace-migration-v2-view-filter-actions-builder.service';
@@ -53,6 +54,7 @@ export class WorkspaceMigrationBuildOrchestratorService {
private readonly workspaceMigrationV2FieldActionsBuilderService: WorkspaceMigrationV2FieldActionsBuilderService,
private readonly workspaceMigrationV2RoleActionsBuilderService: WorkspaceMigrationV2RoleActionsBuilderService,
private readonly workspaceMigrationV2AgentActionsBuilderService: WorkspaceMigrationV2AgentActionsBuilderService,
private readonly workspaceMigrationV2SkillActionsBuilderService: WorkspaceMigrationV2SkillActionsBuilderService,
private readonly workspaceMigrationV2PageLayoutActionsBuilderService: WorkspaceMigrationV2PageLayoutActionsBuilderService,
private readonly workspaceMigrationV2PageLayoutWidgetActionsBuilderService: WorkspaceMigrationV2PageLayoutWidgetActionsBuilderService,
private readonly workspaceMigrationV2PageLayoutTabActionsBuilderService: WorkspaceMigrationV2PageLayoutTabActionsBuilderService,
@@ -153,6 +155,7 @@ export class WorkspaceMigrationBuildOrchestratorService {
flatRoleMaps,
flatRoleTargetMaps,
flatAgentMaps,
flatSkillMaps,
flatPageLayoutMaps,
flatPageLayoutWidgetMaps,
flatPageLayoutTabMaps,
@@ -781,6 +784,36 @@ export class WorkspaceMigrationBuildOrchestratorService {
}
}
if (isDefined(flatSkillMaps)) {
const { from: fromFlatSkillMaps, to: toFlatSkillMaps } = flatSkillMaps;
const skillResult =
await this.workspaceMigrationV2SkillActionsBuilderService.validateAndBuild(
{
additionalCacheDataMaps,
from: fromFlatSkillMaps,
to: toFlatSkillMaps,
buildOptions,
dependencyOptimisticFlatEntityMaps: undefined,
workspaceId,
},
);
this.mergeFlatEntityMapsAndRelatedFlatEntityMapsInAllFlatEntityMapsThroughMutation(
{
allFlatEntityMaps: optimisticAllFlatEntityMaps,
flatEntityMapsAndRelatedFlatEntityMaps:
skillResult.optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
},
);
if (skillResult.status === 'fail') {
orchestratorFailureReport.skill.push(...skillResult.errors);
} else {
orchestratorActionsReport.skill = skillResult.actions;
}
}
if (isDefined(flatPageLayoutMaps)) {
const { from: fromFlatPageLayoutMaps, to: toFlatPageLayoutMaps } =
flatPageLayoutMaps;
@@ -988,6 +1021,12 @@ export class WorkspaceMigrationBuildOrchestratorService {
...aggregatedOrchestratorActionsReport.agent.updated,
///
// Skills
...aggregatedOrchestratorActionsReport.skill.deleted,
...aggregatedOrchestratorActionsReport.skill.created,
...aggregatedOrchestratorActionsReport.skill.updated,
///
// Page layouts
...aggregatedOrchestratorActionsReport.pageLayout.deleted,
...aggregatedOrchestratorActionsReport.pageLayout.created,
@@ -0,0 +1,18 @@
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type FlatEntityPropertiesUpdates } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-properties-updates.type';
export type UpdateSkillAction = {
type: 'update_skill';
flatEntityId: string;
flatEntityUpdates: FlatEntityPropertiesUpdates<'skill'>;
};
export type CreateSkillAction = {
type: 'create_skill';
flatEntity: FlatSkill;
};
export type DeleteSkillAction = {
type: 'delete_skill';
flatEntityId: string;
};
@@ -0,0 +1,96 @@
import { Injectable } from '@nestjs/common';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { UpdateSkillAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/types/workspace-migration-v2-skill-action.type';
import { WorkspaceEntityMigrationBuilderV2Service } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/services/workspace-entity-migration-builder-v2.service';
import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-update-validation-args.type';
import { FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-validation-args.type';
import { FlatEntityValidationReturnType } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-validation-result.type';
import { FlatSkillValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-skill-validator.service';
@Injectable()
export class WorkspaceMigrationV2SkillActionsBuilderService extends WorkspaceEntityMigrationBuilderV2Service<
typeof ALL_METADATA_NAME.skill
> {
constructor(
private readonly flatSkillValidatorService: FlatSkillValidatorService,
) {
super(ALL_METADATA_NAME.skill);
}
protected validateFlatEntityCreation(
args: FlatEntityValidationArgs<typeof ALL_METADATA_NAME.skill>,
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.skill, 'created'> {
const validationResult =
this.flatSkillValidatorService.validateFlatSkillCreation(args);
if (validationResult.errors.length > 0) {
return {
status: 'fail',
...validationResult,
};
}
const { flatEntityToValidate: flatSkillToValidate } = args;
return {
status: 'success',
action: {
type: 'create_skill',
flatEntity: flatSkillToValidate,
},
};
}
protected validateFlatEntityDeletion(
args: FlatEntityValidationArgs<typeof ALL_METADATA_NAME.skill>,
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.skill, 'deleted'> {
const validationResult =
this.flatSkillValidatorService.validateFlatSkillDeletion(args);
if (validationResult.errors.length > 0) {
return {
status: 'fail',
...validationResult,
};
}
const { flatEntityToValidate: flatSkillToValidate } = args;
return {
status: 'success',
action: {
type: 'delete_skill',
flatEntityId: flatSkillToValidate.id,
},
};
}
protected validateFlatEntityUpdate(
args: FlatEntityUpdateValidationArgs<typeof ALL_METADATA_NAME.skill>,
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.skill, 'updated'> {
const validationResult =
this.flatSkillValidatorService.validateFlatSkillUpdate(args);
if (validationResult.errors.length > 0) {
return {
status: 'fail',
...validationResult,
};
}
const { flatEntityId, flatEntityUpdates } = args;
const updateSkillAction: UpdateSkillAction = {
type: 'update_skill',
flatEntityId,
flatEntityUpdates,
};
return {
status: 'success',
action: updateSkillAction,
};
}
}
@@ -0,0 +1,217 @@
import { Injectable } from '@nestjs/common';
import { msg, t } from '@lingui/core/macro';
import { type ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { SkillExceptionCode } from 'src/engine/metadata-modules/skill/skill.exception';
import { isStandardMetadata } from 'src/engine/metadata-modules/utils/is-standard-metadata.util';
import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration-v2/utils/find-flat-entity-property-update.util';
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type';
import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-update-validation-args.type';
import { type FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/types/flat-entity-validation-args.type';
import { validateSkillNameUniqueness } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/utils/validate-skill-name-uniqueness.util';
import {
validateSkillContentIsDefined,
validateSkillLabelIsDefined,
validateSkillRequiredProperties,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/utils/validate-skill-required-properties.util';
import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/utils/from-flat-entity-properties-updates-to-partial-flat-entity';
@Injectable()
export class FlatSkillValidatorService {
public validateFlatSkillCreation({
flatEntityToValidate: flatSkill,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatSkillMaps: optimisticFlatSkillMaps,
},
}: FlatEntityValidationArgs<
typeof ALL_METADATA_NAME.skill
>): FailedFlatEntityValidation<FlatSkill> {
const validationResult: FailedFlatEntityValidation<FlatSkill> = {
type: 'create_skill',
errors: [],
flatEntityMinimalInformation: {
id: flatSkill.id,
name: flatSkill.name,
},
};
const existingSkills = Object.values(optimisticFlatSkillMaps.byId).filter(
isDefined,
);
validationResult.errors.push(
...validateSkillRequiredProperties({ flatSkill }),
);
validationResult.errors.push(
...validateSkillNameUniqueness({
name: flatSkill.name,
existingFlatSkills: existingSkills,
}),
);
return validationResult;
}
public validateFlatSkillDeletion({
flatEntityToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatSkillMaps: optimisticFlatSkillMaps,
},
buildOptions,
}: FlatEntityValidationArgs<
typeof ALL_METADATA_NAME.skill
>): FailedFlatEntityValidation<FlatSkill> {
const validationResult: FailedFlatEntityValidation<FlatSkill> = {
type: 'delete_skill',
errors: [],
flatEntityMinimalInformation: {
id: flatEntityToValidate.id,
name: flatEntityToValidate.name,
},
};
const existingSkill = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: flatEntityToValidate.id,
flatEntityMaps: optimisticFlatSkillMaps,
});
if (!isDefined(existingSkill)) {
validationResult.errors.push({
code: SkillExceptionCode.SKILL_NOT_FOUND,
message: t`Skill not found`,
userFriendlyMessage: msg`Skill not found`,
});
return validationResult;
}
if (!buildOptions.isSystemBuild && isStandardMetadata(existingSkill)) {
validationResult.errors.push({
code: SkillExceptionCode.SKILL_IS_STANDARD,
message: t`Cannot delete standard skill`,
userFriendlyMessage: msg`Cannot delete standard skill`,
});
}
return validationResult;
}
public validateFlatSkillUpdate({
flatEntityId,
flatEntityUpdates,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatSkillMaps: optimisticFlatSkillMaps,
},
buildOptions,
}: FlatEntityUpdateValidationArgs<
typeof ALL_METADATA_NAME.skill
>): FailedFlatEntityValidation<FlatSkill> {
const validationResult: FailedFlatEntityValidation<FlatSkill> = {
type: 'update_skill',
errors: [],
flatEntityMinimalInformation: {
id: flatEntityId,
},
};
const fromFlatSkill = findFlatEntityByIdInFlatEntityMaps({
flatEntityId,
flatEntityMaps: optimisticFlatSkillMaps,
});
if (!isDefined(fromFlatSkill)) {
validationResult.errors.push({
code: SkillExceptionCode.SKILL_NOT_FOUND,
message: t`Skill not found`,
userFriendlyMessage: msg`Skill not found`,
});
return validationResult;
}
// Standard skills can only have isActive toggled, not other properties
const isActiveUpdate = findFlatEntityPropertyUpdate({
flatEntityUpdates,
property: 'isActive',
});
const hasNonIsActiveUpdates = flatEntityUpdates.some(
(update) => update.property !== 'isActive',
);
if (
!buildOptions.isSystemBuild &&
isStandardMetadata(fromFlatSkill) &&
hasNonIsActiveUpdates
) {
validationResult.errors.push({
code: SkillExceptionCode.SKILL_IS_STANDARD,
message: t`Cannot update standard skill properties (only activation/deactivation allowed)`,
userFriendlyMessage: msg`Cannot update standard skill properties (only activation/deactivation allowed)`,
});
}
// If only isActive is being updated on a standard skill, allow it
if (
isStandardMetadata(fromFlatSkill) &&
isDefined(isActiveUpdate) &&
!hasNonIsActiveUpdates
) {
return validationResult;
}
const optimisticFlatSkill: FlatSkill = {
...fromFlatSkill,
...fromFlatEntityPropertiesUpdatesToPartialFlatEntity({
updates: flatEntityUpdates,
}),
};
const labelUpdate = findFlatEntityPropertyUpdate({
flatEntityUpdates,
property: 'label',
});
if (isDefined(labelUpdate)) {
validationResult.errors.push(
...validateSkillLabelIsDefined({ flatSkill: optimisticFlatSkill }),
);
}
const contentUpdate = findFlatEntityPropertyUpdate({
flatEntityUpdates,
property: 'content',
});
if (isDefined(contentUpdate)) {
validationResult.errors.push(
...validateSkillContentIsDefined({ flatSkill: optimisticFlatSkill }),
);
}
const nameUpdate = findFlatEntityPropertyUpdate({
flatEntityUpdates,
property: 'name',
});
if (isDefined(nameUpdate)) {
const existingSkills = Object.values(optimisticFlatSkillMaps.byId)
.filter(isDefined)
.filter((skill) => skill.id !== flatEntityId);
validationResult.errors.push(
...validateSkillNameUniqueness({
name: nameUpdate.to,
existingFlatSkills: existingSkills,
}),
);
}
return validationResult;
}
}
@@ -0,0 +1,25 @@
import { msg, t } from '@lingui/core/macro';
import { SkillExceptionCode } from 'src/engine/metadata-modules/skill/skill.exception';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type';
export const validateSkillNameUniqueness = ({
name,
existingFlatSkills,
}: {
name: string;
existingFlatSkills: FlatSkill[];
}): FlatEntityValidationError<SkillExceptionCode>[] => {
const errors: FlatEntityValidationError<SkillExceptionCode>[] = [];
if (existingFlatSkills.some((skill) => skill.name === name)) {
errors.push({
code: SkillExceptionCode.SKILL_ALREADY_EXISTS,
message: t`Skill with name "${name}" already exists`,
userFriendlyMessage: msg`A skill with this name already exists`,
});
}
return errors;
};
@@ -0,0 +1,51 @@
import { msg, t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { SkillExceptionCode } from 'src/engine/metadata-modules/skill/skill.exception';
import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type';
export const validateSkillLabelIsDefined = ({
flatSkill,
}: {
flatSkill: FlatSkill;
}): FlatEntityValidationError<SkillExceptionCode>[] => {
if (isNonEmptyString(flatSkill.label)) {
return [];
}
return [
{
code: SkillExceptionCode.INVALID_SKILL_INPUT,
message: t`Label cannot be empty`,
userFriendlyMessage: msg`Label cannot be empty`,
},
];
};
export const validateSkillContentIsDefined = ({
flatSkill,
}: {
flatSkill: FlatSkill;
}): FlatEntityValidationError<SkillExceptionCode>[] => {
if (isNonEmptyString(flatSkill.content)) {
return [];
}
return [
{
code: SkillExceptionCode.INVALID_SKILL_INPUT,
message: t`Content cannot be empty`,
userFriendlyMessage: msg`Content cannot be empty`,
},
];
};
export const validateSkillRequiredProperties = ({
flatSkill,
}: {
flatSkill: FlatSkill;
}): FlatEntityValidationError<SkillExceptionCode>[] => [
...validateSkillLabelIsDefined({ flatSkill }),
...validateSkillContentIsDefined({ flatSkill }),
];
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
import { FlatAgentValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-agent-validator.service';
import { FlatSkillValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-skill-validator.service';
import { FlatCronTriggerValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-cron-trigger-validator.service';
import { FlatDatabaseEventTriggerValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-database-event-trigger-validator.service';
import { FlatFieldMetadataValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-field-metadata-validator.service';
@@ -42,6 +43,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace
FlatRoleValidatorService,
FlatRoleTargetValidatorService,
FlatAgentValidatorService,
FlatSkillValidatorService,
FlatPageLayoutValidatorService,
FlatPageLayoutWidgetValidatorService,
FlatPageLayoutTabValidatorService,
@@ -65,6 +67,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace
FlatRoleValidatorService,
FlatRoleTargetValidatorService,
FlatAgentValidatorService,
FlatSkillValidatorService,
FlatPageLayoutValidatorService,
FlatPageLayoutWidgetValidatorService,
FlatPageLayoutTabValidatorService,
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
import { WorkspaceMigrationV2AgentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/agent/workspace-migration-v2-agent-actions-builder.service';
import { WorkspaceMigrationV2SkillActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/workspace-migration-v2-skill-actions-builder.service';
import { WorkspaceMigrationV2CronTriggerActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/cron-trigger/workspace-migration-v2-cron-trigger-action-builder.service';
import { WorkspaceMigrationV2DatabaseEventTriggerActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/database-event-trigger/workspace-migration-v2-database-event-trigger-actions-builder.service';
import { WorkspaceMigrationV2FieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/field/workspace-migration-v2-field-actions-builder.service';
@@ -43,6 +44,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace-
WorkspaceMigrationV2RoleActionsBuilderService,
WorkspaceMigrationV2RoleTargetActionsBuilderService,
WorkspaceMigrationV2AgentActionsBuilderService,
WorkspaceMigrationV2SkillActionsBuilderService,
WorkspaceMigrationV2PageLayoutActionsBuilderService,
WorkspaceMigrationV2PageLayoutWidgetActionsBuilderService,
WorkspaceMigrationV2PageLayoutTabActionsBuilderService,
@@ -65,6 +67,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace-
WorkspaceMigrationV2RoleActionsBuilderService,
WorkspaceMigrationV2RoleTargetActionsBuilderService,
WorkspaceMigrationV2AgentActionsBuilderService,
WorkspaceMigrationV2SkillActionsBuilderService,
WorkspaceMigrationV2PageLayoutActionsBuilderService,
WorkspaceMigrationV2PageLayoutWidgetActionsBuilderService,
WorkspaceMigrationV2PageLayoutTabActionsBuilderService,
@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import {
OptimisticallyApplyActionOnAllFlatEntityMapsArgs,
WorkspaceMigrationRunnerActionHandler,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface';
import { SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { CreateSkillAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/types/workspace-migration-v2-skill-action.type';
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type';
@Injectable()
export class CreateSkillActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
'create_skill',
) {
constructor() {
super();
}
optimisticallyApplyActionOnAllFlatEntityMaps({
action,
allFlatEntityMaps,
}: OptimisticallyApplyActionOnAllFlatEntityMapsArgs<CreateSkillAction>): Partial<AllFlatEntityMaps> {
const { flatSkillMaps } = allFlatEntityMaps;
const { flatEntity } = action;
const updatedFlatSkillMaps = addFlatEntityToFlatEntityMapsOrThrow({
flatEntity,
flatEntityMaps: flatSkillMaps,
});
return {
flatSkillMaps: updatedFlatSkillMaps,
};
}
async executeForMetadata(
context: WorkspaceMigrationActionRunnerArgs<CreateSkillAction>,
): Promise<void> {
const { action, queryRunner, workspaceId } = context;
const { flatEntity } = action;
const skillRepository =
queryRunner.manager.getRepository<SkillEntity>(SkillEntity);
await skillRepository.save({
...flatEntity,
workspaceId,
});
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerArgs<CreateSkillAction>,
): Promise<void> {
return;
}
}
@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import {
OptimisticallyApplyActionOnAllFlatEntityMapsArgs,
WorkspaceMigrationRunnerActionHandler,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface';
import { SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { deleteFlatEntityFromFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/delete-flat-entity-from-flat-entity-maps-or-throw.util';
import { DeleteSkillAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/types/workspace-migration-v2-skill-action.type';
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type';
@Injectable()
export class DeleteSkillActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
'delete_skill',
) {
constructor() {
super();
}
optimisticallyApplyActionOnAllFlatEntityMaps({
action,
allFlatEntityMaps,
}: OptimisticallyApplyActionOnAllFlatEntityMapsArgs<DeleteSkillAction>): Partial<AllFlatEntityMaps> {
const { flatSkillMaps } = allFlatEntityMaps;
const { flatEntityId } = action;
const updatedFlatSkillMaps = deleteFlatEntityFromFlatEntityMapsOrThrow({
entityToDeleteId: flatEntityId,
flatEntityMaps: flatSkillMaps,
});
return {
flatSkillMaps: updatedFlatSkillMaps,
};
}
async executeForMetadata(
context: WorkspaceMigrationActionRunnerArgs<DeleteSkillAction>,
): Promise<void> {
const { action, queryRunner, workspaceId } = context;
const { flatEntityId } = action;
const skillRepository =
queryRunner.manager.getRepository<SkillEntity>(SkillEntity);
await skillRepository.delete({ id: flatEntityId, workspaceId });
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerArgs<DeleteSkillAction>,
): Promise<void> {
return;
}
}
@@ -0,0 +1,71 @@
import { Injectable } from '@nestjs/common';
import {
OptimisticallyApplyActionOnAllFlatEntityMapsArgs,
WorkspaceMigrationRunnerActionHandler,
} from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/interfaces/workspace-migration-runner-action-handler-service.interface';
import { SkillEntity } from 'src/engine/metadata-modules/skill/entities/skill.entity';
import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { replaceFlatEntityInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/replace-flat-entity-in-flat-entity-maps-or-throw.util';
import { UpdateSkillAction } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/skill/types/workspace-migration-v2-skill-action.type';
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/types/workspace-migration-action-runner-args.type';
import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/utils/from-flat-entity-properties-updates-to-partial-flat-entity';
@Injectable()
export class UpdateSkillActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
'update_skill',
) {
optimisticallyApplyActionOnAllFlatEntityMaps({
action,
allFlatEntityMaps,
}: OptimisticallyApplyActionOnAllFlatEntityMapsArgs<UpdateSkillAction>): Partial<AllFlatEntityMaps> {
const { flatSkillMaps } = allFlatEntityMaps;
const { flatEntityId, flatEntityUpdates } = action;
const existingSkill = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId,
flatEntityMaps: flatSkillMaps,
});
const updatedSkill = {
...existingSkill,
...fromFlatEntityPropertiesUpdatesToPartialFlatEntity({
updates: flatEntityUpdates,
}),
};
const updatedFlatSkillMaps = replaceFlatEntityInFlatEntityMapsOrThrow({
flatEntity: updatedSkill,
flatEntityMaps: flatSkillMaps,
});
return {
flatSkillMaps: updatedFlatSkillMaps,
};
}
async executeForMetadata(
context: WorkspaceMigrationActionRunnerArgs<UpdateSkillAction>,
): Promise<void> {
const { action, queryRunner, workspaceId } = context;
const { flatEntityId, flatEntityUpdates } = action;
const skillRepository =
queryRunner.manager.getRepository<SkillEntity>(SkillEntity);
await skillRepository.update(
{ id: flatEntityId, workspaceId },
fromFlatEntityPropertiesUpdatesToPartialFlatEntity({
updates: flatEntityUpdates,
}),
);
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerArgs<UpdateSkillAction>,
): Promise<void> {
return;
}
}
@@ -4,6 +4,9 @@ import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-sc
import { CreateAgentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/agent/services/create-agent-action-handler.service';
import { DeleteAgentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/agent/services/delete-agent-action-handler.service';
import { UpdateAgentActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/agent/services/update-agent-action-handler.service';
import { CreateSkillActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/skill/services/create-skill-action-handler.service';
import { DeleteSkillActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/skill/services/delete-skill-action-handler.service';
import { UpdateSkillActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/skill/services/update-skill-action-handler.service';
import { CreateCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/cron-trigger/services/create-cron-trigger-action-handler.service';
import { DeleteCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/cron-trigger/services/delete-cron-trigger-action-handler.service';
import { UpdateCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-runner-v2/action-handlers/cron-trigger/services/update-cron-trigger-action-handler.service';
@@ -123,6 +126,10 @@ import { UpdateViewActionHandlerService } from 'src/engine/workspace-manager/wor
UpdateAgentActionHandlerService,
DeleteAgentActionHandlerService,
CreateSkillActionHandlerService,
UpdateSkillActionHandlerService,
DeleteSkillActionHandlerService,
CreatePageLayoutActionHandlerService,
UpdatePageLayoutActionHandlerService,
DeletePageLayoutActionHandlerService,