29 KiB
EspoCRM Custom Actions - Implementierungsprinzip
Version: 1.0
Datum: 24. Januar 2026
Blueprint für: Custom Button Actions mit Entity-Erstellung und Relationen
📋 Inhaltsverzeichnis
- Überblick
- Architektur-Prinzipien
- Implementierungs-Schritte
- Code-Template
- Best Practices
- Sicherheit
- Testing & Debugging
- Beispiel-Implementierung
Überblick
Dieses Dokument beschreibt das standardisierte Vorgehen zur Implementierung von Custom Button Actions in EspoCRM, die:
- Von einer Entity-Detailansicht ausgelöst werden
- Eine neue verwandte Entity erstellen
- Automatisch Daten und Relationen übertragen
- ACL-konform und transaktionssicher arbeiten
Basis-Beispiel: Button "Räumungsklage einleiten" in Mietobjekt → Erstellt CVmhRumungsklage mit allen Relationen
Architektur-Prinzipien
1. Drei-Schichten-Architektur
┌─────────────────────────────────────────────────────┐
│ FRONTEND (client/custom/) │
│ • JavaScript Handler (Action Trigger) │
│ • Button Definition (clientDefs) │
│ • i18n Translations │
└────────────────┬────────────────────────────────────┘
│ AJAX POST Request
┌────────────────▼────────────────────────────────────┐
│ CONTROLLER (custom/Espo/Custom/Controllers/) │
│ • Request Validation │
│ • Service Call │
│ • Response Formatting │
└────────────────┬────────────────────────────────────┘
│ Method Call
┌────────────────▼────────────────────────────────────┐
│ SERVICE (custom/Espo/Custom/Services/) │
│ • ACL Security Checks │
│ • Business Logic │
│ • Transaction Management │
│ • Entity Operations │
└─────────────────────────────────────────────────────┘
2. Separation of Concerns
| Schicht | Verantwortlichkeit | Nicht erlaubt |
|---|---|---|
| Frontend | User Interaction, Confirmation, Navigation | Business Logic, Direct DB Access |
| Controller | HTTP Request/Response Handling | Business Logic, Direct Entity Manipulation |
| Service | Business Logic, ACL, Transactions, Entity Operations | HTTP Handling, View Logic |
Implementierungs-Schritte
Schritt 1: Backend Controller erstellen
Datei: custom/Espo/Custom/Controllers/[EntityName].php
<?php
namespace Espo\Custom\Controllers;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Api\Request;
class CYourEntity extends \Espo\Core\Templates\Controllers\Base
{
/**
* POST Action: Beschreibung der Aktion
*/
public function postActionYourAction(Request $request): array
{
$data = $request->getParsedBody();
$id = $data->id ?? null;
if (!$id) {
throw new BadRequest('No Entity ID provided');
}
$service = $this->getRecordService();
$result = $service->yourAction($id);
return $result;
}
}
Wichtig:
- Methode MUSS
postAction[Name]heißen - Parameter ist
Request $request - Return Type ist
array - Extends
\Espo\Core\Templates\Controllers\Base
Schritt 2: Backend Service erstellen
Datei: custom/Espo/Custom/Services/[EntityName].php
<?php
namespace Espo\Custom\Services;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
class CYourEntity extends \Espo\Services\Record
{
/**
* Beschreibung der Business-Logik
*
* @param string $sourceId
* @return array
* @throws NotFound
* @throws Forbidden
*/
public function yourAction(string $sourceId): array
{
// 1. LADEN: Source Entity laden
$source = $this->entityManager->getEntity('CYourEntity', $sourceId);
if (!$source) {
throw new NotFound('Entity not found');
}
// 2. SECURITY: ACL-Checks
if (!$this->acl->check($source, 'read')) {
throw new Forbidden('No read access to source entity');
}
if (!$this->acl->checkScope('CTargetEntity', 'create')) {
throw new Forbidden('No create access to target entity');
}
// 3. TRANSACTION: Start
$this->entityManager->getTransactionManager()->start();
try {
// 4. ERSTELLEN: Target Entity
$data = new \stdClass();
$data->name = 'Generated from ' . $source->get('name');
// Optional: User und Teams übernehmen
if ($source->get('assignedUserId')) {
$data->assignedUserId = $source->get('assignedUserId');
}
$teamsIds = $source->getLinkMultipleIdList('teams');
if (!empty($teamsIds)) {
$data->teamsIds = $teamsIds;
}
$target = $this->entityManager->createEntity('CTargetEntity', (array)$data);
if (!$target) {
throw new \RuntimeException('Failed to create target entity');
}
// 5. RELATIONEN: Verknüpfen
$targetRepo = $this->entityManager->getRepository('CTargetEntity');
// Source → Target
$targetRepo
->getRelation($target, 'sources')
->relate($source);
// Related Entities übertragen
$relatedEntities = $this->entityManager
->getRepository('CYourEntity')
->getRelation($source, 'relatedThings')
->find();
foreach ($relatedEntities as $related) {
$targetRepo
->getRelation($target, 'relatedThings')
->relate($related);
}
// 6. COMMIT: Transaktion abschließen
$this->entityManager->getTransactionManager()->commit();
// 7. RETURN: ID und Name für Frontend
return [
'id' => $target->getId(),
'name' => $target->get('name')
];
} catch (\Exception $e) {
// ROLLBACK: Bei jedem Fehler
$this->entityManager->getTransactionManager()->rollback();
throw $e;
}
}
}
Wichtig:
- Extends
\Espo\Services\Record - Immer Transactions verwenden
- Alle Exceptions im catch-Block fangen
- Rollback bei Fehlern
Schritt 3: Button im Frontend konfigurieren
Datei: custom/Espo/Custom/Resources/metadata/clientDefs/[EntityName].json
{
"menu": {
"detail": {
"buttons": [
{
"name": "initiateYourAction",
"label": "Your Action Button",
"handler": "custom:handlers/your-entity/your-action",
"iconHtml": "<span class=\"fas fa-bolt\"></span>",
"style": "danger",
"acl": "edit"
}
]
}
}
}
Button-Optionen:
name: Eindeutiger Identifierlabel: Translation-Key (siehe i18n)handler: Pfad zum JavaScript HandlericonHtml: FontAwesome Icon oder HTMLstyle:default,success,danger,warning,infoacl:read,edit,delete(benötigte Permission)
Schritt 4: JavaScript Handler implementieren
Datei: client/custom/src/handlers/your-entity/your-action.js
define('custom:handlers/your-entity/your-action', [], function () {
class YourActionHandler {
constructor(view) {
this.view = view;
}
/**
* Setup-Methode wird automatisch aufgerufen
*/
initYourAction() {
this.view.listenTo(
this.view,
'after:render',
() => this.controlButtonVisibility()
);
}
/**
* Button-Sichtbarkeit steuern (optional)
*/
controlButtonVisibility() {
const model = this.view.model;
// Beispiel: Button nur bei bestimmten Bedingungen zeigen
if (!model.get('someField')) {
this.view.hideHeaderActionItem('initiateYourAction');
} else {
this.view.showHeaderActionItem('initiateYourAction');
}
}
/**
* Action-Handler (wird bei Button-Click aufgerufen)
*/
actionInitiateYourAction() {
const model = this.view.model;
// 1. CONFIRMATION: User bestätigen lassen
this.view.confirm(
this.view.translate('confirmationMessage', 'messages', 'CYourEntity'),
() => {
this.initiateAction(model.id);
}
);
}
/**
* AJAX Request zum Backend
*/
initiateAction(entityId) {
Espo.Ui.notify(this.view.translate('pleaseWait', 'messages'));
Espo.Ajax.postRequest('CYourEntity/action/yourAction', {
id: entityId
})
.then(response => {
Espo.Ui.success(
this.view.translate('successMessage', 'messages', 'CYourEntity')
);
// Navigation zur erstellten Entity
this.view.getRouter().navigate(
'#CTargetEntity/view/' + response.id,
{trigger: true}
);
})
.catch(xhr => {
console.error('Action failed:', xhr);
let errorMessage = this.view.translate('errorMessage', 'messages', 'CYourEntity');
if (xhr.status === 403) {
errorMessage = this.view.translate('Access denied', 'messages');
} else if (xhr.status === 404) {
errorMessage = this.view.translate('Not found', 'messages');
}
Espo.Ui.error(errorMessage);
});
}
}
return YourActionHandler;
});
Wichtig:
- Class-Name muss mit Filename übereinstimmen (CamelCase)
- Methode
init[ButtonName]()für Setup - Methode
action[ButtonName]()für Click-Handler - Immer Confirmation Dialog für kritische Actions
- Error Handling mit spezifischen HTTP-Status-Codes
Schritt 5: Übersetzungen hinzufügen
Datei: custom/Espo/Custom/Resources/i18n/de_DE/[EntityName].json
{
"labels": {
"Your Action Button": "Ihre Aktion"
},
"messages": {
"confirmationMessage": "Möchten Sie diese Aktion wirklich ausführen?",
"successMessage": "Die Aktion wurde erfolgreich ausgeführt",
"errorMessage": "Fehler beim Ausführen der Aktion"
}
}
Datei: custom/Espo/Custom/Resources/i18n/en_US/[EntityName].json
{
"labels": {
"Your Action Button": "Your Action"
},
"messages": {
"confirmationMessage": "Do you really want to execute this action?",
"successMessage": "Action executed successfully",
"errorMessage": "Error executing action"
}
}
Schritt 6: Validierung & Rebuild
# Im Root-Verzeichnis von EspoCRM
python3 custom/scripts/validate_and_rebuild.py
Prüfungen:
- ✅ JSON-Syntax aller Dateien
- ✅ PHP-Syntax (Service, Controller)
- ✅ JavaScript-Syntax (Handler)
- ✅ Relationship-Konsistenz
- ✅ Automatischer Cache-Clear & Rebuild
Best Practices
1. Naming Conventions
| Element | Pattern | Beispiel |
|---|---|---|
| Controller Action | postAction[Name] |
postActionInitiateEviction |
| Service Method | camelCase |
initiateEviction |
| Button Name | camelCase |
initiateEviction |
| JS Handler File | kebab-case.js |
eviction-action.js |
| JS Class Name | PascalCase |
EvictionActionHandler |
| JS Init Method | init[ButtonName] |
initInitiateEviction |
| JS Action Method | action[ButtonName] |
actionInitiateEviction |
2. Transaction Management
Immer Transactions verwenden bei:
- Entity-Erstellung
- Mehreren Relationierungen
- Datenänderungen über mehrere Entities
// ✅ RICHTIG
$this->entityManager->getTransactionManager()->start();
try {
// ... operations
$this->entityManager->getTransactionManager()->commit();
} catch (\Exception $e) {
$this->entityManager->getTransactionManager()->rollback();
throw $e;
}
// ❌ FALSCH - Keine Transaction
$entity = $this->entityManager->createEntity(...);
// Bei Fehler in nächstem Schritt: Inkonsistente Daten!
$this->entityManager->getRepository(...)->relate(...);
3. Repository Caching
// ✅ RICHTIG - Repository nur 1× holen
$targetRepo = $this->entityManager->getRepository('Target');
foreach ($items as $item) {
$targetRepo->getRelation($target, 'items')->relate($item);
}
// ❌ FALSCH - Repository in Schleife holen
foreach ($items as $item) {
$this->entityManager->getRepository('Target')
->getRelation($target, 'items')
->relate($item);
}
4. Error Messages
// ✅ RICHTIG - Spezifische Exceptions
if (!$source) {
throw new NotFound('Mietobjekt not found');
}
if (!$this->acl->check($source, 'read')) {
throw new Forbidden('No read access to Mietobjekt');
}
// ❌ FALSCH - Generische Fehler
if (!$source) {
throw new \Exception('Error');
}
5. Null Checks
// ✅ RICHTIG
$entity = $this->entityManager->createEntity(...);
if (!$entity) {
throw new \RuntimeException('Failed to create entity');
}
// ❌ FALSCH - Keine Prüfung
$entity = $this->entityManager->createEntity(...);
$entity->getId(); // Kann zu Fehler führen!
Sicherheit
1. ACL-Checks (Access Control Lists)
Minimum: Immer beide Checks durchführen:
// Check 1: Kann User Source-Entity lesen?
if (!$this->acl->check($sourceEntity, 'read')) {
throw new Forbidden('No read access');
}
// Check 2: Kann User Target-Entity erstellen?
if (!$this->acl->checkScope('TargetEntity', 'create')) {
throw new Forbidden('No create access');
}
Optional: Zusätzliche Checks je nach Business-Logik:
// Check 3: Kann User Source-Entity bearbeiten?
if (!$this->acl->check($sourceEntity, 'edit')) {
throw new Forbidden('No edit access');
}
// Check 4: Ownership-Check
if ($sourceEntity->get('assignedUserId') !== $this->user->getId()) {
throw new Forbidden('Not the owner');
}
2. Input Validation
// ✅ RICHTIG - ID validieren
$id = $data->id ?? null;
if (!$id || !is_string($id)) {
throw new BadRequest('Invalid ID provided');
}
// Optional: Format prüfen
if (!preg_match('/^[a-f0-9]{17}$/', $id)) {
throw new BadRequest('Invalid ID format');
}
3. SQL Injection Prevention
Automatisch geschützt durch:
- ✅ EntityManager ORM (immer verwenden!)
- ✅ Repository Pattern
- ✅ Prepared Statements
Niemals:
- ❌ Direct SQL Queries
- ❌ String Concatenation in Queries
- ❌
$pdo->query()direkt verwenden
4. XSS Prevention
Automatisch geschützt durch:
- ✅ EspoCRM Template Engine
- ✅ Automatic HTML Escaping
- ✅ Content Security Policy Headers
Im Frontend:
// ✅ RICHTIG - translate() escaped automatisch
this.view.translate('message', 'messages', 'Entity');
// ❌ FALSCH - Rohe HTML-Injection
$('.container').html('<div>' + userInput + '</div>');
5. CSRF Protection
Automatisch geschützt durch:
- ✅ EspoCRM API Token System
- ✅ Espo.Ajax verwendet automatisch CSRF-Token
- ✅ Session-basierte Validierung
Kein manueller Code nötig!
Testing & Debugging
1. Entwickler-Workflow
# 1. Code ändern
vim custom/Espo/Custom/Services/YourEntity.php
# 2. Validieren & Rebuild
python3 custom/scripts/validate_and_rebuild.py
# 3. Browser Hard-Refresh
# Ctrl + Shift + R (Chrome/Firefox)
# Cmd + Shift + R (Mac)
# 4. Test durchführen
# 5. Logs prüfen bei Fehlern
tail -f data/logs/espo-$(date +%Y-%m-%d).log
2. Häufige Fehler & Lösungen
| Fehler | Ursache | Lösung |
|---|---|---|
Error 500: Call to undefined method getAcl() |
Falsche ACL-Methode | Nutze $this->acl statt $this->getAcl() |
Error 500: Entity does not have a relation 'xyz' |
Relation existiert nicht | Prüfe entityDefs, korrekter Relationname |
| Button erscheint nicht | Falsche clientDefs-Struktur | Nutze menu.detail.buttons nicht detailActionList |
| JavaScript-Fehler | Handler nicht gefunden | Prüfe Pfad und Class-Name, Cache leeren |
| Transaction-Fehler | Verschachtelte Transactions | Nur eine Transaction auf Service-Level |
3. Debugging-Kommandos
# Logs live verfolgen
tail -f data/logs/espo-$(date +%Y-%m-%d).log
# Letzten Fehler finden
tail -n 200 data/logs/espo-$(date +%Y-%m-%d).log | grep -i "error"
# PHP-Syntax prüfen
find custom/Espo/Custom -name "*.php" -exec php -l {} \;
# JSON-Syntax prüfen
find custom/Espo/Custom/Resources/metadata -name "*.json" -exec python3 -m json.tool {} \; > /dev/null
# Cache manuell löschen
rm -rf data/cache/*
4. Browser DevTools
Console (F12):
// Current Model inspizieren
console.log(this.model.attributes);
// AJAX Request debuggen
Espo.Ajax.postRequest('Entity/action/test', {id: 'xyz'})
.then(r => console.log('Success:', r))
.catch(e => console.error('Error:', e));
Beispiel-Implementierung
Use Case: "Räumungsklage einleiten"
Requirement:
- Button in Mietobjekt-Detailansicht
- Erstellt CVmhRumungsklage
- Verknüpft Mietverhältnisse, Vermieter (Kläger), Mieter (Beklagte)
- Kopiert assignedUser und Teams
Dateien-Struktur
custom/Espo/Custom/
├── Controllers/
│ └── CMietobjekt.php # POST Action Endpoint
├── Services/
│ └── CMietobjekt.php # Business Logic
└── Resources/
├── metadata/clientDefs/
│ └── CMietobjekt.json # Button Definition
└── i18n/
├── de_DE/
│ └── CMietobjekt.json # Deutsche Übersetzungen
└── en_US/
└── CMietobjekt.json # Englische Übersetzungen
client/custom/src/handlers/
└── mietobjekt/
└── eviction-action.js # Frontend Handler
Vollständiger Code
1. Controller (CMietobjekt.php):
<?php
namespace Espo\Custom\Controllers;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Api\Request;
class CMietobjekt extends \Espo\Core\Templates\Controllers\Base
{
public function postActionInitiateEviction(Request $request): array
{
$data = $request->getParsedBody();
$id = $data->id ?? null;
if (!$id) {
throw new BadRequest('No Mietobjekt ID provided');
}
return $this->getRecordService()->initiateEviction($id);
}
}
2. Service (CMietobjekt.php):
<?php
namespace Espo\Custom\Services;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
class CMietobjekt extends \Espo\Services\Record
{
public function initiateEviction(string $mietobjektId): array
{
// 1. Load & Validate
$mietobjekt = $this->entityManager->getEntity('CMietobjekt', $mietobjektId);
if (!$mietobjekt) {
throw new NotFound('Mietobjekt not found');
}
// 2. ACL Checks
if (!$this->acl->check($mietobjekt, 'read')) {
throw new Forbidden('No read access to Mietobjekt');
}
if (!$this->acl->checkScope('CVmhRumungsklage', 'create')) {
throw new Forbidden('No create access to Räumungsklage');
}
// 3. Start Transaction
$this->entityManager->getTransactionManager()->start();
try {
// 4. Create Räumungsklage
$data = new \stdClass();
$data->name = 'Räumungsklage - ' . $mietobjekt->get('name');
if ($mietobjekt->get('assignedUserId')) {
$data->assignedUserId = $mietobjekt->get('assignedUserId');
}
$teamsIds = $mietobjekt->getLinkMultipleIdList('teams');
if (!empty($teamsIds)) {
$data->teamsIds = $teamsIds;
}
$raeumungsklage = $this->entityManager->createEntity(
'CVmhRumungsklage',
(array)$data
);
if (!$raeumungsklage) {
throw new \RuntimeException('Failed to create Räumungsklage');
}
$repo = $this->entityManager->getRepository('CVmhRumungsklage');
// 5. Link Relations
$repo->getRelation($raeumungsklage, 'mietobjekte')
->relate($mietobjekt);
$mietverhaeltnisse = $this->entityManager
->getRepository('CMietobjekt')
->getRelation($mietobjekt, 'vmhMietverhltnises')
->find();
foreach ($mietverhaeltnisse as $mv) {
$repo->getRelation($raeumungsklage, 'vmhMietverhltnises')
->relate($mv);
// Link Vermieter as Kläger
$vermieter = $this->entityManager
->getRepository('CVmhMietverhltnis')
->getRelation($mv, 'vmhbeteiligtevermieter')
->find();
foreach ($vermieter as $v) {
$repo->getRelation($raeumungsklage, 'klaeger')->relate($v);
}
// Link Mieter as Beklagte
$mieter = $this->entityManager
->getRepository('CVmhMietverhltnis')
->getRelation($mv, 'vmhbeteiligtemieter')
->find();
foreach ($mieter as $m) {
$repo->getRelation($raeumungsklage, 'beklagte')->relate($m);
}
}
// 6. Commit
$this->entityManager->getTransactionManager()->commit();
return [
'id' => $raeumungsklage->getId(),
'name' => $raeumungsklage->get('name')
];
} catch (\Exception $e) {
$this->entityManager->getTransactionManager()->rollback();
throw $e;
}
}
}
3. Button Config (clientDefs/CMietobjekt.json):
{
"menu": {
"detail": {
"buttons": [
{
"name": "initiateEviction",
"label": "Initiate Eviction",
"handler": "custom:handlers/mietobjekt/eviction-action",
"iconHtml": "<span class=\"fas fa-gavel\"></span>",
"style": "danger",
"acl": "edit"
}
]
}
}
}
4. JavaScript Handler (eviction-action.js):
define('custom:handlers/mietobjekt/eviction-action', [], function () {
class EvictionActionHandler {
constructor(view) {
this.view = view;
}
initInitiateEviction() {
// Optional: Button-Logik nach Render
}
actionInitiateEviction() {
this.view.confirm(
this.view.translate('confirmEviction', 'messages', 'CMietobjekt'),
() => this.initiateEviction(this.view.model.id)
);
}
initiateEviction(id) {
Espo.Ui.notify(this.view.translate('pleaseWait', 'messages'));
Espo.Ajax.postRequest('CMietobjekt/action/initiateEviction', { id })
.then(response => {
Espo.Ui.success(
this.view.translate('evictionCreated', 'messages', 'CMietobjekt')
);
this.view.getRouter().navigate(
'#CVmhRumungsklage/view/' + response.id,
{ trigger: true }
);
})
.catch(xhr => {
let msg = this.view.translate('evictionError', 'messages', 'CMietobjekt');
if (xhr.status === 403) {
msg = this.view.translate('Access denied', 'messages');
}
Espo.Ui.error(msg);
});
}
}
return EvictionActionHandler;
});
5. Translations (i18n/de_DE/CMietobjekt.json):
{
"labels": {
"Initiate Eviction": "Räumungsklage einleiten"
},
"messages": {
"confirmEviction": "Möchten Sie wirklich eine Räumungsklage einleiten?",
"evictionCreated": "Räumungsklage wurde erfolgreich erstellt",
"evictionError": "Fehler beim Erstellen der Räumungsklage"
}
}
Checkliste für neue Implementierungen
Vor dem Start
- Requirements klar definiert (Welche Entity? Welche Relationen?)
- Entity-Relationen in entityDefs geprüft
- ACL-Requirements geklärt
- Datenfluss dokumentiert
Implementierung
- Controller erstellt mit
postAction[Name]Methode - Service erstellt mit Business-Logik
- ACL-Checks:
check()für Source +checkScope()für Target - Transaction:
start()→try/catch→commit()/rollback() - Null-Check nach
createEntity() - Repository-Caching bei Loops
- Button in clientDefs konfiguriert (
menu.detail.buttons) - JavaScript Handler erstellt
- Init-Methode:
init[ButtonName]() - Action-Methode:
action[ButtonName]() - Confirmation Dialog implementiert
- Error Handling mit HTTP-Status-Codes
- Translations: Deutsch + Englisch
- Labels + Messages vollständig
Testing
- Validierung erfolgreich:
validate_and_rebuild.py - Browser Hard-Refresh durchgeführt
- Button erscheint in Detailansicht
- Confirmation Dialog funktioniert
- Entity wird korrekt erstellt
- Relationen sind korrekt verknüpft
- Navigation zur neuen Entity funktioniert
- ACL wird respektiert (Test mit eingeschränktem User)
- Error Cases getestet (keine Permission, Entity nicht gefunden)
- Logs geprüft: Keine Errors
Dokumentation
- Code-Kommentare hinzugefügt
- PHPDoc vollständig
- README aktualisiert (falls generische Änderungen)
Support & Weiterentwicklung
Erweiterungen des Templates
Optional implementierbar:
- Conditional Button Visibility (nur unter bestimmten Bedingungen)
- Batch Operations (mehrere Entities gleichzeitig)
- Background Jobs (lange Operationen asynchron)
- Email-Benachrichtigungen nach Erstellung
- PDF-Generierung
- Webhook-Trigger
- Audit Logging
Performance-Optimierungen
Bei großen Datenmengen (> 100 Relations):
- Batch-Insert mit
saveEntity()stattcreateEntity() - Eager Loading mit
with() - Query Optimization mit
select()undwhere() - Background Jobs für lange Operationen
Nützliche Links
Versionierung
| Version | Datum | Änderungen |
|---|---|---|
| 1.0 | 24.01.2026 | Initiale Version basierend auf Räumungsklage-Implementierung |
Erstellt von: EspoCRM Development Team
Letzte Aktualisierung: 24. Januar 2026
Status: Production Ready ✅