Reuse of existing APIs
Before you write a PDF generator, a notification table or an SMTP client, check whether a community app already offers it. The apps below expose a small service that you get with getService() and call from controllers, crons and event listeners. Reusing them keeps your app small and gives the user one place for documents, notifications and e-mails.
| Need | App | Class |
|---|---|---|
| PDF from a template | Documents |
Hubleto\App\Community\Documents\Generator |
| Internal notification | Notifications |
Hubleto\App\Community\Notifications\Sender |
Mail |
Hubleto\App\Community\Mail\Loader (method send()), or a queued Mail\Models\Mail record |
Add the app to requires in manifest.yaml, so that it is installed together with yours (see manifest.yaml):
requires:
- Hubleto\App\Community\Documents
- Hubleto\App\Community\Notifications
- Hubleto\App\Community\Mail
Generating documents: Documents\Generator
Generator renders a Twig template of the Documents app with the variables of a record and stores the result as a PDF document in the user's files. Templates are managed by the user in Documents → Templates; a model can provide a default template with getDocumentDefaultTemplate(). The PDF itself is produced by an external service that is configured as pdfGeneratorEndpoint. The model's table needs the columns id_template, id_document and pdf. See Custom document and invoice templates.
| Method | Purpose |
|---|---|
getPreviewVars($model, $recordId) |
Variables of the record available in the template. |
getPreviewHtml($model, $recordId, $idTemplate) |
HTML preview of the document. |
renderTemplate($model, $idTemplate, $vars) |
Renders the template with the variables. |
generatePdf($model, $recordId, $documentName) |
Generates the PDF of the record (uses the record's id_template), saves it as a document and writes pdf and id_document to the record. Returns [$idDocument, $filename]. |
createPdfDocumentFromTemplate($documentName, $model, $recordId, $idTemplate, $outputFilename, $vars) |
The same with an explicit template and variables. Returns the ID of the document. |
generatePdfDocumentFromTemplate(..., $createDocumentEntry = false) |
Lower-level variant; $createDocumentEntry decides whether a Document record is created. |
Methods of Generator.
PriceLists generates the PDF in an API controller and links the new document to the price list:
apps/PriceLists/Controllers/Api/GeneratePdf.php
<?php
namespace Hubleto\App\Community\PriceLists\Controllers\Api;
use Hubleto\App\Community\Documents\Generator;
use Hubleto\App\Community\PriceLists\Models\PriceList;
use Hubleto\App\Community\PriceLists\Models\PriceListHasDocument;
/**
* Generates a PDF of a price list with Documents\Generator and links the document to the price list.
*/
class GeneratePdf extends \Hubleto\Erp\Controllers\ApiController
{
public function renderJson(): array
{
$idPriceList = $this->router()->urlParamAsInteger('idPriceList');
/** @var PriceList $mPriceList */
$mPriceList = $this->getModel(PriceList::class);
$priceList = $mPriceList->record->where($mPriceList->table . '.id', $idPriceList)->first();
if (!$priceList) return ['status' => 'error', 'message' => $this->translate('Price list was not found.')];
try {
$result = $this->generate($priceList);
} catch (\Throwable $e) {
return ['status' => 'error', 'message' => $e->getMessage()];
}
return ['status' => 'success', 'idDocument' => $result['idDocument'], 'file' => $result['file']];
}
/**
* Generates the PDF and links it to the price list.
*
* @param mixed $priceList Record of the price list
*
* @return array ['idDocument', 'file']
*
*/
public function generate(mixed $priceList): array
{
/** @var Generator $generator */
$generator = $this->getService(Generator::class);
$documentName = $priceList->code . ' ' . $priceList->name;
$slug = trim((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($documentName)), '-');
$file = ($slug === '' ? 'price-list' : $slug) . '-' . date('Ymd-His') . '.pdf';
$vars = $generator->getPreviewVars(PriceList::class, (int) $priceList->id);
$idDocument = $generator->generatePdfDocumentFromTemplate($documentName, PriceList::class, (int) $priceList->id, 0, $file, $vars, true);
if ($idDocument > 0) {
/** @var PriceListHasDocument $mLink */
$mLink = $this->getModel(PriceListHasDocument::class);
$exists = $mLink->record
->where($mLink->table . '.id_price_list', (int) $priceList->id)
->where($mLink->table . '.id_document', $idDocument)
->count() > 0
;
if (!$exists) $mLink->record->recordCreate(['id_price_list' => (int) $priceList->id, 'id_document' => $idDocument]);
}
return ['idDocument' => $idDocument, 'file' => $file];
}
}
Deals does the same in a model method and returns the ID of the document to the browser:
apps/Deals/Controllers/Api/GenerateQuotationPdf.php
<?php declare(strict_types=1);
namespace Hubleto\App\Community\Deals\Controllers\Api;
use Hubleto\App\Community\Deals\Models\Deal;
class GenerateQuotationPdf extends \Hubleto\Erp\Controllers\ApiController
{
public function renderJson(): array
{
$idDeal = $this->router()->urlParamAsInteger('idDeal');
/** @var Deal */
$mDeal = $this->getModel(Deal::class);
$idDocument = $mDeal->generateQuotationPdf($idDeal);
return [
'idDocument' => $idDocument
];
}
}
Sending notifications: Notifications\Sender
Sender::send() creates a notification for a user. It is shown in the notifications of the recipient and links to the record.
public function send(
int $category, // number of the category, e.g. 900 for your own notifications
array $tags, // tags used to filter notifications
string $model, // the model of the record the notification is about
int $recordId, // the ID of the record
int $idTo, // the recipient (ID of a user)
string $subject,
string $body,
string $url = '', // where the notification leads
string $color = '',
int $priority = 0
): array|bool
apps/PriceLists/Crons/WarnAboutExpiry.php (excerpt)
foreach ($counter->expiringPriceLists() as $priceList) {
$idOwner = (int) ($priceList['id_owner'] ?? 0);
if ($idOwner <= 0) continue;
$sender->send(
900,
['price-lists', 'expiry'],
PriceList::class,
(int) $priceList['id'],
$idOwner,
$this->translate('Price list is about to expire'),
$priceList['code'] . ' ' . $priceList['name'] . ' - ' . $this->translate('valid to') . ' ' . $priceList['valid_to'],
'price-lists/' . $priceList['id']
);
}
NOTE
send()takes the sender from the session. It returnsfalseand stores nothing when nobody is signed in, or when the recipient is the signed-in user. In a cron there is no session, so the call above delivers nothing with the current code. UntilSenderaccepts an explicit sender, send notifications from event listeners and controllers, and describe the needed change incommunity-apps-patches.md(see Patch files).
Sending e-mails: Mail
The Mail app offers two different ways to create an e-mail. Choose according to what you need.
1. Mail\Loader->send(): a message inside Hubleto
send() creates a mail record on behalf of the signed-in user (the sender is the e-mail of that user) and adds the message to the mailbox of every Hubleto user found among the recipients. The recipients ($to, $cc, $bcc) are a string with e-mail addresses or the ID of a user. It returns the created record, or an empty array when nobody with an e-mail address is signed in.
public function send(
int|string $to,
int|string $cc,
int|string $bcc,
string $subject,
string $body,
string $color = '',
int $priority = 0
): array
apps/PriceLists/Controllers/Api/SendByEmail.php
<?php
namespace Hubleto\App\Community\PriceLists\Controllers\Api;
use Hubleto\App\Community\Mail\Loader as MailApp;
use Hubleto\App\Community\PriceLists\Models\PriceList;
/**
* Generates the PDF of a price list and sends a link to it by e-mail with Mail\Loader->send().
* Parameters: idPriceList, to (e-mail address), subject, body.
*/
class SendByEmail extends GeneratePdf
{
public function renderJson(): array
{
$idPriceList = $this->router()->urlParamAsInteger('idPriceList');
$to = trim($this->router()->urlParamAsString('to'));
if ($to === '') return ['status' => 'error', 'message' => $this->translate('Enter the e-mail address of the recipient.')];
/** @var PriceList $mPriceList */
$mPriceList = $this->getModel(PriceList::class);
$priceList = $mPriceList->record->where($mPriceList->table . '.id', $idPriceList)->first();
if (!$priceList) return ['status' => 'error', 'message' => $this->translate('Price list was not found.')];
try {
$pdf = $this->generate($priceList);
/** @var MailApp $mail */
$mail = $this->getService(MailApp::class);
$subject = $this->router()->urlParamAsString('subject', $this->translate('Price list') . ' ' . $priceList->code . ' ' . $priceList->name);
$body = $this->router()->urlParamAsString('body', $this->translate('Please find the price list at the link below.'))
. '<br/><br/><a href="' . $this->env()->uploadUrl . '/' . $pdf['file'] . '">' . $pdf['file'] . '</a>'
;
$mail->send($to, '', '', $subject, $body);
} catch (\Throwable $e) {
return ['status' => 'error', 'message' => $e->getMessage()];
}
return ['status' => 'success', 'sent' => true];
}
}
The pattern of the controller is worth copying: it validates the input, generates the PDF with the Generator, sends the link with Mail and returns status: error with a translated message when anything fails.
NOTE As read from the source,
send()only writes records to the database. It does not call an SMTP client and it does not setdatetime_scheduled_to_send, which theSendMailscron needs. If your message must leave the system, queue it as described below.
2. Queue a message for delivery with the SendMails cron
To send a message through an SMTP account, create a record of Mail\Models\Mail with an account (id_account), the addresses, the HTML body and datetime_scheduled_to_send. The cron Mail\Crons\SendMails runs every 5 minutes, takes the messages that are not sent yet and whose scheduled time has passed (at most 10 per run), and sends them. EmailMarketing queues the messages of a campaign this way, 10 seconds apart:
apps/EmailMarketing/Controllers/Api/Launch.php (excerpt)
$mailData = [
'subject' => $email->mail_subject,
'body_html' => $bodyHtml,
'id_account' => $email->id_sender_account,
'from' => $email->SENDER_ACCOUNT->sender_email ?? '',
'to' => $recipient->email,
'datetime_created' => date('Y-m-d H:i:s'),
'datetime_scheduled_to_send' => date('Y-m-d H:i:s', strtotime("+{$sec} seconds")),
];
$sec += 10;
$mail = $mMail->record->recordCreate($mailData);
apps/Mail/Crons/SendMails.php (excerpt)
public function run(): void
{
/** @var Mail */
$mMail = $this->getModel(Mail::class);
$maxMailsToSend = $this->maxMailsToSend;
if ($maxMailsToSend > 30) $maxMailsToSend = 30;
$mailsToSendQuery = $mMail->record->prepareReadQuery()
->whereNull('datetime_sent')
->where('datetime_scheduled_to_send', '<=', date('Y-m-d H:i:s'))
;
$mailsScheduledCount = $mailsToSendQuery->count();
$mailsToSend = $mailsToSendQuery
->with('ACCOUNT')
->with('MAILBOX')
->limit($maxMailsToSend) // cron is launched each minute; send max 3 emails per minute
->get()
Do not do this
- Do not write your own
PDFlibrary calls. UseGeneratorand a template, so users can change the layout. - Do not insert rows into
notificationsyourself.Senderkeeps the fields consistent. For e-mails use one of the two ways above. - Do not call
mail()or an SMTP library. TheMailapp handles accounts and the queue.
