www.hubleto.eu Reddit User Guide Stack Overflow LinkedIn Report an issue

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
E-mail 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 returns false and 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. Until Sender accepts an explicit sender, send notifications from event listeners and controllers, and describe the needed change in community-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 set datetime_scheduled_to_send, which the SendMails cron 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