claude-mpm 5.4.59__py3-none-any.whl → 5.4.62__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of claude-mpm might be problematic. Click here for more details.
- claude_mpm/VERSION +1 -1
- claude_mpm/agents/CLAUDE_MPM_TEACHER_OUTPUT_STYLE.md +5 -0
- claude_mpm/scripts/start_activity_logging.py +0 -0
- claude_mpm/services/agents/deployment/agent_template_builder.py +8 -0
- claude_mpm/skills/bundled/collaboration/brainstorming/SKILL.md +79 -0
- claude_mpm/skills/bundled/collaboration/dispatching-parallel-agents/SKILL.md +178 -0
- claude_mpm/skills/bundled/collaboration/dispatching-parallel-agents/references/agent-prompts.md +577 -0
- claude_mpm/skills/bundled/collaboration/dispatching-parallel-agents/references/coordination-patterns.md +467 -0
- claude_mpm/skills/bundled/collaboration/dispatching-parallel-agents/references/examples.md +537 -0
- claude_mpm/skills/bundled/collaboration/dispatching-parallel-agents/references/troubleshooting.md +730 -0
- claude_mpm/skills/bundled/collaboration/git-worktrees.md +317 -0
- claude_mpm/skills/bundled/collaboration/requesting-code-review/SKILL.md +112 -0
- claude_mpm/skills/bundled/collaboration/requesting-code-review/references/code-reviewer-template.md +146 -0
- claude_mpm/skills/bundled/collaboration/requesting-code-review/references/review-examples.md +412 -0
- claude_mpm/skills/bundled/collaboration/stacked-prs.md +251 -0
- claude_mpm/skills/bundled/collaboration/writing-plans/SKILL.md +81 -0
- claude_mpm/skills/bundled/collaboration/writing-plans/references/best-practices.md +362 -0
- claude_mpm/skills/bundled/collaboration/writing-plans/references/plan-structure-templates.md +312 -0
- claude_mpm/skills/bundled/debugging/root-cause-tracing/SKILL.md +152 -0
- claude_mpm/skills/bundled/debugging/root-cause-tracing/references/advanced-techniques.md +668 -0
- claude_mpm/skills/bundled/debugging/root-cause-tracing/references/examples.md +587 -0
- claude_mpm/skills/bundled/debugging/root-cause-tracing/references/integration.md +438 -0
- claude_mpm/skills/bundled/debugging/root-cause-tracing/references/tracing-techniques.md +391 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/CREATION-LOG.md +119 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/SKILL.md +148 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/references/anti-patterns.md +483 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/references/examples.md +452 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/references/troubleshooting.md +449 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/references/workflow.md +411 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/test-academic.md +14 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/test-pressure-1.md +58 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/test-pressure-2.md +68 -0
- claude_mpm/skills/bundled/debugging/systematic-debugging/test-pressure-3.md +69 -0
- claude_mpm/skills/bundled/debugging/verification-before-completion/SKILL.md +131 -0
- claude_mpm/skills/bundled/debugging/verification-before-completion/references/gate-function.md +325 -0
- claude_mpm/skills/bundled/debugging/verification-before-completion/references/integration-and-workflows.md +490 -0
- claude_mpm/skills/bundled/debugging/verification-before-completion/references/red-flags-and-failures.md +425 -0
- claude_mpm/skills/bundled/debugging/verification-before-completion/references/verification-patterns.md +499 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/INTEGRATION.md +611 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/README.md +596 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/SKILL.md +260 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/examples/nextjs-env-structure.md +315 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/references/frameworks.md +436 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/references/security.md +433 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/references/synchronization.md +452 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/references/troubleshooting.md +404 -0
- claude_mpm/skills/bundled/infrastructure/env-manager/references/validation.md +420 -0
- claude_mpm/skills/bundled/main/artifacts-builder/SKILL.md +86 -0
- claude_mpm/skills/bundled/main/internal-comms/SKILL.md +43 -0
- claude_mpm/skills/bundled/main/internal-comms/examples/3p-updates.md +47 -0
- claude_mpm/skills/bundled/main/internal-comms/examples/company-newsletter.md +65 -0
- claude_mpm/skills/bundled/main/internal-comms/examples/faq-answers.md +30 -0
- claude_mpm/skills/bundled/main/internal-comms/examples/general-comms.md +16 -0
- claude_mpm/skills/bundled/main/mcp-builder/SKILL.md +160 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/design_principles.md +412 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/evaluation.md +602 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/mcp_best_practices.md +915 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/node_mcp_server.md +916 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/python_mcp_server.md +752 -0
- claude_mpm/skills/bundled/main/mcp-builder/reference/workflow.md +1237 -0
- claude_mpm/skills/bundled/main/skill-creator/SKILL.md +189 -0
- claude_mpm/skills/bundled/main/skill-creator/references/best-practices.md +500 -0
- claude_mpm/skills/bundled/main/skill-creator/references/creation-workflow.md +464 -0
- claude_mpm/skills/bundled/main/skill-creator/references/examples.md +619 -0
- claude_mpm/skills/bundled/main/skill-creator/references/progressive-disclosure.md +437 -0
- claude_mpm/skills/bundled/main/skill-creator/references/skill-structure.md +231 -0
- claude_mpm/skills/bundled/php/espocrm-development/SKILL.md +170 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/architecture.md +602 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/common-tasks.md +821 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/development-workflow.md +742 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/frontend-customization.md +726 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/hooks-and-services.md +764 -0
- claude_mpm/skills/bundled/php/espocrm-development/references/testing-debugging.md +831 -0
- claude_mpm/skills/bundled/pm/pm-delegation-patterns/SKILL.md +167 -0
- claude_mpm/skills/bundled/pm/pm-git-file-tracking/SKILL.md +113 -0
- claude_mpm/skills/bundled/pm/pm-pr-workflow/SKILL.md +124 -0
- claude_mpm/skills/bundled/pm/pm-ticketing-integration/SKILL.md +154 -0
- claude_mpm/skills/bundled/pm/pm-verification-protocols/SKILL.md +198 -0
- claude_mpm/skills/bundled/react/flexlayout-react.md +742 -0
- claude_mpm/skills/bundled/rust/desktop-applications/SKILL.md +226 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/architecture-patterns.md +901 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/native-gui-frameworks.md +901 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/platform-integration.md +775 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/state-management.md +937 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/tauri-framework.md +770 -0
- claude_mpm/skills/bundled/rust/desktop-applications/references/testing-deployment.md +961 -0
- claude_mpm/skills/bundled/tauri/tauri-async-patterns.md +495 -0
- claude_mpm/skills/bundled/tauri/tauri-build-deploy.md +599 -0
- claude_mpm/skills/bundled/tauri/tauri-command-patterns.md +535 -0
- claude_mpm/skills/bundled/tauri/tauri-error-handling.md +613 -0
- claude_mpm/skills/bundled/tauri/tauri-event-system.md +648 -0
- claude_mpm/skills/bundled/tauri/tauri-file-system.md +673 -0
- claude_mpm/skills/bundled/tauri/tauri-frontend-integration.md +767 -0
- claude_mpm/skills/bundled/tauri/tauri-performance.md +669 -0
- claude_mpm/skills/bundled/tauri/tauri-state-management.md +573 -0
- claude_mpm/skills/bundled/tauri/tauri-testing.md +384 -0
- claude_mpm/skills/bundled/tauri/tauri-window-management.md +628 -0
- claude_mpm/skills/bundled/testing/condition-based-waiting/SKILL.md +119 -0
- claude_mpm/skills/bundled/testing/condition-based-waiting/references/patterns-and-implementation.md +253 -0
- claude_mpm/skills/bundled/testing/test-driven-development/SKILL.md +145 -0
- claude_mpm/skills/bundled/testing/test-driven-development/references/anti-patterns.md +543 -0
- claude_mpm/skills/bundled/testing/test-driven-development/references/examples.md +741 -0
- claude_mpm/skills/bundled/testing/test-driven-development/references/integration.md +470 -0
- claude_mpm/skills/bundled/testing/test-driven-development/references/philosophy.md +458 -0
- claude_mpm/skills/bundled/testing/test-driven-development/references/workflow.md +639 -0
- claude_mpm/skills/bundled/testing/test-quality-inspector/SKILL.md +458 -0
- claude_mpm/skills/bundled/testing/test-quality-inspector/examples/example-inspection-report.md +411 -0
- claude_mpm/skills/bundled/testing/test-quality-inspector/references/assertion-quality.md +317 -0
- claude_mpm/skills/bundled/testing/test-quality-inspector/references/inspection-checklist.md +270 -0
- claude_mpm/skills/bundled/testing/test-quality-inspector/references/red-flags.md +436 -0
- claude_mpm/skills/bundled/testing/testing-anti-patterns/SKILL.md +140 -0
- claude_mpm/skills/bundled/testing/testing-anti-patterns/references/completeness-anti-patterns.md +572 -0
- claude_mpm/skills/bundled/testing/testing-anti-patterns/references/core-anti-patterns.md +411 -0
- claude_mpm/skills/bundled/testing/testing-anti-patterns/references/detection-guide.md +569 -0
- claude_mpm/skills/bundled/testing/testing-anti-patterns/references/tdd-connection.md +695 -0
- claude_mpm/skills/bundled/testing/webapp-testing/SKILL.md +184 -0
- claude_mpm/skills/bundled/testing/webapp-testing/decision-tree.md +459 -0
- claude_mpm/skills/bundled/testing/webapp-testing/playwright-patterns.md +479 -0
- claude_mpm/skills/bundled/testing/webapp-testing/reconnaissance-pattern.md +687 -0
- claude_mpm/skills/bundled/testing/webapp-testing/server-management.md +758 -0
- claude_mpm/skills/bundled/testing/webapp-testing/troubleshooting.md +868 -0
- {claude_mpm-5.4.59.dist-info → claude_mpm-5.4.62.dist-info}/METADATA +1 -1
- {claude_mpm-5.4.59.dist-info → claude_mpm-5.4.62.dist-info}/RECORD +127 -10
- {claude_mpm-5.4.59.dist-info → claude_mpm-5.4.62.dist-info}/WHEEL +0 -0
- {claude_mpm-5.4.59.dist-info → claude_mpm-5.4.62.dist-info}/entry_points.txt +0 -0
- {claude_mpm-5.4.59.dist-info → claude_mpm-5.4.62.dist-info}/licenses/LICENSE +0 -0
- {claude_mpm-5.4.59.dist-info → claude_mpm-5.4.62.dist-info}/licenses/LICENSE-FAQ.md +0 -0
- {claude_mpm-5.4.59.dist-info → claude_mpm-5.4.62.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
# Common Tasks Reference
|
|
2
|
+
|
|
3
|
+
## Scheduled Jobs
|
|
4
|
+
|
|
5
|
+
Scheduled jobs run automatically at specified intervals via cron.
|
|
6
|
+
|
|
7
|
+
### Creating a Scheduled Job
|
|
8
|
+
|
|
9
|
+
#### Step 1: Define Job in Metadata
|
|
10
|
+
|
|
11
|
+
Create `src/files/custom/Espo/Modules/MyModule/Resources/metadata/app/scheduledJobs.json`:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"MyCustomJob": {
|
|
16
|
+
"name": "My Custom Job",
|
|
17
|
+
"scheduling": "*/30 * * * *"
|
|
18
|
+
},
|
|
19
|
+
"DataSyncJob": {
|
|
20
|
+
"name": "Sync External Data",
|
|
21
|
+
"scheduling": "0 2 * * *"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Scheduling format (cron syntax):
|
|
27
|
+
```
|
|
28
|
+
* * * * *
|
|
29
|
+
│ │ │ │ │
|
|
30
|
+
│ │ │ │ └─── Day of week (0-6, Sunday = 0)
|
|
31
|
+
│ │ │ └───── Month (1-12)
|
|
32
|
+
│ │ └─────── Day of month (1-31)
|
|
33
|
+
│ └───────── Hour (0-23)
|
|
34
|
+
└─────────── Minute (0-59)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Common schedules:
|
|
38
|
+
- `*/5 * * * *` - Every 5 minutes
|
|
39
|
+
- `0 * * * *` - Every hour
|
|
40
|
+
- `0 2 * * *` - Daily at 2 AM
|
|
41
|
+
- `0 0 * * 0` - Weekly on Sunday at midnight
|
|
42
|
+
|
|
43
|
+
#### Step 2: Implement Job Class
|
|
44
|
+
|
|
45
|
+
Create `src/files/custom/Espo/Modules/MyModule/Jobs/MyCustomJob.php`:
|
|
46
|
+
|
|
47
|
+
```php
|
|
48
|
+
<?php
|
|
49
|
+
namespace Espo\Modules\MyModule\Jobs;
|
|
50
|
+
|
|
51
|
+
use Espo\Core\Job\JobDataLess;
|
|
52
|
+
use Espo\ORM\EntityManager;
|
|
53
|
+
use Espo\Core\Utils\Log;
|
|
54
|
+
|
|
55
|
+
class MyCustomJob implements JobDataLess
|
|
56
|
+
{
|
|
57
|
+
public function __construct(
|
|
58
|
+
private EntityManager $entityManager,
|
|
59
|
+
private Log $log
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
public function run(): void
|
|
63
|
+
{
|
|
64
|
+
$this->log->info('MyCustomJob started');
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
// Job logic
|
|
68
|
+
$this->processRecords();
|
|
69
|
+
|
|
70
|
+
$this->log->info('MyCustomJob completed successfully');
|
|
71
|
+
} catch (\Throwable $e) {
|
|
72
|
+
$this->log->error('MyCustomJob failed: ' . $e->getMessage());
|
|
73
|
+
throw $e;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private function processRecords(): void
|
|
78
|
+
{
|
|
79
|
+
$accounts = $this->entityManager
|
|
80
|
+
->getRDBRepository('Account')
|
|
81
|
+
->where([
|
|
82
|
+
'status' => 'Active',
|
|
83
|
+
'lastContactedAt<' => date('Y-m-d', strtotime('-30 days'))
|
|
84
|
+
])
|
|
85
|
+
->find();
|
|
86
|
+
|
|
87
|
+
foreach ($accounts as $account) {
|
|
88
|
+
$account->set('needsFollowUp', true);
|
|
89
|
+
$this->entityManager->saveEntity($account);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
$this->log->info('Processed ' . count($accounts) . ' accounts');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### Advanced: Job with Data
|
|
98
|
+
|
|
99
|
+
For jobs that need parameters:
|
|
100
|
+
|
|
101
|
+
```php
|
|
102
|
+
<?php
|
|
103
|
+
namespace Espo\Modules\MyModule\Jobs;
|
|
104
|
+
|
|
105
|
+
use Espo\Core\Job\Job;
|
|
106
|
+
use Espo\Core\Job\Job\Data;
|
|
107
|
+
|
|
108
|
+
class DataSyncJob implements Job
|
|
109
|
+
{
|
|
110
|
+
public function __construct(
|
|
111
|
+
private EntityManager $entityManager,
|
|
112
|
+
private Log $log
|
|
113
|
+
) {}
|
|
114
|
+
|
|
115
|
+
public function run(Data $data): void
|
|
116
|
+
{
|
|
117
|
+
$entityType = $data->get('entityType');
|
|
118
|
+
$limit = $data->get('limit') ?? 100;
|
|
119
|
+
|
|
120
|
+
$this->syncData($entityType, $limit);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private function syncData(string $entityType, int $limit): void
|
|
124
|
+
{
|
|
125
|
+
// Implementation
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Running Jobs Manually
|
|
131
|
+
|
|
132
|
+
```php
|
|
133
|
+
// Via service
|
|
134
|
+
use Espo\Core\Job\JobSchedulerFactory;
|
|
135
|
+
|
|
136
|
+
class MyService {
|
|
137
|
+
public function __construct(
|
|
138
|
+
private JobSchedulerFactory $jobSchedulerFactory
|
|
139
|
+
) {}
|
|
140
|
+
|
|
141
|
+
public function triggerJob(): void
|
|
142
|
+
{
|
|
143
|
+
$jobScheduler = $this->jobSchedulerFactory->create();
|
|
144
|
+
|
|
145
|
+
$jobScheduler->scheduleJob('MyCustomJob', [
|
|
146
|
+
'entityType' => 'Account',
|
|
147
|
+
'limit' => 50
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Email Management
|
|
154
|
+
|
|
155
|
+
### Sending Emails
|
|
156
|
+
|
|
157
|
+
```php
|
|
158
|
+
<?php
|
|
159
|
+
namespace Espo\Modules\MyModule\Services;
|
|
160
|
+
|
|
161
|
+
use Espo\Core\Mail\EmailSender;
|
|
162
|
+
use Espo\Entities\Email;
|
|
163
|
+
use Espo\ORM\EntityManager;
|
|
164
|
+
|
|
165
|
+
class NotificationService
|
|
166
|
+
{
|
|
167
|
+
public function __construct(
|
|
168
|
+
private EmailSender $emailSender,
|
|
169
|
+
private EntityManager $entityManager
|
|
170
|
+
) {}
|
|
171
|
+
|
|
172
|
+
public function sendWelcomeEmail(string $contactId): void
|
|
173
|
+
{
|
|
174
|
+
$contact = $this->entityManager->getEntityById('Contact', $contactId);
|
|
175
|
+
|
|
176
|
+
if (!$contact) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
$emailAddress = $contact->get('emailAddress');
|
|
181
|
+
|
|
182
|
+
if (!$emailAddress) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
$sender = $this->emailSender->create();
|
|
187
|
+
|
|
188
|
+
$sender
|
|
189
|
+
->withSubject('Welcome to Our Platform')
|
|
190
|
+
->withBody('Dear ' . $contact->get('name') . ',\n\nWelcome!')
|
|
191
|
+
->withTo($emailAddress)
|
|
192
|
+
->send();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
public function sendEmailWithTemplate(string $contactId): void
|
|
196
|
+
{
|
|
197
|
+
$contact = $this->entityManager->getEntityById('Contact', $contactId);
|
|
198
|
+
|
|
199
|
+
if (!$contact) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Create email entity
|
|
204
|
+
$email = $this->entityManager->getNewEntity('Email');
|
|
205
|
+
|
|
206
|
+
$email->set([
|
|
207
|
+
'to' => $contact->get('emailAddress'),
|
|
208
|
+
'subject' => 'Welcome',
|
|
209
|
+
'body' => $this->renderTemplate($contact),
|
|
210
|
+
'isHtml' => true,
|
|
211
|
+
'parentType' => 'Contact',
|
|
212
|
+
'parentId' => $contactId
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
$this->entityManager->saveEntity($email);
|
|
216
|
+
|
|
217
|
+
// Send
|
|
218
|
+
$sender = $this->emailSender->create();
|
|
219
|
+
$sender
|
|
220
|
+
->withEnvelopeOptions([
|
|
221
|
+
'from' => 'noreply@example.com'
|
|
222
|
+
])
|
|
223
|
+
->send($email);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private function renderTemplate($contact): string
|
|
227
|
+
{
|
|
228
|
+
return '<h1>Welcome ' . htmlspecialchars($contact->get('name')) . '</h1>';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Email Templates
|
|
234
|
+
|
|
235
|
+
Create `src/files/custom/Espo/Modules/MyModule/Resources/metadata/app/emailTemplates.json`:
|
|
236
|
+
|
|
237
|
+
```json
|
|
238
|
+
{
|
|
239
|
+
"welcomeEmail": {
|
|
240
|
+
"subject": "Welcome {Contact.name}",
|
|
241
|
+
"body": "<p>Dear {Contact.name},</p><p>Welcome to our platform!</p>"
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Using email templates:
|
|
247
|
+
|
|
248
|
+
```php
|
|
249
|
+
use Espo\Tools\EmailTemplate\Processor;
|
|
250
|
+
|
|
251
|
+
class EmailService {
|
|
252
|
+
public function __construct(
|
|
253
|
+
private Processor $emailTemplateProcessor,
|
|
254
|
+
private EmailSender $emailSender,
|
|
255
|
+
private EntityManager $entityManager
|
|
256
|
+
) {}
|
|
257
|
+
|
|
258
|
+
public function sendFromTemplate(string $templateId, string $entityId): void
|
|
259
|
+
{
|
|
260
|
+
$template = $this->entityManager->getEntityById('EmailTemplate', $templateId);
|
|
261
|
+
$entity = $this->entityManager->getEntityById('Contact', $entityId);
|
|
262
|
+
|
|
263
|
+
// Process template (replace placeholders)
|
|
264
|
+
$data = $this->emailTemplateProcessor->process($template, [
|
|
265
|
+
'entityType' => 'Contact',
|
|
266
|
+
'entity' => $entity
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
// Send
|
|
270
|
+
$sender = $this->emailSender->create();
|
|
271
|
+
$sender
|
|
272
|
+
->withSubject($data->getSubject())
|
|
273
|
+
->withBody($data->getBody())
|
|
274
|
+
->withTo($entity->get('emailAddress'))
|
|
275
|
+
->withIsHtml($data->isHtml())
|
|
276
|
+
->send();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## PDF Generation
|
|
282
|
+
|
|
283
|
+
### Creating PDF Templates
|
|
284
|
+
|
|
285
|
+
PDF templates use HTML with placeholders.
|
|
286
|
+
|
|
287
|
+
Create `custom/Espo/Custom/Resources/templates/Invoice.html`:
|
|
288
|
+
|
|
289
|
+
```html
|
|
290
|
+
<!DOCTYPE html>
|
|
291
|
+
<html>
|
|
292
|
+
<head>
|
|
293
|
+
<style>
|
|
294
|
+
body { font-family: Arial, sans-serif; }
|
|
295
|
+
.header { text-align: center; margin-bottom: 30px; }
|
|
296
|
+
.invoice-details { margin-bottom: 20px; }
|
|
297
|
+
table { width: 100%; border-collapse: collapse; }
|
|
298
|
+
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
299
|
+
th { background-color: #f2f2f2; }
|
|
300
|
+
.total { text-align: right; font-weight: bold; }
|
|
301
|
+
</style>
|
|
302
|
+
</head>
|
|
303
|
+
<body>
|
|
304
|
+
<div class="header">
|
|
305
|
+
<h1>INVOICE</h1>
|
|
306
|
+
<p>Invoice #{{number}}</p>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
<div class="invoice-details">
|
|
310
|
+
<p><strong>Date:</strong> {{dateInvoiced}}</p>
|
|
311
|
+
<p><strong>Customer:</strong> {{account.name}}</p>
|
|
312
|
+
<p><strong>Amount:</strong> {{amount}}</p>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
<table>
|
|
316
|
+
<thead>
|
|
317
|
+
<tr>
|
|
318
|
+
<th>Item</th>
|
|
319
|
+
<th>Quantity</th>
|
|
320
|
+
<th>Price</th>
|
|
321
|
+
<th>Total</th>
|
|
322
|
+
</tr>
|
|
323
|
+
</thead>
|
|
324
|
+
<tbody>
|
|
325
|
+
{{#each items}}
|
|
326
|
+
<tr>
|
|
327
|
+
<td>{{name}}</td>
|
|
328
|
+
<td>{{quantity}}</td>
|
|
329
|
+
<td>{{price}}</td>
|
|
330
|
+
<td>{{total}}</td>
|
|
331
|
+
</tr>
|
|
332
|
+
{{/each}}
|
|
333
|
+
</tbody>
|
|
334
|
+
</table>
|
|
335
|
+
|
|
336
|
+
<div class="total">
|
|
337
|
+
<p>Total: {{amount}}</p>
|
|
338
|
+
</div>
|
|
339
|
+
</body>
|
|
340
|
+
</html>
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Generating PDFs Programmatically
|
|
344
|
+
|
|
345
|
+
```php
|
|
346
|
+
<?php
|
|
347
|
+
namespace Espo\Modules\MyModule\Services;
|
|
348
|
+
|
|
349
|
+
use Espo\Tools\Pdf\Service as PdfService;
|
|
350
|
+
use Espo\ORM\EntityManager;
|
|
351
|
+
|
|
352
|
+
class InvoiceService
|
|
353
|
+
{
|
|
354
|
+
public function __construct(
|
|
355
|
+
private PdfService $pdfService,
|
|
356
|
+
private EntityManager $entityManager
|
|
357
|
+
) {}
|
|
358
|
+
|
|
359
|
+
public function generateInvoicePdf(string $invoiceId): string
|
|
360
|
+
{
|
|
361
|
+
$invoice = $this->entityManager->getEntityById('Invoice', $invoiceId);
|
|
362
|
+
|
|
363
|
+
if (!$invoice) {
|
|
364
|
+
throw new \Espo\Core\Exceptions\NotFound();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Generate PDF
|
|
368
|
+
$contents = $this->pdfService->generate(
|
|
369
|
+
'Invoice', // Entity type
|
|
370
|
+
$invoiceId, // Entity ID
|
|
371
|
+
'Invoice' // Template name
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
// Save to file
|
|
375
|
+
$fileName = 'invoice_' . $invoice->get('number') . '.pdf';
|
|
376
|
+
$filePath = 'data/upload/' . $fileName;
|
|
377
|
+
|
|
378
|
+
file_put_contents($filePath, $contents);
|
|
379
|
+
|
|
380
|
+
return $filePath;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## Access Control (ACL)
|
|
386
|
+
|
|
387
|
+
### Implementing Custom ACL
|
|
388
|
+
|
|
389
|
+
Create `src/files/custom/Espo/Modules/MyModule/Acl/MyEntity.php`:
|
|
390
|
+
|
|
391
|
+
```php
|
|
392
|
+
<?php
|
|
393
|
+
namespace Espo\Modules\MyModule\Acl;
|
|
394
|
+
|
|
395
|
+
use Espo\ORM\Entity;
|
|
396
|
+
use Espo\Core\Acl\Table;
|
|
397
|
+
use Espo\Entities\User;
|
|
398
|
+
use Espo\Core\Acl\AccessEntityCREDChecker;
|
|
399
|
+
use Espo\Core\Acl\DefaultAccessChecker;
|
|
400
|
+
use Espo\Core\Acl\ScopeData;
|
|
401
|
+
use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency;
|
|
402
|
+
|
|
403
|
+
class MyEntity implements AccessEntityCREDChecker
|
|
404
|
+
{
|
|
405
|
+
use DefaultAccessCheckerDependency;
|
|
406
|
+
|
|
407
|
+
public function __construct(
|
|
408
|
+
private DefaultAccessChecker $defaultAccessChecker
|
|
409
|
+
) {}
|
|
410
|
+
|
|
411
|
+
public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool
|
|
412
|
+
{
|
|
413
|
+
// Custom read permission logic
|
|
414
|
+
|
|
415
|
+
// Check if user is assigned
|
|
416
|
+
if ($entity->get('assignedUserId') === $user->getId()) {
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Check if user is in account team
|
|
421
|
+
if ($entity->get('accountId')) {
|
|
422
|
+
$account = $entity->get('account');
|
|
423
|
+
if ($this->isUserInAccountTeams($user, $account)) {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Fall back to default ACL check
|
|
429
|
+
return $this->defaultAccessChecker->checkEntityRead($user, $entity, $data);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool
|
|
433
|
+
{
|
|
434
|
+
// Custom create permission logic
|
|
435
|
+
if ($user->get('type') === 'portal') {
|
|
436
|
+
// Portal users can only create if they have an account
|
|
437
|
+
return $entity->get('accountId') !== null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return $this->defaultAccessChecker->checkEntityCreate($user, $entity, $data);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool
|
|
444
|
+
{
|
|
445
|
+
// Custom edit permission logic
|
|
446
|
+
|
|
447
|
+
// Only owner can edit after 7 days
|
|
448
|
+
$createdAt = $entity->get('createdAt');
|
|
449
|
+
if ($createdAt) {
|
|
450
|
+
$daysSinceCreation = (time() - strtotime($createdAt)) / 86400;
|
|
451
|
+
|
|
452
|
+
if ($daysSinceCreation > 7) {
|
|
453
|
+
if ($entity->get('createdById') !== $user->getId()) {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return $this->defaultAccessChecker->checkEntityEdit($user, $entity, $data);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool
|
|
463
|
+
{
|
|
464
|
+
// Custom delete permission logic
|
|
465
|
+
|
|
466
|
+
// Prevent deletion of completed items
|
|
467
|
+
if ($entity->get('status') === 'Complete') {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return $this->defaultAccessChecker->checkEntityDelete($user, $entity, $data);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private function isUserInAccountTeams(User $user, ?Entity $account): bool
|
|
475
|
+
{
|
|
476
|
+
if (!$account) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
$userTeamIds = array_column($user->get('teams')->toArray(), 'id');
|
|
481
|
+
$accountTeamIds = array_column($account->get('teams')->toArray(), 'id');
|
|
482
|
+
|
|
483
|
+
return !empty(array_intersect($userTeamIds, $accountTeamIds));
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### Checking ACL in Code
|
|
489
|
+
|
|
490
|
+
```php
|
|
491
|
+
// Check entity-level permission
|
|
492
|
+
if (!$this->acl->check($entity, 'read')) {
|
|
493
|
+
throw new Forbidden();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Check scope-level permission
|
|
497
|
+
if (!$this->acl->check('Account', 'create')) {
|
|
498
|
+
throw new Forbidden();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Check field-level permission
|
|
502
|
+
if (!$this->acl->checkField('Account', 'billingAddress', 'edit')) {
|
|
503
|
+
throw new Forbidden('Cannot edit billing address');
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Check ownership level
|
|
507
|
+
$level = $this->acl->getLevel('Account', 'read');
|
|
508
|
+
// Levels: all, team, own, no
|
|
509
|
+
|
|
510
|
+
// Filter query by ACL
|
|
511
|
+
$query = $this->entityManager
|
|
512
|
+
->getQueryBuilder()
|
|
513
|
+
->select()
|
|
514
|
+
->from('Account')
|
|
515
|
+
->build();
|
|
516
|
+
|
|
517
|
+
$this->acl->applyFilter($query, 'Account', 'read');
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
## Workflow Customization
|
|
521
|
+
|
|
522
|
+
### Custom Workflow Action
|
|
523
|
+
|
|
524
|
+
Create `src/files/custom/Espo/Modules/MyModule/Classes/Workflow/Actions/SendSlackNotification.php`:
|
|
525
|
+
|
|
526
|
+
```php
|
|
527
|
+
<?php
|
|
528
|
+
namespace Espo\Modules\MyModule\Classes\Workflow\Actions;
|
|
529
|
+
|
|
530
|
+
use Espo\Core\Workflow\Action;
|
|
531
|
+
use Espo\Core\Workflow\Action\Params;
|
|
532
|
+
use Espo\ORM\Entity;
|
|
533
|
+
|
|
534
|
+
class SendSlackNotification implements Action
|
|
535
|
+
{
|
|
536
|
+
public function __construct(
|
|
537
|
+
private SlackClient $slackClient
|
|
538
|
+
) {}
|
|
539
|
+
|
|
540
|
+
public function run(Entity $entity, Params $params): bool
|
|
541
|
+
{
|
|
542
|
+
$channel = $params->get('channel') ?? '#general';
|
|
543
|
+
$message = $params->get('message') ?? 'Entity updated';
|
|
544
|
+
|
|
545
|
+
// Replace placeholders
|
|
546
|
+
$message = str_replace('{name}', $entity->get('name'), $message);
|
|
547
|
+
|
|
548
|
+
$this->slackClient->sendMessage($channel, $message);
|
|
549
|
+
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
### Custom Workflow Condition
|
|
556
|
+
|
|
557
|
+
Create `src/files/custom/Espo/Modules/MyModule/Classes/Workflow/Conditions/IsHighValue.php`:
|
|
558
|
+
|
|
559
|
+
```php
|
|
560
|
+
<?php
|
|
561
|
+
namespace Espo\Modules\MyModule\Classes\Workflow\Conditions;
|
|
562
|
+
|
|
563
|
+
use Espo\Core\Workflow\Condition;
|
|
564
|
+
use Espo\Core\Workflow\Condition\Params;
|
|
565
|
+
use Espo\ORM\Entity;
|
|
566
|
+
|
|
567
|
+
class IsHighValue implements Condition
|
|
568
|
+
{
|
|
569
|
+
public function check(Entity $entity, Params $params): bool
|
|
570
|
+
{
|
|
571
|
+
$threshold = $params->get('threshold') ?? 10000;
|
|
572
|
+
$amount = $entity->get('amount') ?? 0;
|
|
573
|
+
|
|
574
|
+
return $amount >= $threshold;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
## Integration Patterns
|
|
580
|
+
|
|
581
|
+
### REST API Integration
|
|
582
|
+
|
|
583
|
+
```php
|
|
584
|
+
<?php
|
|
585
|
+
namespace Espo\Modules\MyModule\Services;
|
|
586
|
+
|
|
587
|
+
use Espo\Core\Utils\Config;
|
|
588
|
+
use Espo\Core\Utils\Log;
|
|
589
|
+
|
|
590
|
+
class ExternalApiService
|
|
591
|
+
{
|
|
592
|
+
private string $apiUrl;
|
|
593
|
+
private string $apiKey;
|
|
594
|
+
|
|
595
|
+
public function __construct(
|
|
596
|
+
private Config $config,
|
|
597
|
+
private Log $log
|
|
598
|
+
) {
|
|
599
|
+
$this->apiUrl = $this->config->get('externalApiUrl');
|
|
600
|
+
$this->apiKey = $this->config->get('externalApiKey');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
public function fetchCustomerData(string $customerId): ?array
|
|
604
|
+
{
|
|
605
|
+
$url = $this->apiUrl . '/customers/' . $customerId;
|
|
606
|
+
|
|
607
|
+
$ch = curl_init();
|
|
608
|
+
|
|
609
|
+
curl_setopt_array($ch, [
|
|
610
|
+
CURLOPT_URL => $url,
|
|
611
|
+
CURLOPT_RETURNTRANSFER => true,
|
|
612
|
+
CURLOPT_HTTPHEADER => [
|
|
613
|
+
'Authorization: Bearer ' . $this->apiKey,
|
|
614
|
+
'Content-Type: application/json'
|
|
615
|
+
]
|
|
616
|
+
]);
|
|
617
|
+
|
|
618
|
+
$response = curl_exec($ch);
|
|
619
|
+
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
620
|
+
|
|
621
|
+
curl_close($ch);
|
|
622
|
+
|
|
623
|
+
if ($httpCode !== 200) {
|
|
624
|
+
$this->log->error('External API request failed: ' . $httpCode);
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return json_decode($response, true);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
public function syncCustomer(Entity $account): bool
|
|
632
|
+
{
|
|
633
|
+
$data = [
|
|
634
|
+
'name' => $account->get('name'),
|
|
635
|
+
'email' => $account->get('emailAddress'),
|
|
636
|
+
'phone' => $account->get('phoneNumber')
|
|
637
|
+
];
|
|
638
|
+
|
|
639
|
+
$url = $this->apiUrl . '/customers';
|
|
640
|
+
|
|
641
|
+
$ch = curl_init();
|
|
642
|
+
|
|
643
|
+
curl_setopt_array($ch, [
|
|
644
|
+
CURLOPT_URL => $url,
|
|
645
|
+
CURLOPT_RETURNTRANSFER => true,
|
|
646
|
+
CURLOPT_POST => true,
|
|
647
|
+
CURLOPT_POSTFIELDS => json_encode($data),
|
|
648
|
+
CURLOPT_HTTPHEADER => [
|
|
649
|
+
'Authorization: Bearer ' . $this->apiKey,
|
|
650
|
+
'Content-Type: application/json'
|
|
651
|
+
]
|
|
652
|
+
]);
|
|
653
|
+
|
|
654
|
+
$response = curl_exec($ch);
|
|
655
|
+
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
656
|
+
|
|
657
|
+
curl_close($ch);
|
|
658
|
+
|
|
659
|
+
if ($httpCode === 201) {
|
|
660
|
+
$responseData = json_decode($response, true);
|
|
661
|
+
$account->set('externalId', $responseData['id']);
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
$this->log->error('Failed to sync customer: ' . $httpCode);
|
|
666
|
+
return false;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
### Webhook Handler
|
|
672
|
+
|
|
673
|
+
```php
|
|
674
|
+
<?php
|
|
675
|
+
namespace Espo\Modules\MyModule\Controllers;
|
|
676
|
+
|
|
677
|
+
use Espo\Core\Api\Request;
|
|
678
|
+
use Espo\Core\Api\Response;
|
|
679
|
+
use Espo\Core\Controllers\Base;
|
|
680
|
+
use Espo\Core\Exceptions\BadRequest;
|
|
681
|
+
|
|
682
|
+
class Webhook extends Base
|
|
683
|
+
{
|
|
684
|
+
public function postActionReceive(Request $request, Response $response): bool
|
|
685
|
+
{
|
|
686
|
+
$data = $request->getParsedBody();
|
|
687
|
+
|
|
688
|
+
if (!$data->event) {
|
|
689
|
+
throw new BadRequest('Missing event type');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Verify webhook signature
|
|
693
|
+
$signature = $request->getHeader('X-Webhook-Signature');
|
|
694
|
+
if (!$this->verifySignature($signature, $request->getBodyContents())) {
|
|
695
|
+
throw new Forbidden('Invalid signature');
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Process webhook
|
|
699
|
+
$service = $this->getRecordService('WebhookEvent');
|
|
700
|
+
$service->processWebhook($data->event, $data);
|
|
701
|
+
|
|
702
|
+
$response->setStatus(200);
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
private function verifySignature(?string $signature, string $payload): bool
|
|
707
|
+
{
|
|
708
|
+
if (!$signature) {
|
|
709
|
+
return false;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
$secret = $this->config->get('webhookSecret');
|
|
713
|
+
$expectedSignature = hash_hmac('sha256', $payload, $secret);
|
|
714
|
+
|
|
715
|
+
return hash_equals($expectedSignature, $signature);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
## File Handling
|
|
721
|
+
|
|
722
|
+
### File Upload and Storage
|
|
723
|
+
|
|
724
|
+
```php
|
|
725
|
+
<?php
|
|
726
|
+
namespace Espo\Modules\MyModule\Services;
|
|
727
|
+
|
|
728
|
+
use Espo\Core\FileStorage\Manager as FileStorageManager;
|
|
729
|
+
use Espo\Entities\Attachment;
|
|
730
|
+
|
|
731
|
+
class DocumentService
|
|
732
|
+
{
|
|
733
|
+
public function __construct(
|
|
734
|
+
private FileStorageManager $fileStorageManager,
|
|
735
|
+
private EntityManager $entityManager
|
|
736
|
+
) {}
|
|
737
|
+
|
|
738
|
+
public function uploadFile(string $filePath, string $name, string $type): Attachment
|
|
739
|
+
{
|
|
740
|
+
$contents = file_get_contents($filePath);
|
|
741
|
+
|
|
742
|
+
$attachment = $this->entityManager->getNewEntity('Attachment');
|
|
743
|
+
$attachment->set([
|
|
744
|
+
'name' => $name,
|
|
745
|
+
'type' => $type,
|
|
746
|
+
'size' => strlen($contents),
|
|
747
|
+
'role' => 'Attachment'
|
|
748
|
+
]);
|
|
749
|
+
|
|
750
|
+
$this->entityManager->saveEntity($attachment);
|
|
751
|
+
|
|
752
|
+
// Store file
|
|
753
|
+
$this->fileStorageManager->putContents($attachment, $contents);
|
|
754
|
+
|
|
755
|
+
return $attachment;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
public function getFileContents(string $attachmentId): ?string
|
|
759
|
+
{
|
|
760
|
+
$attachment = $this->entityManager->getEntityById('Attachment', $attachmentId);
|
|
761
|
+
|
|
762
|
+
if (!$attachment) {
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return $this->fileStorageManager->getContents($attachment);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
## Custom Entry Points
|
|
772
|
+
|
|
773
|
+
Entry points are public endpoints (no authentication required).
|
|
774
|
+
|
|
775
|
+
```php
|
|
776
|
+
<?php
|
|
777
|
+
namespace Espo\Modules\MyModule\EntryPoints;
|
|
778
|
+
|
|
779
|
+
use Espo\Core\EntryPoint\EntryPoint;
|
|
780
|
+
use Espo\Core\Api\Request;
|
|
781
|
+
use Espo\Core\Api\Response;
|
|
782
|
+
|
|
783
|
+
class PublicDownload implements EntryPoint
|
|
784
|
+
{
|
|
785
|
+
public function run(Request $request, Response $response): void
|
|
786
|
+
{
|
|
787
|
+
$id = $request->getQueryParam('id');
|
|
788
|
+
|
|
789
|
+
if (!$id) {
|
|
790
|
+
$response->setStatus(400);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Fetch file
|
|
795
|
+
$attachment = $this->entityManager->getEntityById('Attachment', $id);
|
|
796
|
+
|
|
797
|
+
if (!$attachment || !$attachment->get('isPublic')) {
|
|
798
|
+
$response->setStatus(404);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Serve file
|
|
803
|
+
$contents = $this->fileStorageManager->getContents($attachment);
|
|
804
|
+
|
|
805
|
+
$response->setHeader('Content-Type', $attachment->get('type'));
|
|
806
|
+
$response->setHeader('Content-Disposition', 'attachment; filename="' . $attachment->get('name') . '"');
|
|
807
|
+
$response->writeBody($contents);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
Register in metadata (`app/entryPoints.json`):
|
|
813
|
+
```json
|
|
814
|
+
{
|
|
815
|
+
"publicDownload": {
|
|
816
|
+
"className": "Espo\\Modules\\MyModule\\EntryPoints\\PublicDownload"
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
```
|
|
820
|
+
|
|
821
|
+
Access via: `?entryPoint=publicDownload&id=ATTACHMENT_ID`
|