From f1ffdae8f4acd2c2206ad0a8c040cd094d2f260a Mon Sep 17 00:00:00 2001 From: evarisk-kilyan Date: Thu, 22 Oct 2026 10:30:37 +0200 Subject: [PATCH] #1152 [Pocket] feat: a Pocket account per user, and "I just hung up" picks the last recording of the folder Each user can now enter their own Pocket key and folder on a Pocket tab of their user card. The key of the module setup becomes the shared account, used by whoever has no key of their own. The synchronisation walks every configured account, and each mirrored recording remembers the account it comes from, since Pocket only signs its detail and audio for that account. "I just hung up" now only takes recordings filed in the folder of the user's account. When the last one is older than REEDCRM_POCKET_HANGUP_MAX_AGE, nothing is attached and the user is asked to file the call in the folder first. The migration adds llx_reedcrm_pocket_recording.fk_user_pocket and drops the shared key from the test of the synchronisation job. The module has to be activated again. --- admin/pocket.php | 189 +------- ajax/pocket_audio_url.php | 7 +- class/pocketaccount.class.php | 431 ++++++++++++++++++ class/pocketapi.class.php | 11 +- class/pocketcron.class.php | 21 +- class/pocketrecording.class.php | 6 + class/pocketsync.class.php | 112 +++-- core/modules/modReedCRM.class.php | 9 +- core/tpl/reedcrm_pocket_account_setup.tpl.php | 158 +++++++ langs/en_US/reedcrm.lang | 22 +- langs/fr_FR/reedcrm.lang | 22 +- lib/reedcrm_pocketrecording.lib.php | 125 ++++- ...ation_reedcrm_pocket_personal_accounts.sql | 34 ++ .../llx_reedcrm_pocket_recording.key.sql | 1 + sql/pocket/llx_reedcrm_pocket_recording.sql | 1 + view/pocketrecording/pocketrecording_card.php | 8 +- view/pocketrecording/pocketrecording_user.php | 110 +++++ 17 files changed, 1016 insertions(+), 251 deletions(-) create mode 100644 class/pocketaccount.class.php create mode 100644 core/tpl/reedcrm_pocket_account_setup.tpl.php create mode 100644 sql/migration/llx_migration_reedcrm_pocket_personal_accounts.sql create mode 100644 view/pocketrecording/pocketrecording_user.php diff --git a/admin/pocket.php b/admin/pocket.php index dc3ab0aa..19e4fa2e 100644 --- a/admin/pocket.php +++ b/admin/pocket.php @@ -37,8 +37,7 @@ // Load ReedCRM libraries require_once __DIR__ . '/../lib/reedcrm.lib.php'; require_once __DIR__ . '/../lib/reedcrm_pocketrecording.lib.php'; -require_once __DIR__ . '/../class/pocketapi.class.php'; -require_once __DIR__ . '/../class/pocketsync.class.php'; +require_once __DIR__ . '/../class/pocketaccount.class.php'; // Global variables definitions global $conf, $db, $langs, $user; @@ -61,77 +60,10 @@ * Actions */ -if ($action == 'set_pocket_api_key' && $permissiontoread) { - // The key is only rewritten when the field carries something: an untouched masked field must - // never wipe a working key. - $apiKey = trim(GETPOST('pocket_api_key', 'alphanohtml')); - if ($apiKey !== '') { - dolibarr_set_const($db, 'REEDCRM_POCKET_API_KEY', $apiKey, 'chaine', 0, '', $conf->entity); - setEventMessage($langs->trans('SavedConfig')); - } - - header('Location: ' . $_SERVER['PHP_SELF']); - exit; -} - -if ($action == 'del_pocket_api_key' && $permissiontoread) { - dolibarr_del_const($db, 'REEDCRM_POCKET_API_KEY', $conf->entity); - dolibarr_del_const($db, 'REEDCRM_POCKET_FOLDER_ID', $conf->entity); - dolibarr_del_const($db, 'REEDCRM_POCKET_FOLDER_LABEL', $conf->entity); - setEventMessage($langs->trans('SavedConfig')); - - header('Location: ' . $_SERVER['PHP_SELF']); - exit; -} - -if ($action == 'set_pocket_folder' && $permissiontoread) { - $folderId = GETPOST('pocket_folder_id', 'alphanohtml'); - - // The label is denormalised so the recordings list and the sync stay readable even when the - // API is unreachable. - $folderLabel = ''; - if ($folderId !== '') { - $pocketApi = new PocketApi($db); - foreach ((array) $pocketApi->getFolders() as $folder) { - if ($folder['id'] === $folderId) { - $folderLabel = $folder['label']; - break; - } - } - } - - dolibarr_set_const($db, 'REEDCRM_POCKET_FOLDER_ID', $folderId, 'chaine', 0, '', $conf->entity); - dolibarr_set_const($db, 'REEDCRM_POCKET_FOLDER_LABEL', $folderLabel, 'chaine', 0, '', $conf->entity); - setEventMessage($langs->trans('SavedConfig')); - - header('Location: ' . $_SERVER['PHP_SELF']); - exit; -} - -if ($action == 'test_pocket_connection' && $permissiontoread) { - $pocketApi = new PocketApi($db); - $folders = $pocketApi->getFolders(); - - if ($folders === null) { - setEventMessages($langs->trans('PocketConnectionFailed', $pocketApi->error), [], 'errors'); - } else { - setEventMessage($langs->trans('PocketConnectionSucceeded', count($folders))); - } - - header('Location: ' . $_SERVER['PHP_SELF']); - exit; -} - -if ($action == 'sync_pocket_recordings' && $permissiontoread) { - $pocketSync = new PocketSync($db); - $report = $pocketSync->syncRecordings($user); - - if (!empty($pocketSync->error)) { - setEventMessages($pocketSync->error, [], 'errors'); - } else { - setEventMessage($langs->trans('PocketSyncReport', $report['created'], $report['updated'], $report['skipped'], $report['errors'])); - } +// The shared account: the one of the users who did not set a key of their own +$pocketAccount = PocketAccount::getShared($db); +if ($permissiontoread && reedcrm_pocket_process_account_action($action, $pocketAccount, $user)) { header('Location: ' . $_SERVER['PHP_SELF']); exit; } @@ -188,113 +120,36 @@ $head = reedcrm_admin_prepare_head(); print dol_get_fiche_head($head, 'pocket', $title, -1, 'reedcrm_color@reedcrm'); -$apiKey = getDolGlobalString('REEDCRM_POCKET_API_KEY'); -$folderId = getDolGlobalString('REEDCRM_POCKET_FOLDER_ID'); -$folderLabel = getDolGlobalString('REEDCRM_POCKET_FOLDER_LABEL'); +$pocketFormUrl = $_SERVER['PHP_SELF']; +require_once __DIR__ . '/../core/tpl/reedcrm_pocket_account_setup.tpl.php'; -// Connection -print load_fiche_titre($langs->trans('PocketConnection'), '', ''); +// Personal accounts, set by each user on the Pocket tab of their user card +print load_fiche_titre($langs->trans('PocketPersonalAccounts'), '', ''); print ''; print ''; -print ''; -print ''; +print ''; +print ''; print ''; print ''; -print ''; -print ''; -print ''; -print ''; -print ''; -print ''; -print ''; -print ''; -print ''; - -print ''; -print ''; -print ''; -print ''; } -print ''; -print ''; - -print '
' . $langs->trans('Parameter') . '' . $langs->trans('Value') . '' . $langs->trans('User') . '' . $langs->trans('PocketFolder') . '' . $langs->trans('Action') . '
' . $form->textwithpicto($langs->trans('PocketApiKey'), $langs->trans('PocketApiKeyHelp')) . ''; -// The stored key is never sent back to the browser, only the hint of the one in place. -print ''; -print ''; -print ''; -if ($apiKey !== '') { - print ' ' . $langs->trans('Delete') . ''; -} -print '
' . $langs->trans('PocketConnectionState') . ''; -print $apiKey !== '' ? '' . $langs->trans('PocketApiKeySet') . '' : '' . $langs->trans('PocketApiKeyMissing') . ''; -print ''; -if ($apiKey !== '') { - print '' . $langs->trans('PocketTestConnection') . ''; -} else { - print '' . $langs->trans('PocketTestConnection') . ''; +$personalAccounts = PocketAccount::getAllPersonal($db); +if (empty($personalAccounts)) { + print '
' . $langs->trans('PocketNoPersonalAccount') . '
'; -// Imported folder -print load_fiche_titre($langs->trans('PocketFolderTitle'), '', ''); - -print ''; -print ''; -print ''; -print ''; -print ''; -print ''; - -print ''; -print ''; -print ''; -print ''; -print ''; -print ''; -print ''; -print ''; -print ''; - -print ''; -print ''; -print ''; -print ''; + print ''; + print ''; + print ''; + print ''; } -print ''; -print ''; print '
' . $langs->trans('Parameter') . '' . $langs->trans('Value') . '' . $langs->trans('Action') . '
' . $form->textwithpicto($langs->trans('PocketFolder'), $langs->trans('PocketFolderHelp')) . ''; - -if ($apiKey === '') { - print '' . $langs->trans('PocketApiKeyMissing') . ''; -} else { - $pocketApi = new PocketApi($db); - $folders = $pocketApi->getFolders(); - - if ($folders === null) { - // The API is down or the key is wrong: keep the configured folder visible instead of - // silently offering an empty selector that would clear it on save. - print '' . dol_escape_htmltag($pocketApi->error) . ''; - if ($folderId !== '') { - print '
' . dol_escape_htmltag($folderLabel ?: $folderId) . ''; - } - } else { - $folderOptions = ['' => $langs->trans('PocketNoFolderSelected')]; - foreach ($folders as $folder) { - $folderOptions[$folder['id']] = str_repeat('  ', $folder['depth']) . $folder['label']; - } - print $form->selectarray('pocket_folder_id', $folderOptions, $folderId, 0, 0, 0, '', 1, 0, 0, '', 'minwidth300'); +$accountOwner = new User($db); +foreach ($personalAccounts as $personalAccount) { + if ($accountOwner->fetch($personalAccount->fk_user) <= 0) { + continue; } -} -print '
'; -print ''; -print '
' . $form->textwithpicto($langs->trans('PocketManualSync'), $langs->trans('PocketManualSyncHelp')) . ''; -print $folderId !== '' ? dol_escape_htmltag($folderLabel ?: $folderId) : '' . $langs->trans('PocketFolderMissing') . ''; -print ''; -if ($apiKey !== '' && $folderId !== '') { - print '' . $langs->trans('PocketSyncNow') . ''; -} else { - print '' . $langs->trans('PocketSyncNow') . ''; + print '
' . $accountOwner->getNomUrl(-1) . '' . ($personalAccount->folderId !== '' ? dol_escape_htmltag($personalAccount->folderLabel ?: $personalAccount->folderId) : '' . $langs->trans('PocketFolderMissing') . '') . '' . $langs->trans('Modify') . '
'; diff --git a/ajax/pocket_audio_url.php b/ajax/pocket_audio_url.php index 2d4d714e..573ec6c5 100644 --- a/ajax/pocket_audio_url.php +++ b/ajax/pocket_audio_url.php @@ -40,7 +40,7 @@ die('Include of reedcrm main fails'); } -require_once __DIR__ . '/../class/pocketapi.class.php'; +require_once __DIR__ . '/../class/pocketaccount.class.php'; require_once __DIR__ . '/../class/pocketrecording.class.php'; global $db, $langs, $user; @@ -63,8 +63,9 @@ } // The URL Pocket signs is short lived, so it is resolved when the user actually presses play -// rather than on every card render, and it is never stored. -$pocketApi = new PocketApi($db); +// rather than on every card render, and it is never stored. Only the account holding the +// recording may sign it. +$pocketApi = PocketAccount::getForRecording($db, $recording)->getApi(); $audioUrl = $pocketApi->getAudioUrl($recording->pocket_id); if (empty($audioUrl)) { diff --git a/class/pocketaccount.class.php b/class/pocketaccount.class.php new file mode 100644 index 00000000..d0ba5738 --- /dev/null +++ b/class/pocketaccount.class.php @@ -0,0 +1,431 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file class/pocketaccount.class.php + * \ingroup reedcrm + * \brief Pocket account a recording is read from: the personal one of a user, or the shared one. + */ + +require_once __DIR__ . '/pocketapi.class.php'; + +/** + * Class PocketAccount. + * + * Every salesperson carries their own dictaphone, hence their own Pocket account: the key and the + * imported folder are first looked for on the user (llx_user_param, set from the Pocket tab of the + * user card). The account of the module setup stays as the shared one, used by whoever has not set + * a key of their own, the way a team sharing a single dictaphone works. + * + * The personal settings are kept per entity like the module setup: the folders of a Pocket account + * often split the companies of a group, and each entity imports its own. + */ +class PocketAccount +{ + /** + * @var string Name of the API key, as a constant of the shared account and as a user parameter. + */ + public const PARAM_API_KEY = 'REEDCRM_POCKET_API_KEY'; + + /** + * @var string Name of the imported folder id. + */ + public const PARAM_FOLDER_ID = 'REEDCRM_POCKET_FOLDER_ID'; + + /** + * @var string Name of the imported folder label. + */ + public const PARAM_FOLDER_LABEL = 'REEDCRM_POCKET_FOLDER_LABEL'; + + /** + * @var DoliDB Database handler. + */ + public DoliDB $db; + + /** + * @var int Owner of the account, 0 for the shared account of the module setup. + */ + public int $fk_user = 0; + + /** + * @var string API key, in clear. + */ + public string $apiKey = ''; + + /** + * @var string Imported Pocket folder id. + */ + public string $folderId = ''; + + /** + * @var string Imported Pocket folder label, denormalised so the pages stay readable offline. + */ + public string $folderLabel = ''; + + /** + * Constructor. + * + * @param DoliDB $db Database handler. + * @param int $fkUser Owner of the account, 0 for the shared account. + */ + public function __construct(DoliDB $db, int $fkUser = 0) + { + $this->db = $db; + $this->fk_user = max(0, $fkUser); + } + + /** + * Get the shared account of the module setup. + * + * @param DoliDB $db Database handler. + * @return self Shared account, possibly not configured. + */ + public static function getShared(DoliDB $db): self + { + $account = new self($db); + + // getDolGlobalString() hands the key back decrypted: dolibarr_set_const() encrypts the + // constants named *_KEY + $account->apiKey = getDolGlobalString(self::PARAM_API_KEY); + $account->folderId = getDolGlobalString(self::PARAM_FOLDER_ID); + $account->folderLabel = getDolGlobalString(self::PARAM_FOLDER_LABEL); + + return $account; + } + + /** + * Get the personal account of a user. + * + * @param DoliDB $db Database handler. + * @param int $fkUser User id. + * @return self Personal account, possibly not configured. + */ + public static function getPersonal(DoliDB $db, int $fkUser): self + { + global $conf; + + if (!isset($conf->cache['reedcrmPocketAccounts'][$fkUser])) { + $account = new self($db, $fkUser); + if ($fkUser > 0) { + $params = self::readUserParams($db, $fkUser); + $account->fill($params[$fkUser] ?? []); + } + + $conf->cache['reedcrmPocketAccounts'][$fkUser] = $account; + } + + return $conf->cache['reedcrmPocketAccounts'][$fkUser]; + } + + /** + * Get the account a user works with: their own when they set a key, the shared one otherwise. + * + * A personal key without folder is still the user's account. Falling back to the shared one + * there would attach the calls of someone else to the cards of the user. + * + * @param DoliDB $db Database handler. + * @param User $user User. + * @return self Account of the user. + */ + public static function getForUser(DoliDB $db, User $user): self + { + $personal = self::getPersonal($db, (int) $user->id); + + return $personal->hasApiKey() ? $personal : self::getShared($db); + } + + /** + * Get the account a mirrored recording was imported from. + * + * Pocket only answers the detail and the audio of a recording to the account holding it: the + * owner stored on the recording decides the key, whoever is looking at the card. + * + * @param DoliDB $db Database handler. + * @param PocketRecording $recording Mirrored recording. + * @return self Account of the recording. + */ + public static function getForRecording(DoliDB $db, PocketRecording $recording): self + { + $fkUser = (int) $recording->fk_user_pocket; + + return $fkUser > 0 ? self::getPersonal($db, $fkUser) : self::getShared($db); + } + + /** + * Get every account ready to be synchronised: the shared one and the personal ones. + * + * @param DoliDB $db Database handler. + * @return self[] Accounts holding both a key and a folder. + */ + public static function getAllConfigured(DoliDB $db): array + { + $accounts = []; + + $shared = self::getShared($db); + if ($shared->isConfigured()) { + $accounts[] = $shared; + } + + foreach (self::readUserParams($db) as $fkUser => $params) { + $account = new self($db, $fkUser); + $account->fill($params); + if ($account->isConfigured()) { + $accounts[] = $account; + } + } + + return $accounts; + } + + /** + * Get the users who set a personal key, configured or not. + * + * @param DoliDB $db Database handler. + * @return self[] Personal accounts, by user id. + */ + public static function getAllPersonal(DoliDB $db): array + { + $accounts = []; + + foreach (self::readUserParams($db) as $fkUser => $params) { + $account = new self($db, $fkUser); + $account->fill($params); + if ($account->hasApiKey()) { + $accounts[$fkUser] = $account; + } + } + + return $accounts; + } + + /** + * Tell whether the account is the shared one of the module setup. + * + * @return bool True for the shared account. + */ + public function isShared(): bool + { + return $this->fk_user <= 0; + } + + /** + * Get the name the account is shown with in the messages. + * + * @return string Name of the owner, or the wording of the shared account. + */ + public function getName(): string + { + global $langs; + + if ($this->isShared()) { + return $langs->trans('PocketSharedAccount'); + } + + $owner = new User($this->db); + + return $owner->fetch($this->fk_user) > 0 ? $owner->getFullName($langs) : '#' . $this->fk_user; + } + + /** + * Tell whether an API key is set. + * + * @return bool True when a key is set. + */ + public function hasApiKey(): bool + { + return $this->apiKey !== ''; + } + + /** + * Tell whether the account can be synchronised: key and folder set. + * + * Without folder the synchronisation deliberately does nothing, importing the whole account + * would flood the list. + * + * @return bool True when both the key and the folder are set. + */ + public function isConfigured(): bool + { + return $this->hasApiKey() && $this->folderId !== ''; + } + + /** + * Get an API client authenticated with the key of the account. + * + * @return PocketApi API client. + */ + public function getApi(): PocketApi + { + return new PocketApi($this->db, $this->apiKey); + } + + /** + * Save the API key. + * + * @param string $apiKey Key in clear. + * @return int > 0 if OK, < 0 if KO. + */ + public function setApiKey(string $apiKey): int + { + $this->apiKey = $apiKey; + + if ($this->isShared()) { + return $this->writeConsts([self::PARAM_API_KEY => $apiKey]); + } + + // Encrypted like the constants named *_KEY, which llx_user_param would not do on its own + require_once DOL_DOCUMENT_ROOT . '/core/lib/security.lib.php'; + + return $this->writeUserParams([self::PARAM_API_KEY => $apiKey !== '' ? dolEncrypt($apiKey) : '']); + } + + /** + * Save the imported folder. + * + * @param string $folderId Pocket folder id, empty to clear it. + * @param string $folderLabel Pocket folder label. + * @return int > 0 if OK, < 0 if KO. + */ + public function setFolder(string $folderId, string $folderLabel): int + { + $this->folderId = $folderId; + $this->folderLabel = $folderId !== '' ? $folderLabel : ''; + + $params = [self::PARAM_FOLDER_ID => $this->folderId, self::PARAM_FOLDER_LABEL => $this->folderLabel]; + + return $this->isShared() ? $this->writeConsts($params) : $this->writeUserParams($params); + } + + /** + * Forget the key and the folder of the account. + * + * @return int > 0 if OK, < 0 if KO. + */ + public function delete(): int + { + $this->apiKey = ''; + $this->folderId = ''; + $this->folderLabel = ''; + + $params = [self::PARAM_API_KEY => '', self::PARAM_FOLDER_ID => '', self::PARAM_FOLDER_LABEL => '']; + + return $this->isShared() ? $this->writeConsts($params) : $this->writeUserParams($params); + } + + /** + * Fill the account from the user parameters read in the database. + * + * @param array $params Parameter name => stored value. + * @return void + */ + private function fill(array $params): void + { + $apiKey = (string) ($params[self::PARAM_API_KEY] ?? ''); + if ($apiKey !== '') { + require_once DOL_DOCUMENT_ROOT . '/core/lib/security.lib.php'; + $apiKey = (string) dolDecrypt($apiKey); + } + + $this->apiKey = $apiKey; + $this->folderId = (string) ($params[self::PARAM_FOLDER_ID] ?? ''); + $this->folderLabel = (string) ($params[self::PARAM_FOLDER_LABEL] ?? ''); + } + + /** + * Read the Pocket parameters of the users of the current entity. + * + * Read here rather than from $user->conf: main.inc.php loads the parameters of the logged user + * only, and the synchronisation walks every account. + * + * @param DoliDB $db Database handler. + * @param int $fkUser User to read, 0 for every user. + * @return array> User id => parameter name => stored value. + */ + private static function readUserParams(DoliDB $db, int $fkUser = 0): array + { + global $conf; + + $params = []; + + $sql = 'SELECT up.fk_user, up.param, up.value FROM ' . MAIN_DB_PREFIX . 'user_param as up'; + $sql .= ' INNER JOIN ' . MAIN_DB_PREFIX . 'user as u ON u.rowid = up.fk_user'; + $sql .= ' WHERE up.entity = ' . ((int) $conf->entity); + $sql .= " AND up.param IN ('" . $db->escape(self::PARAM_API_KEY) . "', '" . $db->escape(self::PARAM_FOLDER_ID) . "', '" . $db->escape(self::PARAM_FOLDER_LABEL) . "')"; + if ($fkUser > 0) { + $sql .= ' AND up.fk_user = ' . ((int) $fkUser); + } else { + // A disabled user keeps their parameters, their dictaphone is no longer synchronised + $sql .= ' AND u.statut = 1'; + } + + $resql = $db->query($sql); + if (!$resql) { + return $params; + } + + while ($obj = $db->fetch_object($resql)) { + $params[(int) $obj->fk_user][(string) $obj->param] = (string) $obj->value; + } + $db->free($resql); + + return $params; + } + + /** + * Write constants of the shared account, an empty value removes the constant. + * + * @param array $consts Constant name => value to store. + * @return int > 0 if OK, < 0 if KO. + */ + private function writeConsts(array $consts): int + { + global $conf; + + require_once DOL_DOCUMENT_ROOT . '/core/lib/admin.lib.php'; + + foreach ($consts as $name => $value) { + $result = $value !== '' ? dolibarr_set_const($this->db, $name, $value, 'chaine', 0, '', $conf->entity) : dolibarr_del_const($this->db, $name, $conf->entity); + if ($result < 0) { + return -1; + } + } + + return 1; + } + + /** + * Write parameters of the owner of the account, an empty value removes the parameter. + * + * @param array $params Parameter name => value to store. + * @return int > 0 if OK, < 0 if KO. + */ + private function writeUserParams(array $params): int + { + global $conf; + + require_once DOL_DOCUMENT_ROOT . '/core/lib/functions2.lib.php'; + + $owner = new User($this->db); + $owner->id = $this->fk_user; + + $result = dol_set_user_param($this->db, $conf, $owner, $params, $conf->entity); + + unset($conf->cache['reedcrmPocketAccounts'][$this->fk_user]); + + return $result; + } +} diff --git a/class/pocketapi.class.php b/class/pocketapi.class.php index 9ef8aeae..032cc72d 100644 --- a/class/pocketapi.class.php +++ b/class/pocketapi.class.php @@ -28,7 +28,8 @@ * * Thin read-only client over the Pocket public API. Only the endpoints the module needs are * exposed: folders (to feed the admin selector), recordings and their detail, and the signed - * audio URL. Every call is authenticated with the API key stored in the module configuration. + * audio URL. Every call is authenticated with the API key of the account it is built for, see + * PocketAccount::getApi(). */ class PocketApi { @@ -61,12 +62,14 @@ class PocketApi * Constructor. * * @param DoliDB $db Database handler. - * @param string $apiKey Override of the configured key, used by the admin connection test. + * @param string $apiKey Key of the account, personal or shared. No implicit fallback on the + * shared key: the caller picks the account, a recording is only readable + * with the key of the account holding it. */ - public function __construct(DoliDB $db, string $apiKey = '') + public function __construct(DoliDB $db, string $apiKey) { $this->db = $db; - $this->apiKey = $apiKey !== '' ? $apiKey : getDolGlobalString('REEDCRM_POCKET_API_KEY'); + $this->apiKey = $apiKey; } /** diff --git a/class/pocketcron.class.php b/class/pocketcron.class.php index 394b4cbf..6febe870 100644 --- a/class/pocketcron.class.php +++ b/class/pocketcron.class.php @@ -49,7 +49,7 @@ public function __construct(DoliDB $db) } /** - * Job: mirror the recordings of the configured Pocket folder into ReedCRM. + * Job: mirror the recordings of every configured Pocket account into ReedCRM. * * @return int 0 if OK, < 0 if KO. */ @@ -59,16 +59,21 @@ public function syncPocketRecordings(): int $langs->loadLangs(['reedcrm@reedcrm']); - $pocketSync = new PocketSync($this->db); - $report = $pocketSync->syncRecordings($user); + $outcome = PocketSync::syncAllAccounts($this->db, $user); + $report = $outcome['report']; - if (!empty($pocketSync->error)) { - $this->output = $pocketSync->error; - return -1; + // Nothing configured is not a failure: the job is declared on every install + if ($outcome['accounts'] == 0) { + $this->output = $langs->trans('PocketNoAccountConfigured'); + return 0; } - $this->output = $langs->trans('PocketSyncReport', $report['created'], $report['updated'], $report['skipped'], $report['errors']); + // trans() takes four values at most, the number of accounts gets a sentence of its own + $this->output = $langs->trans('PocketSyncAccounts', $outcome['accounts']) . ' - ' . $langs->trans('PocketSyncReport', $report['created'], $report['updated'], $report['skipped'], $report['errors']); + if (!empty($outcome['errors'])) { + $this->output .= "\n" . implode("\n", $outcome['errors']); + } - return $report['errors'] > 0 ? -1 : 0; + return ($report['errors'] > 0 || !empty($outcome['errors'])) ? -1 : 0; } } diff --git a/class/pocketrecording.class.php b/class/pocketrecording.class.php index 52389436..0062ff8c 100644 --- a/class/pocketrecording.class.php +++ b/class/pocketrecording.class.php @@ -88,6 +88,7 @@ class PocketRecording extends SaturneObject 'fk_soc' => ['type' => 'integer:Societe:societe/class/societe.class.php', 'label' => 'ThirdParty', 'picto' => 'company', 'enabled' => 1, 'position' => 140, 'notnull' => 0, 'visible' => 1, 'index' => 1], 'pocket_id' => ['type' => 'varchar(64)', 'label' => 'PocketRecordingId', 'enabled' => 1, 'position' => 150, 'notnull' => 1, 'visible' => 0, 'noteditable' => 1, 'index' => 1], 'pocket_folder_id' => ['type' => 'varchar(64)', 'label' => 'PocketFolderId', 'enabled' => 1, 'position' => 160, 'notnull' => 0, 'visible' => 0, 'noteditable' => 1], + 'fk_user_pocket' => ['type' => 'integer:User:user/class/user.class.php', 'label' => 'PocketAccount', 'picto' => 'user', 'enabled' => 1, 'position' => 165, 'notnull' => 0, 'visible' => 0, 'noteditable' => 1, 'foreignkey' => 'user.rowid'], 'pocket_state' => ['type' => 'varchar(32)', 'label' => 'PocketState', 'enabled' => 1, 'position' => 170, 'notnull' => 0, 'visible' => 0, 'noteditable' => 1], 'last_sync_date' => ['type' => 'datetime', 'label' => 'PocketLastSyncDate', 'enabled' => 1, 'position' => 180, 'notnull' => 0, 'visible' => -2, 'noteditable' => 1], 'summary' => ['type' => 'html', 'label' => 'PocketSummary', 'enabled' => 1, 'position' => 190, 'notnull' => 0, 'visible' => 0], @@ -135,6 +136,11 @@ class PocketRecording extends SaturneObject */ public $pocket_folder_label; + /** + * @var int|null User whose personal Pocket account the recording comes from, null for the shared account. + */ + public $fk_user_pocket; + /** * @var string|null Pocket processing state (completed, pending, ...). */ diff --git a/class/pocketsync.class.php b/class/pocketsync.class.php index 698c6a0c..ad8dbe3f 100644 --- a/class/pocketsync.class.php +++ b/class/pocketsync.class.php @@ -21,6 +21,7 @@ * \brief Import of the Pocket recordings into the local mirror. */ +require_once __DIR__ . '/pocketaccount.class.php'; require_once __DIR__ . '/pocketapi.class.php'; require_once __DIR__ . '/pocketrecording.class.php'; require_once __DIR__ . '/pocketactionitem.class.php'; @@ -28,7 +29,7 @@ /** * Class PocketSync. * - * Pulls the recordings of the configured Pocket folder and mirrors them into + * Pulls the recordings of the folder of one Pocket account and mirrors them into * llx_reedcrm_pocket_recording. Idempotent: a recording already imported is updated in place, * matched on its Pocket id, and the fields the user owns (status, note, links, thirdparty) are * never overwritten. @@ -40,6 +41,11 @@ class PocketSync */ public DoliDB $db; + /** + * @var PocketAccount Account the recordings are read from. + */ + public PocketAccount $account; + /** * @var PocketApi API client. */ @@ -53,16 +59,50 @@ class PocketSync /** * Constructor. * - * @param DoliDB $db Database handler. + * @param DoliDB $db Database handler. + * @param PocketAccount $account Account the recordings are read from. */ - public function __construct(DoliDB $db) + public function __construct(DoliDB $db, PocketAccount $account) { - $this->db = $db; - $this->api = new PocketApi($db); + $this->db = $db; + $this->account = $account; + $this->api = $account->getApi(); + } + + /** + * Import the recordings of every configured account, the shared one and the personal ones. + * + * An account in error does not stop the others: the dictaphone of one salesperson being out of + * reach is no reason to leave the calls of the whole team without transcript. + * + * @param DoliDB $db Database handler. + * @param User $user User the created records are attributed to. + * @return array{report:array{created:int,updated:int,skipped:int,errors:int},errors:string[],accounts:int} Summed report, error of each failing account, and number of accounts walked. + */ + public static function syncAllAccounts(DoliDB $db, User $user): array + { + $outcome = ['report' => ['created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0], 'errors' => [], 'accounts' => 0]; + + foreach (PocketAccount::getAllConfigured($db) as $account) { + $pocketSync = new self($db, $account); + $report = $pocketSync->syncRecordings($user); + + foreach ($report as $counter => $value) { + $outcome['report'][$counter] += $value; + } + + if (!empty($pocketSync->error)) { + $outcome['errors'][] = $account->getName() . ' : ' . $pocketSync->error; + } + + $outcome['accounts']++; + } + + return $outcome; } /** - * Import the recordings of the configured folder. + * Import the recordings of the folder of the account. * * @param User $user User the created records are attributed to. * @param int $maxPages Safety bound on the number of API pages walked in one run. @@ -81,13 +121,13 @@ public function syncRecordings(User $user, int $maxPages = 20): array // An empty folder means "not configured yet": importing the whole account instead would // flood the list, so the sync deliberately does nothing until a folder is chosen. - $folderId = getDolGlobalString('REEDCRM_POCKET_FOLDER_ID'); + $folderId = $this->account->folderId; if (empty($folderId)) { $this->error = $langs->trans('PocketFolderMissing'); return $report; } - $folderLabel = getDolGlobalString('REEDCRM_POCKET_FOLDER_LABEL'); + $folderLabel = $this->account->folderLabel; $page = 1; $hasMore = false; @@ -107,9 +147,9 @@ public function syncRecordings(User $user, int $maxPages = 20): array // The folder filter is documented but currently not honoured by the API, which // answers the whole account instead of failing on it. The page is therefore // filtered again here, otherwise a silently unfiltered answer imports everything. - // A recording already mirrored keeps being refreshed whatever folder it sits in: - // the hang up button imports recordings before Pocket files them, and dropping them - // here would freeze them without transcript nor summary. + // A recording already mirrored keeps being refreshed whatever folder it sits in: one + // moved out of the folder after its import, or attached unfiled by the hang up button + // before it was restricted to the folder, would otherwise freeze without transcript. $recordingFolderId = (string) ($recording['folder_id'] ?? ''); if ($recordingFolderId !== $folderId && empty($mirroredPocketIds[(string) ($recording['id'] ?? '')])) { $report['skipped']++; @@ -117,7 +157,7 @@ public function syncRecordings(User $user, int $maxPages = 20): array } // Each recording is stamped with the folder it really sits in, which is not always - // the configured one now that unfiled recordings can already be mirrored. + // the configured one for those refreshed above. $result = $this->importRecording($recording, $user, $recordingFolderId, $recordingFolderId === $folderId ? $folderLabel : ''); if ($result < 0) { $report['errors']++; @@ -136,13 +176,13 @@ public function syncRecordings(User $user, int $maxPages = 20): array } /** - * Get the most recent recording available in Pocket. + * Get the most recent recording of the folder of the account. * * Answers the "I just hung up" button: the conversation that just ended is the last thing the * dictaphone recorded. The folder filter of the API is not honoured (see syncRecordings), so the - * pages are filtered here, and more loosely than the synchronisation does: a recording made - * seconds ago is usually not filed in a folder yet, so an unfiled one is accepted too. A - * recording sitting in another folder belongs to another scope and is never picked. + * pages are filtered here, as strictly as the synchronisation does. An unfiled recording is not + * picked either: the folders of an account split the companies of a group, and until it is + * filed nothing tells which one the call belongs to. * * @param int $maxPages Safety bound on the number of API pages walked when nothing matches. * @return array|null Recording as returned by the list endpoint, null when none. @@ -158,7 +198,12 @@ public function findLastRecording(int $maxPages = 3): ?array return null; } - $folderId = getDolGlobalString('REEDCRM_POCKET_FOLDER_ID'); + $folderId = $this->account->folderId; + if (empty($folderId)) { + $this->error = $langs->trans('PocketFolderMissing'); + return null; + } + $last = null; $lastDate = 0; $page = 1; @@ -175,8 +220,7 @@ public function findLastRecording(int $maxPages = 3): ?array continue; } - $recordingFolder = (string) ($recording['folder_id'] ?? ''); - if (!empty($folderId) && !empty($recordingFolder) && $recordingFolder !== $folderId) { + if ((string) ($recording['folder_id'] ?? '') !== $folderId) { continue; } @@ -197,32 +241,37 @@ public function findLastRecording(int $maxPages = 3): ?array } /** - * Import the most recent Pocket recording into the local mirror. + * Import the most recent recording of the folder into the local mirror. * - * @param User $user User the created record is attributed to. + * @param User $user User the created record is attributed to. + * @param int $maxAge Age in seconds past which the last recording is refused, 0 to accept any. * @return PocketRecording|null Recording as stored in Dolibarr, null on error, $this->error is then set. */ - public function importLastRecording(User $user): ?PocketRecording + public function importLastRecording(User $user, int $maxAge = 0): ?PocketRecording { global $langs; + $folderName = dol_escape_htmltag($this->account->folderLabel ?: $this->account->folderId); + $recording = $this->findLastRecording(); if ($recording === null) { if (empty($this->error)) { - $this->error = $langs->trans('PocketHangupNoRecording'); + $this->error = $langs->trans('PocketHangupNoRecordingInFolder', $folderName); } return null; } - // The recording keeps the folder it actually sits in: stamping a still unfiled recording - // with the configured folder would claim a filing Pocket has not done. - $folderId = (string) ($recording['folder_id'] ?? ''); - $folderLabel = ($folderId !== '' && $folderId === getDolGlobalString('REEDCRM_POCKET_FOLDER_ID')) - ? getDolGlobalString('REEDCRM_POCKET_FOLDER_LABEL') - : ''; + // The call that just ended is not filed yet when the last recording of the folder is that + // old: attaching it would put an earlier conversation on the card, so nothing is attached + // and the user is told to file the call first. + $recordingDate = $this->getRecordingTimestamp($recording); + if ($maxAge > 0 && $recordingDate > 0 && (dol_now() - $recordingDate) > $maxAge) { + $this->error = $langs->trans('PocketHangupNotFiled', $folderName, dol_print_date($recordingDate, 'dayhour')); + return null; + } - if ($this->importRecording($recording, $user, $folderId, $folderLabel) < 0) { + if ($this->importRecording($recording, $user, $this->account->folderId, $this->account->folderLabel) < 0) { $this->error = $langs->trans('PocketHangupImportFailed'); return null; } @@ -313,8 +362,11 @@ public function importRecording(array $recording, User $user, string $folderId, $isNew = $pocketRecording->fetchByPocketId((string) $recording['id']) <= 0; $previousSync = $isNew ? 0 : (int) $pocketRecording->last_sync_date; + // The owner is only set on creation: an account configured twice, as the shared one and as + // someone's own, sees the same recordings, and the refresh must not hand them over if ($isNew) { $pocketRecording->pocket_id = (string) $recording['id']; + $pocketRecording->fk_user_pocket = $this->account->fk_user > 0 ? $this->account->fk_user : null; $pocketRecording->status = PocketRecording::STATUS_NEW; $pocketRecording->recording_date = !empty($recording['recording_at']) ? dol_stringtotime($recording['recording_at']) : dol_now(); } diff --git a/core/modules/modReedCRM.class.php b/core/modules/modReedCRM.class.php index 47ff4811..2c5cdf24 100644 --- a/core/modules/modReedCRM.class.php +++ b/core/modules/modReedCRM.class.php @@ -258,8 +258,9 @@ public function __construct($db) $i++ => ['REEDCRM_CALL_LIST_GENERATE_DOCUMENTS_ADDON', 'chaine', 'pdf_calllist_standard', '', 0, 'current'], // CONST POCKET - // The API key and the imported folder are deliberately left empty: nothing is fetched - // from Pocket until an admin fills them in admin/pocket.php + // The API key and the imported folder of the shared account are deliberately left empty: + // nothing is fetched from it until an admin fills them in admin/pocket.php. Each user may + // also set an account of their own on the Pocket tab of their user card. $i++ => ['REEDCRM_POCKET_API_KEY', 'chaine', '', '', 0, 'current'], $i++ => ['REEDCRM_POCKET_FOLDER_ID', 'chaine', '', '', 0, 'current'], $i++ => ['REEDCRM_POCKET_FOLDER_LABEL', 'chaine', '', '', 0, 'current'], @@ -306,6 +307,7 @@ public function __construct($db) $this->tabs[] = ['data' => 'project' . ':+event:' . $pictoReedcrm . $langs->transnoentities('CardPro') . ':reedcrm@reedcrm:1:/reedcrm/view/procard.php?from_id=__ID__&from_type=project']; $this->tabs[] = ['data' => 'thirdparty' . ':+event:' . $pictoReedcrm . $langs->transnoentities('CardPro') . ':reedcrm@reedcrm:1:/reedcrm/view/procard.php?from_id=__ID__&from_type=societe']; $this->tabs[] = ['data' => 'thirdparty:+keyyo:' . $pictoReedcrm . $langs->transnoentities('KeyyoCalls') . ':reedcrm@reedcrm:$user->hasRight(\'societe\', \'lire\'):/reedcrm/view/thirdparty_calls.php?id=__ID__']; + $this->tabs[] = ['data' => 'user:+pocket:' . $pictoReedcrm . $langs->transnoentities('Pocket') . ':reedcrm@reedcrm:$user->hasRight(\'reedcrm\', \'pocketrecording\', \'write\'):/reedcrm/view/pocketrecording/pocketrecording_user.php?id=__ID__']; // Pocket recording tabs, driven by the REEDCRM_POCKET_LINK_* constants set in admin/pocket.php. // This loop belongs to the constructor: saturne_refresh_module_registrations() instantiates the @@ -540,7 +542,8 @@ public function __construct($db) 'unitfrequency' => 86400, 'datenextrun' => dol_mktime(20, 0, 0, (int) date('m'), (int) date('d'), (int) date('Y'), 'tzserver'), 'status' => 0, - 'test' => 'isModEnabled(\'saturne\') && isModEnabled(\'reedcrm\') && getDolGlobalString(\'REEDCRM_POCKET_API_KEY\') != \'\'', + // No key in the test: the accounts may all be personal ones, the job reports what it walked + 'test' => 'isModEnabled(\'saturne\') && isModEnabled(\'reedcrm\')', 'priority' => 57 ], 10 => [ diff --git a/core/tpl/reedcrm_pocket_account_setup.tpl.php b/core/tpl/reedcrm_pocket_account_setup.tpl.php new file mode 100644 index 00000000..2523aa1e --- /dev/null +++ b/core/tpl/reedcrm_pocket_account_setup.tpl.php @@ -0,0 +1,158 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file core/tpl/reedcrm_pocket_account_setup.tpl.php + * \ingroup reedcrm + * \brief Connection and imported folder of a Pocket account, shared by the setup page and the + * Pocket tab of the user card. The actions are run by reedcrm_pocket_process_account_action(). + * + * Expects: + * - $pocketAccount (PocketAccount) Account shown, shared or personal. + * - $pocketFormUrl (string) Page the forms and links are sent to, its own parameters included. + */ + +// Protection to avoid direct call of template +if (empty($conf) || !is_object($conf)) { + print 'Error, template page can be called as an URL'; + exit; +} + +global $form, $langs; + +$apiKey = $pocketAccount->apiKey; +$folderId = $pocketAccount->folderId; +$folderLabel = $pocketAccount->folderLabel; + +// The forms post to the page itself, the parameters of its URL travel as hidden fields +$pocketFormAction = strtok($pocketFormUrl, '?'); +$pocketFormParams = []; +parse_str((string) parse_url($pocketFormUrl, PHP_URL_QUERY), $pocketFormParams); +$pocketLinkUrl = $pocketFormUrl . (strpos($pocketFormUrl, '?') === false ? '?' : '&'); + +$pocketHiddenFields = ''; +foreach ($pocketFormParams as $paramName => $paramValue) { + $pocketHiddenFields .= ''; +} + +// Connection +$accountTitle = $pocketAccount->isShared() ? $langs->trans('PocketSharedAccount') : $langs->trans('PocketPersonalAccount'); +$accountHelp = $pocketAccount->isShared() ? $langs->trans('PocketSharedAccountHelp') : $langs->trans('PocketPersonalAccountHelp'); +print load_fiche_titre($form->textwithpicto($accountTitle, $accountHelp), '', ''); + +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +print ''; +print $pocketHiddenFields; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; +print ''; + +print '
' . $langs->trans('Parameter') . '' . $langs->trans('Value') . '' . $langs->trans('Action') . '
' . $form->textwithpicto($langs->trans('PocketApiKey'), $langs->trans('PocketApiKeyHelp')) . ''; +// The stored key is never sent back to the browser, only the hint of the one in place. +print ''; +print ''; +print ''; +if ($apiKey !== '') { + print ' ' . $langs->trans('Delete') . ''; +} +print '
' . $langs->trans('PocketConnectionState') . ''; +print $apiKey !== '' ? '' . $langs->trans('PocketApiKeySet') . '' : '' . $langs->trans('PocketApiKeyMissing') . ''; +print ''; +if ($apiKey !== '') { + print '' . $langs->trans('PocketTestConnection') . ''; +} else { + print '' . $langs->trans('PocketTestConnection') . ''; +} +print '
'; + +// Imported folder +print load_fiche_titre($langs->trans('PocketFolderTitle'), '', ''); + +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +print ''; +print $pocketHiddenFields; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; +print ''; + +print ''; +print ''; +print ''; +print ''; +print ''; + +print '
' . $langs->trans('Parameter') . '' . $langs->trans('Value') . '' . $langs->trans('Action') . '
' . $form->textwithpicto($langs->trans('PocketFolder'), $langs->trans('PocketFolderHelp')) . ''; + +if ($apiKey === '') { + print '' . $langs->trans('PocketApiKeyMissing') . ''; +} else { + $pocketApi = $pocketAccount->getApi(); + $folders = $pocketApi->getFolders(); + + if ($folders === null) { + // The API is down or the key is wrong: keep the configured folder visible instead of + // silently offering an empty selector that would clear it on save. + print '' . dol_escape_htmltag($pocketApi->error) . ''; + if ($folderId !== '') { + print '
' . dol_escape_htmltag($folderLabel ?: $folderId) . ''; + } + } else { + $folderOptions = ['' => $langs->trans('PocketNoFolderSelected')]; + foreach ($folders as $folder) { + $folderOptions[$folder['id']] = str_repeat('  ', $folder['depth']) . $folder['label']; + } + print $form->selectarray('pocket_folder_id', $folderOptions, $folderId, 0, 0, 0, '', 1, 0, 0, '', 'minwidth300'); + } +} + +print '
'; +print ''; +print '
' . $form->textwithpicto($langs->trans('PocketManualSync'), $langs->trans('PocketManualSyncHelp')) . ''; +print $folderId !== '' ? dol_escape_htmltag($folderLabel ?: $folderId) : '' . $langs->trans('PocketFolderMissing') . ''; +print ''; +if ($pocketAccount->isConfigured()) { + print '' . $langs->trans('PocketSyncNow') . ''; +} else { + print '' . $langs->trans('PocketSyncNow') . ''; +} +print '
'; diff --git a/langs/en_US/reedcrm.lang b/langs/en_US/reedcrm.lang index 5e266e60..cef46743 100644 --- a/langs/en_US/reedcrm.lang +++ b/langs/en_US/reedcrm.lang @@ -860,7 +860,7 @@ PocketRecordingDate = Recording date PocketFolder = Pocket folder PocketFolderId = Pocket folder identifier PocketFolderTitle = Imported folder -PocketFolderHelp = Only the recordings of this folder are imported into Dolibarr. As long as no folder is selected, no synchronisation happens. +PocketFolderHelp = Only the recordings of this folder are imported into Dolibarr, and "I just hung up" picks the last one filed in it. As long as no folder is selected, no synchronisation happens. PocketFolderMissing = No Pocket folder selected PocketNoFolderSelected = -- No folder -- PocketTags = Pocket tags @@ -879,7 +879,16 @@ PocketSummaryEditedHint = Summary edited by hand: Pocket will not write it again PocketRecordingStatusNew = New PocketRecordingStatusProcessed = Processed PocketRecordingStatusArchived = Archived -PocketConnection = Pocket connection +PocketAccount = Pocket account +PocketSharedAccount = Shared Pocket account +PocketSharedAccountHelp = Used by the users who did not enter a key of their own on the Pocket tab of their user card. +PocketPersonalAccount = Personal Pocket account +PocketPersonalAccountHelp = Key and folder of this user only. They replace the shared account of the module setup for their "I just hung up" button, and the synchronisation imports this folder too. +PocketPersonalAccounts = Personal Pocket accounts +PocketNoPersonalAccount = No user entered a Pocket key of their own +PocketUsesSharedAccount = No personal key: this user works with the shared Pocket account of the module setup (folder %s). +PocketNoSharedAccount = Neither a personal key nor a shared Pocket account: the "I just hung up" button is not offered to this user. +PocketNoAccountConfigured = No Pocket account configured (key and folder): nothing to synchronise PocketConnectionState = Connection state PocketApiKey = Pocket API key PocketApiKeyHelp = A pk_... key created from the Pocket application. It grants read access to the recordings, transcripts and audio files of the account. @@ -894,8 +903,9 @@ PocketManualSync = Manual synchronisation PocketManualSyncHelp = Imports the recordings of the configured folder right away. The scheduled job runs the very same operation. PocketSyncNow = Synchronise now PocketSyncReport = Pocket synchronisation: %s created, %s updated, %s skipped, %s failed +PocketSyncAccounts = Pocket accounts synchronised: %s PocketSyncCronLabel = Pocket recordings synchronisation -PocketSyncCronComment = Imports the recordings of the configured Pocket folder, along with their summary, transcript and action items +PocketSyncCronComment = Imports the recordings of the folder of every configured Pocket account, shared or personal, along with their summary, transcript and action items PocketRefreshFromApi = Refresh from Pocket PocketRefreshDone = Recording refreshed from Pocket PocketRefreshFailed = Refreshing from Pocket failed @@ -1438,11 +1448,11 @@ SepaMandateIssueNoDefault = %s pending direct debit request(s), but no bank acco # PocketHangup = I just hung up PocketHangupWith = I just hung up with %s -PocketHangupHelp = Import the last Pocket recording and attach it to this record +PocketHangupHelp = Import the last recording of the Pocket folder %s and attach it to this record PocketHangupAttached = Last Pocket recording attached: %s PocketHangupAlreadyLinked = The last Pocket recording is already attached to this record: %s -PocketHangupOldRecording = Beware, the last Pocket recording is dated %s: check that Pocket has uploaded the conversation that just ended -PocketHangupNoRecording = No recording available in Pocket +PocketHangupNoRecordingInFolder = No recording in the Pocket folder %s: file the call in this folder from the Pocket application, then try again +PocketHangupNotFiled = The last recording of the Pocket folder %s is dated %s: file the call that just ended in this folder from the Pocket application, then try again PocketHangupImportFailed = The import of the last Pocket recording failed # diff --git a/langs/fr_FR/reedcrm.lang b/langs/fr_FR/reedcrm.lang index ac86b491..67894b0b 100644 --- a/langs/fr_FR/reedcrm.lang +++ b/langs/fr_FR/reedcrm.lang @@ -1004,7 +1004,7 @@ PocketRecordingDate = Date d'enregistrement PocketFolder = Dossier Pocket PocketFolderId = Identifiant du dossier Pocket PocketFolderTitle = Dossier importé -PocketFolderHelp = Seuls les enregistrements de ce dossier sont importés dans Dolibarr. Tant qu'aucun dossier n'est choisi, aucune synchronisation n'a lieu. +PocketFolderHelp = Seuls les enregistrements de ce dossier sont importés dans Dolibarr, et « Je viens de raccrocher » prend le dernier qui y est rangé. Tant qu'aucun dossier n'est choisi, aucune synchronisation n'a lieu. PocketFolderMissing = Aucun dossier Pocket sélectionné PocketNoFolderSelected = -- Aucun dossier -- PocketTags = Tags Pocket @@ -1023,7 +1023,16 @@ PocketSummaryEditedHint = Résumé modifié manuellement : Pocket ne le réécri PocketRecordingStatusNew = Nouveau PocketRecordingStatusProcessed = Traité PocketRecordingStatusArchived = Archivé -PocketConnection = Connexion à Pocket +PocketAccount = Compte Pocket +PocketSharedAccount = Compte Pocket partagé +PocketSharedAccountHelp = Utilisé par les utilisateurs qui n'ont pas saisi leur propre clé dans l'onglet Pocket de leur fiche utilisateur. +PocketPersonalAccount = Compte Pocket personnel +PocketPersonalAccountHelp = Clé et dossier propres à cet utilisateur. Ils remplacent le compte partagé de la configuration du module pour son bouton « Je viens de raccrocher », et la synchronisation importe aussi ce dossier. +PocketPersonalAccounts = Comptes Pocket personnels +PocketNoPersonalAccount = Aucun utilisateur n'a saisi sa propre clé Pocket +PocketUsesSharedAccount = Aucune clé personnelle : cet utilisateur utilise le compte Pocket partagé de la configuration du module (dossier %s). +PocketNoSharedAccount = Ni clé personnelle ni compte Pocket partagé : le bouton « Je viens de raccrocher » n'est pas proposé à cet utilisateur. +PocketNoAccountConfigured = Aucun compte Pocket configuré (clé et dossier) : rien à synchroniser PocketConnectionState = État de la connexion PocketApiKey = Clé API Pocket PocketApiKeyHelp = Clé de type pk_... créée depuis l'application Pocket. Elle donne accès en lecture aux enregistrements, transcriptions et audios du compte. @@ -1038,8 +1047,9 @@ PocketManualSync = Synchronisation manuelle PocketManualSyncHelp = Importe immédiatement les enregistrements du dossier configuré. La même opération est jouée automatiquement par la tâche planifiée. PocketSyncNow = Synchroniser maintenant PocketSyncReport = Synchronisation Pocket : %s créés, %s mis à jour, %s ignorés, %s en erreur +PocketSyncAccounts = Comptes Pocket synchronisés : %s PocketSyncCronLabel = Synchronisation des enregistrements Pocket -PocketSyncCronComment = Importe les enregistrements du dossier Pocket configuré, avec leur résumé, leur transcription et leurs actions +PocketSyncCronComment = Importe les enregistrements du dossier de chaque compte Pocket configuré, partagé ou personnel, avec leur résumé, leur transcription et leurs actions PocketRefreshFromApi = Rafraîchir depuis Pocket PocketRefreshDone = Enregistrement rafraîchi depuis Pocket PocketRefreshFailed = Le rafraîchissement depuis Pocket a échoué @@ -1558,11 +1568,11 @@ SepaMandateIssueNoDefault = %s demande(s) de prélèvement en attente, mais aucu # PocketHangup = Je viens de raccrocher PocketHangupWith = Je viens de raccrocher avec %s -PocketHangupHelp = Importe le dernier enregistrement Pocket et le rattache à cet élément +PocketHangupHelp = Importe le dernier enregistrement du dossier Pocket %s et le rattache à cet élément PocketHangupAttached = Dernier enregistrement Pocket rattaché : %s PocketHangupAlreadyLinked = Le dernier enregistrement Pocket est déjà rattaché à cet élément : %s -PocketHangupOldRecording = Attention, le dernier enregistrement Pocket date du %s : vérifiez que Pocket a bien envoyé la conversation qui vient de se terminer -PocketHangupNoRecording = Aucun enregistrement disponible dans Pocket +PocketHangupNoRecordingInFolder = Aucun enregistrement dans le dossier Pocket %s : rangez l'appel dans ce dossier depuis l'application Pocket, puis réessayez +PocketHangupNotFiled = Le dernier enregistrement du dossier Pocket %s date du %s : rangez l'appel qui vient de se terminer dans ce dossier depuis l'application Pocket, puis réessayez PocketHangupImportFailed = L'import du dernier enregistrement Pocket a échoué # diff --git a/lib/reedcrm_pocketrecording.lib.php b/lib/reedcrm_pocketrecording.lib.php index e0cc34d1..d9a73f7b 100644 --- a/lib/reedcrm_pocketrecording.lib.php +++ b/lib/reedcrm_pocketrecording.lib.php @@ -447,19 +447,24 @@ function reedcrm_pocket_url_without_action(): string /** * Tell whether the hang up button may be offered on a card. * - * Pocket counts as configured once both the API key and the imported folder are set, as on the setup - * page. A key alone is not enough: without folder the synchronisation does nothing, so a recording - * attached seconds after the call would never receive its transcript, and the last recording would - * be picked from the whole account instead of the configured scope. + * The account checked is the one of the logged user: their own when they set a key on the Pocket + * tab of their user card, the shared one of the setup page otherwise. It counts as configured once + * both the API key and the imported folder are set. A key alone is not enough: the button only + * picks recordings filed in that folder. * - * @return bool True when Pocket is configured and the user may write recordings. + * @return bool True when the Pocket account of the user is configured and they may write recordings. */ function reedcrm_pocket_can_hangup(): bool { - global $user; + global $db, $user; - return getDolGlobalString('REEDCRM_POCKET_API_KEY') !== '' && getDolGlobalString('REEDCRM_POCKET_FOLDER_ID') !== '' - && $user->hasRight('reedcrm', 'pocketrecording', 'write'); + if (!$user->hasRight('reedcrm', 'pocketrecording', 'write')) { + return false; + } + + require_once __DIR__ . '/../class/pocketaccount.class.php'; + + return PocketAccount::getForUser($db, $user)->isConfigured(); } /** @@ -475,7 +480,7 @@ function reedcrm_pocket_can_hangup(): bool */ function reedcrm_pocket_hangup_button(CommonObject $object, string $baseUrl = ''): string { - global $langs; + global $db, $langs, $user; if ($object->id <= 0 || !reedcrm_pocket_can_hangup()) { return ''; @@ -484,9 +489,11 @@ function reedcrm_pocket_hangup_button(CommonObject $object, string $baseUrl = '' $langs->load('reedcrm@reedcrm'); // The name of the correspondent lives in the tooltip: printed, it would make the chip as wide - // as the title it sits next to. - $targetName = reedcrm_pocket_get_hangup_target_name($object); - $tooltip = !empty($targetName) ? $langs->trans('PocketHangupWith', $targetName) . ' - ' . $langs->trans('PocketHangupHelp') : $langs->trans('PocketHangupHelp'); + // as the title it sits next to. The folder is named too, the call has to be filed there first. + $pocketAccount = PocketAccount::getForUser($db, $user); + $hangupHelp = $langs->trans('PocketHangupHelp', $pocketAccount->folderLabel ?: $pocketAccount->folderId); + $targetName = reedcrm_pocket_get_hangup_target_name($object); + $tooltip = !empty($targetName) ? $langs->trans('PocketHangupWith', $targetName) . ' - ' . $hangupHelp : $hangupHelp; $url = !empty($baseUrl) ? $baseUrl : reedcrm_pocket_url_without_action(); $url .= (strpos($url, '?') === false ? '?' : '&') . 'action=add_pocket_hangup&token=' . newToken(); @@ -527,10 +534,12 @@ function reedcrm_pocket_insert_hangup_button(string $moreHtmlRef, CommonObject $ } /** - * Attach the last Pocket recording to a business object. + * Attach the last Pocket recording of the folder to a business object. * * The recording is imported first: the conversation that just ended is not in Dolibarr yet, and - * waiting for the next synchronisation would defeat the whole gesture. + * waiting for the next synchronisation would defeat the whole gesture. It is read from the account + * of the user, and refused when it is older than REEDCRM_POCKET_HANGUP_MAX_AGE minutes: the call + * that just ended is then not filed in the folder yet, and an earlier one would be attached. * * @param CommonObject $object Object the recording is attached to. * @param string $linkName Element link name of the object, ex. 'ticket'. @@ -550,8 +559,8 @@ function reedcrm_pocket_attach_last_recording(CommonObject $object, string $link require_once __DIR__ . '/../class/pocketsync.class.php'; - $pocketSync = new PocketSync($db); - $recording = $pocketSync->importLastRecording($user); + $pocketSync = new PocketSync($db, PocketAccount::getForUser($db, $user)); + $recording = $pocketSync->importLastRecording($user, getDolGlobalInt('REEDCRM_POCKET_HANGUP_MAX_AGE', 240) * 60); if ($recording === null) { $outcome['error'] = $pocketSync->error; return $outcome; @@ -618,13 +627,87 @@ function reedcrm_pocket_process_hangup(CommonObject $object, string $linkName, U } setEventMessage($langs->trans('PocketHangupAttached', $description)); +} + +/** + * Run an action of the Pocket account setup and report its outcome as page messages. + * + * Shared by the setup page, for the shared account, and by the Pocket tab of the user card, for a + * personal one: the forms are the same, only the account they write to differs. + * + * @param string $action Action posted by the form. + * @param PocketAccount $account Account the action applies to. + * @param User $user User performing the action. + * @return bool True when the action was an account one, the caller then redirects. + */ +function reedcrm_pocket_process_account_action(string $action, PocketAccount $account, User $user): bool +{ + global $db, $langs; - // A recording much older than the call that just ended usually means Pocket has not uploaded the - // new one yet. The link is kept, the user is told which conversation they just attached. - $maxAge = getDolGlobalInt('REEDCRM_POCKET_HANGUP_MAX_AGE', 240) * 60; - if ($maxAge > 0 && !empty($recording->recording_date) && (dol_now() - $recording->recording_date) > $maxAge) { - setEventMessages($langs->trans('PocketHangupOldRecording', dol_print_date($recording->recording_date, 'dayhour')), [], 'warnings'); + switch ($action) { + case 'set_pocket_api_key': + // The key is only rewritten when the field carries something: an untouched masked field + // must never wipe a working key. + $apiKey = trim(GETPOST('pocket_api_key', 'alphanohtml')); + if ($apiKey !== '') { + if ($account->setApiKey($apiKey) > 0) { + setEventMessage($langs->trans('SavedConfig')); + } else { + setEventMessages($langs->trans('Error'), [], 'errors'); + } + } + return true; + + case 'del_pocket_api_key': + $account->delete(); + setEventMessage($langs->trans('SavedConfig')); + return true; + + case 'set_pocket_folder': + $folderId = GETPOST('pocket_folder_id', 'alphanohtml'); + + // The label is denormalised so the recordings list and the sync stay readable even when + // the API is unreachable. + $folderLabel = ''; + if ($folderId !== '') { + foreach ((array) $account->getApi()->getFolders() as $folder) { + if ($folder['id'] === $folderId) { + $folderLabel = $folder['label']; + break; + } + } + } + + $account->setFolder($folderId, $folderLabel); + setEventMessage($langs->trans('SavedConfig')); + return true; + + case 'test_pocket_connection': + $pocketApi = $account->getApi(); + $folders = $pocketApi->getFolders(); + + if ($folders === null) { + setEventMessages($langs->trans('PocketConnectionFailed', $pocketApi->error), [], 'errors'); + } else { + setEventMessage($langs->trans('PocketConnectionSucceeded', count($folders))); + } + return true; + + case 'sync_pocket_recordings': + require_once __DIR__ . '/../class/pocketsync.class.php'; + + $pocketSync = new PocketSync($db, $account); + $report = $pocketSync->syncRecordings($user); + + if (!empty($pocketSync->error)) { + setEventMessages($pocketSync->error, [], 'errors'); + } else { + setEventMessage($langs->trans('PocketSyncReport', $report['created'], $report['updated'], $report['skipped'], $report['errors'])); + } + return true; } + + return false; } /** diff --git a/sql/migration/llx_migration_reedcrm_pocket_personal_accounts.sql b/sql/migration/llx_migration_reedcrm_pocket_personal_accounts.sql new file mode 100644 index 00000000..9c7d3dfc --- /dev/null +++ b/sql/migration/llx_migration_reedcrm_pocket_personal_accounts.sql @@ -0,0 +1,34 @@ +-- Copyright (C) 2026 EVARISK +-- +-- This program is free software: you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License +-- along with this program. If not, see https://www.gnu.org/licenses/. + +-- Personal Pocket accounts: each user may set their own key and folder on the Pocket tab of their +-- user card, the key of the module setup becoming the shared account of the others. +-- +-- llx_reedcrm_pocket_recording.fk_user_pocket remembers the account a recording was imported +-- from: Pocket only answers its detail and its audio to that account. NULL is the shared account, +-- which is where every recording imported so far comes from. The column is declared in the +-- $fields of PocketRecording, so an installation without it answers 'Record not found' on every +-- card until the module is activated again. +-- +-- The synchronisation job used to be skipped by the cron manager when the shared key was empty, +-- its test is frozen in llx_cronjob at activation. A team where everyone set their own key leaves +-- the shared one empty, so the test drops the key and the job reports the accounts it walked. +-- +-- Re-running the module activation is harmless: run_sql accepts DB_ERROR_COLUMN_ALREADY_EXISTS, +-- DB_ERROR_NOSUCHTABLE on a fresh install where sql/migration/ is loaded before sql/pocket/, and +-- the update only rewrites the test to the value the descriptor declares. + +ALTER TABLE llx_reedcrm_pocket_recording ADD COLUMN fk_user_pocket integer AFTER pocket_folder_label; +UPDATE llx_cronjob SET test = 'isModEnabled(''saturne'') && isModEnabled(''reedcrm'')' WHERE classesname = '/reedcrm/class/pocketcron.class.php' AND methodename = 'syncPocketRecordings'; diff --git a/sql/pocket/llx_reedcrm_pocket_recording.key.sql b/sql/pocket/llx_reedcrm_pocket_recording.key.sql index e980bda1..79b49675 100644 --- a/sql/pocket/llx_reedcrm_pocket_recording.key.sql +++ b/sql/pocket/llx_reedcrm_pocket_recording.key.sql @@ -18,5 +18,6 @@ ALTER TABLE llx_reedcrm_pocket_recording ADD INDEX idx_reedcrm_pocket_recording_ ALTER TABLE llx_reedcrm_pocket_recording ADD INDEX idx_reedcrm_pocket_recording_folder (pocket_folder_id); ALTER TABLE llx_reedcrm_pocket_recording ADD INDEX idx_reedcrm_pocket_recording_date (recording_date); ALTER TABLE llx_reedcrm_pocket_recording ADD INDEX idx_reedcrm_pocket_recording_fk_soc (fk_soc); +ALTER TABLE llx_reedcrm_pocket_recording ADD INDEX idx_reedcrm_pocket_recording_fk_user_pocket (fk_user_pocket); ALTER TABLE llx_reedcrm_pocket_recording ADD UNIQUE INDEX uk_reedcrm_pocket_recording_pocket_id (pocket_id, entity); ALTER TABLE llx_reedcrm_pocket_recording ADD CONSTRAINT llx_reedcrm_pocket_recording_fk_user_creat FOREIGN KEY (fk_user_creat) REFERENCES llx_user(rowid); diff --git a/sql/pocket/llx_reedcrm_pocket_recording.sql b/sql/pocket/llx_reedcrm_pocket_recording.sql index 585b6f80..3f672a56 100644 --- a/sql/pocket/llx_reedcrm_pocket_recording.sql +++ b/sql/pocket/llx_reedcrm_pocket_recording.sql @@ -26,6 +26,7 @@ CREATE TABLE llx_reedcrm_pocket_recording( pocket_id varchar(64) NOT NULL, pocket_folder_id varchar(64), pocket_folder_label varchar(255), + fk_user_pocket integer, pocket_state varchar(32), pocket_tags varchar(255), language varchar(8), diff --git a/view/pocketrecording/pocketrecording_card.php b/view/pocketrecording/pocketrecording_card.php index af50e8ff..1dd91298 100644 --- a/view/pocketrecording/pocketrecording_card.php +++ b/view/pocketrecording/pocketrecording_card.php @@ -41,7 +41,7 @@ // Load ReedCRM libraries require_once __DIR__ . '/../../class/pocketrecording.class.php'; require_once __DIR__ . '/../../class/pocketactionitem.class.php'; -require_once __DIR__ . '/../../class/pocketapi.class.php'; +require_once __DIR__ . '/../../class/pocketaccount.class.php'; require_once __DIR__ . '/../../class/pocketsync.class.php'; require_once __DIR__ . '/../../lib/reedcrm_pocketrecording.lib.php'; @@ -114,8 +114,9 @@ exit; } +// Pocket only answers the detail and the audio of a recording to the account holding it if ($action == 'refresh_from_pocket' && $permissiontoadd) { - $pocketSync = new PocketSync($db); + $pocketSync = new PocketSync($db, PocketAccount::getForRecording($db, $object)); $recording = $pocketSync->api->getRecording($object->pocket_id); if ($recording === null) { @@ -140,7 +141,7 @@ // The signed audio URL expires quickly, it is resolved on demand and never stored if ($action == 'play_audio' && $permissiontoread) { - $pocketApi = new PocketApi($db); + $pocketApi = PocketAccount::getForRecording($db, $object)->getApi(); $audioUrl = $pocketApi->getAudioUrl($object->pocket_id); if (empty($audioUrl)) { @@ -182,6 +183,7 @@ if (!empty($object->pocket_folder_label)) { $moreHtmlRef .= '
' . $langs->trans('PocketFolder') . ': ' . dol_escape_htmltag($object->pocket_folder_label); } +$moreHtmlRef .= '
' . $langs->trans('PocketAccount') . ': ' . dol_escape_htmltag(PocketAccount::getForRecording($db, $object)->getName()); $moreHtmlRef .= ''; $linkBack = '' . $langs->trans('BackToList') . ''; diff --git a/view/pocketrecording/pocketrecording_user.php b/view/pocketrecording/pocketrecording_user.php new file mode 100644 index 00000000..1e866ed4 --- /dev/null +++ b/view/pocketrecording/pocketrecording_user.php @@ -0,0 +1,110 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * \file view/pocketrecording/pocketrecording_user.php + * \ingroup reedcrm + * \brief Pocket tab of the user card: the personal Pocket account of the user, key and folder. + */ + +// Load ReedCRM environment +if (file_exists('../../reedcrm.main.inc.php')) { + require_once __DIR__ . '/../../reedcrm.main.inc.php'; +} elseif (file_exists('../../../reedcrm.main.inc.php')) { + require_once __DIR__ . '/../../../reedcrm.main.inc.php'; +} else { + die('Include of reedcrm main fails'); +} + +// Load Dolibarr libraries +require_once DOL_DOCUMENT_ROOT . '/core/class/html.form.class.php'; +require_once DOL_DOCUMENT_ROOT . '/core/lib/usergroups.lib.php'; + +// Load ReedCRM libraries +require_once __DIR__ . '/../../class/pocketaccount.class.php'; +require_once __DIR__ . '/../../lib/reedcrm_pocketrecording.lib.php'; + +// Global variables definitions +global $conf, $db, $langs, $user; + +// Load translation files required by the page +saturne_load_langs(['users']); + +// Get parameters +$id = GETPOSTINT('id'); +$action = GETPOST('action', 'aZ09'); + +$object = new User($db); +if ($id <= 0 || $object->fetch($id) <= 0) { + accessforbidden($langs->trans('ErrorRecordNotFound')); +} + +// Security check - Own account for whoever uses Pocket, the account of someone else for those +// allowed to edit the users, as on the display setup tab of the user card +$permissiontoadd = $user->hasRight('reedcrm', 'pocketrecording', 'write') + && ($user->id == $object->id || $user->admin || $user->hasRight('user', 'user', 'write')); + +saturne_check_access($permissiontoadd); + +$form = new Form($db); +$pocketAccount = PocketAccount::getPersonal($db, (int) $object->id); +$pocketFormUrl = $_SERVER['PHP_SELF'] . '?id=' . $object->id; + +/* + * Actions + */ + +if (reedcrm_pocket_process_account_action($action, $pocketAccount, $user)) { + header('Location: ' . $pocketFormUrl); + exit; +} + +/* + * View + */ + +$title = $langs->trans('User') . ' - ' . $langs->trans('Pocket'); +$help_url = 'FR:Module_ReedCRM'; + +saturne_header(0, '', $title, $help_url); + +$head = user_prepare_head($object); +print dol_get_fiche_head($head, 'pocket', $langs->trans('User'), -1, 'user'); + +$canReadUsers = $user->hasRight('user', 'user', 'read') || $user->admin; +$linkBack = $canReadUsers ? '' . $langs->trans('BackToList') . '' : ''; +dol_banner_tab($object, 'id', $linkBack, $canReadUsers); + +print '
'; + +// Without a key of their own, the user works with the shared account: said here, otherwise the +// empty form reads as "Pocket is not available to this user" +if (!$pocketAccount->hasApiKey()) { + $sharedAccount = PocketAccount::getShared($db); + if ($sharedAccount->isConfigured()) { + print info_admin($langs->trans('PocketUsesSharedAccount', dol_escape_htmltag($sharedAccount->folderLabel ?: $sharedAccount->folderId)), 0, 0, 'info', ''); + } else { + print info_admin($langs->trans('PocketNoSharedAccount'), 0, 0, 'warning', ''); + } +} + +require_once __DIR__ . '/../../core/tpl/reedcrm_pocket_account_setup.tpl.php'; + +print dol_get_fiche_end(); + +llxFooter(); +$db->close();