Enhance Radar functionality: add sync route and method for Gmail allowlist, update documentation on Git usage, and improve UI for sync feedback. Ensure agent rules are clear regarding commit and push restrictions.
This commit is contained in:
@ -29,6 +29,9 @@ Windows --push--> Gitea(dev) --webhook--> serveur (deploy_dev)
|
||||
+--miroir push--> Bitbucket(dev)
|
||||
```
|
||||
|
||||
**Agent : jamais de `git push`.** L'utilisateur synchronise (voir `00-jamais-push.mdc`).
|
||||
|
||||
|
||||
## Déploiement (règles absolues)
|
||||
|
||||
- Script shell hors webroot : typiquement `~/deploy/deploy_dev.sh`
|
||||
|
||||
34
.cursor/rules/entreprise/00-jamais-push.mdc
Normal file
34
.cursor/rules/entreprise/00-jamais-push.mdc
Normal file
@ -0,0 +1,34 @@
|
||||
---
|
||||
description: INTERDIT — l'agent ne commit / push / deploy jamais tout seul
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Jamais de commit / push / deploy — l'utilisateur pilote Git
|
||||
|
||||
Règle absolue. Pas d'exception « pour aider », « c’est urgent », ou « pour que ça marche sur le serveur ».
|
||||
|
||||
## Interdit (sauf demande explicite de l'utilisateur dans le message)
|
||||
|
||||
- `git commit` (toutes formes)
|
||||
- `git push` (tous remotes)
|
||||
- Déclencher `deploy_dev.php`, webhook, ou `deploy_dev.sh`
|
||||
- « Sync Changes » / publier à la place de l'utilisateur
|
||||
|
||||
## Comportement par défaut
|
||||
|
||||
1. Modifier les fichiers **en local** si la tâche le demande.
|
||||
2. Dire clairement **quels fichiers** ont changé et **quoi** faire (commit / sync).
|
||||
3. **S'arrêter** — l'utilisateur commit et synchronise lui-même.
|
||||
|
||||
## Autorisé seulement si l'utilisateur le dit clairement
|
||||
|
||||
Exemples qui autorisent : « commit », « fais le commit », « commit ça », « pousse », « sync », « déploie ».
|
||||
|
||||
- S'il demande **seulement** commit → commit, **pas** de push.
|
||||
- S'il demande push / sync / deploy → seulement ce qu'il a demandé (et rappeler le modèle utilisateur-sync si la règle push s'applique encore).
|
||||
|
||||
Ambigu (« mets ça en ligne », « arrange Git ») → **demander** avant d'exécuter.
|
||||
|
||||
## Pourquoi
|
||||
|
||||
L'utilisateur doit **voir** chaque commit. Un agent qui commit/push en silence = perte de contrôle et risque de mélange DEV/prod.
|
||||
@ -7,6 +7,11 @@ alwaysApply: true
|
||||
|
||||
Référentiel agent — pas une doc humaine. S'appliquer à tous les projets Progiweb montés sur ce modèle.
|
||||
|
||||
## Git
|
||||
|
||||
**Jamais de commit, push ou deploy** sauf demande explicite. Voir `entreprise/00-jamais-push.mdc`.
|
||||
L'utilisateur voit les diffs, commit, synchronise.
|
||||
|
||||
## Avant de modifier du code
|
||||
|
||||
- Demander le **numéro JIRA** si l'utilisateur ne l'a pas fourni (préfixe selon le projet, voir `projet/`).
|
||||
|
||||
@ -14,6 +14,7 @@ alwaysApply: true
|
||||
|
||||
- Serveur = déploiement only (pas de `user.name` / commits serveur).
|
||||
- Windows → Gitea → webhook → `deploy_dev.php` → `~/deploy/deploy_dev.sh`.
|
||||
- **L'agent ne pousse jamais** et ne déclenche jamais `deploy_dev.php`. L'utilisateur synchronise.
|
||||
|
||||
## Subtilités / état à surveiller
|
||||
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
---
|
||||
description: Radar mail — proto CRM v4 (IDEE-4 / IDEE-5)
|
||||
description: Radar mail — proto CRM v4 (IDEE-4 / MSOP)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Radar mail (CRM)
|
||||
|
||||
Épique **MSOP-1**. Idée : **IDEE-4**. Proto écran : **MSOP-2**.
|
||||
Épique **MSOP-1**. Idée : **IDEE-4**. Proto écran : **MSOP-2**. Analyse IA : **MSOP-3**. Moteur Gmail : **MSOP-4**.
|
||||
|
||||
- UI : CI4 / Raven `/v4/radar` — pas SmartAdmin.
|
||||
- Périmètre : tout `@ms1timing.com` moins `radar_exceptions` (v0 : `confirmation@`). Pas de palier `info@`.
|
||||
- Moteur (plus tard) : Gmail Push + Gemini. L’écran lit `radar_threads`.
|
||||
- Cible long terme : tout `@ms1timing.com` moins `radar_exceptions`.
|
||||
- **MSOP-4 v0 (en cours)** : allowlist **une** mailbox — `leith.s@ms1timing.com` — avant domaine entier.
|
||||
- Moteur : Gmail (réutiliser SA + DWD comme Calendar) + plus tard Gemini. L’écran lit `radar_threads`.
|
||||
- Un fil = N skills. Vérité = CRM (on n’invente pas un client).
|
||||
- Pas dans MS1 Inscription.
|
||||
- Agent : **pas de commit/push** sauf demande explicite.
|
||||
|
||||
7
sql/MSOP-4-gmail-v0-notes.sql
Normal file
7
sql/MSOP-4-gmail-v0-notes.sql
Normal file
@ -0,0 +1,7 @@
|
||||
-- MSOP-4 — note (pas obligatoire)
|
||||
-- Allowlist v0 = Config\Radar::$mailboxAllowlist (leith.s@ms1timing.com).
|
||||
-- Pas de nouvelle table : lecture Gmail → radar_threads existantes.
|
||||
-- Prérequis Google Admin (Domain-Wide Delegation) pour le SA
|
||||
-- crm-calendar-service@….iam.gserviceaccount.com :
|
||||
-- ajouter le scope https://www.googleapis.com/auth/gmail.readonly
|
||||
-- (Calendar seul ne suffit pas).
|
||||
47
v4_ci4/app/Commands/RadarSync.php
Normal file
47
v4_ci4/app/Commands/RadarSync.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Libraries\Radar\RadarSyncService;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
|
||||
/**
|
||||
* MSOP-4 — php spark radar:sync
|
||||
*/
|
||||
class RadarSync extends BaseCommand
|
||||
{
|
||||
protected $group = 'Radar';
|
||||
protected $name = 'radar:sync';
|
||||
protected $description = 'Lit les mailboxes allowlist Gmail et remplit radar_threads (v0).';
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
CLI::write('Radar sync MSOP-4…', 'yellow');
|
||||
$result = (new RadarSyncService())->sync();
|
||||
|
||||
if (! empty($result['error']) && empty($result['mailboxes'])) {
|
||||
CLI::error($result['error']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($result['mailboxes'] as $row) {
|
||||
if (! empty($row['ok'])) {
|
||||
CLI::write(sprintf(
|
||||
'OK %s — fetched=%d insert=%d update=%d',
|
||||
$row['mailbox'],
|
||||
$row['fetched'] ?? 0,
|
||||
$row['inserted'] ?? 0,
|
||||
$row['updated'] ?? 0
|
||||
), 'green');
|
||||
} else {
|
||||
CLI::error(($row['mailbox'] ?? '?') . ' — ' . ($row['error'] ?? 'erreur'));
|
||||
}
|
||||
}
|
||||
|
||||
if (! $result['ok']) {
|
||||
CLI::write('Astuce DWD : ajouter le scope gmail.readonly pour le SA dans Google Admin.', 'yellow');
|
||||
}
|
||||
}
|
||||
}
|
||||
34
v4_ci4/app/Config/Radar.php
Normal file
34
v4_ci4/app/Config/Radar.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
/**
|
||||
* MSOP-4 — Radar Gmail v0 (allowlist une mailbox).
|
||||
*/
|
||||
class Radar extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* Seules ces boîtes sont lues en v0. Domaine entier = story suivante.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $mailboxAllowlist = [
|
||||
'leith.s@ms1timing.com',
|
||||
];
|
||||
|
||||
/** Fenêtre de lecture initiale (jours). */
|
||||
public int $lookbackDays = 14;
|
||||
|
||||
/** Max messages par boîte et run (garde-fou). */
|
||||
public int $maxMessagesPerMailbox = 40;
|
||||
|
||||
/**
|
||||
* Clé SA (même fichier que Calendar CRM).
|
||||
* Relatif à ROOTPATH (dossier v4_ci4/).
|
||||
*/
|
||||
public string $serviceAccountJson = '../application/credentials/service_account.json';
|
||||
|
||||
public string $gmailScope = 'https://www.googleapis.com/auth/gmail.readonly';
|
||||
}
|
||||
@ -10,6 +10,7 @@ $routes->get('/', 'Home::index');
|
||||
// IDEE-5 — proto Radar (écran CRM v4)
|
||||
$routes->get('radar', 'Radar::index');
|
||||
$routes->get('radar/feed', 'Radar::feed');
|
||||
$routes->get('radar/sync', 'Radar::sync'); // MSOP-4 — déclenche sync allowlist (auth requise)
|
||||
|
||||
$routes->get('inventaire/redirect/(:segment)', 'Inventaire::redirect/$1');
|
||||
$routes->get('inventaire/test/(:segment)', 'Inventaire::test/$1');
|
||||
|
||||
@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Libraries\Radar\RadarSyncService;
|
||||
use App\Models\RadarModel;
|
||||
use Config\Radar as RadarConfig;
|
||||
|
||||
/**
|
||||
* IDEE-5 — proto écran Radar (CI4 / Raven).
|
||||
* IDEE-5 / MSOP-2 — proto écran Radar (CI4 / Raven).
|
||||
* MSOP-4 — sync Gmail allowlist.
|
||||
*/
|
||||
class Radar extends BaseController
|
||||
{
|
||||
@ -13,19 +16,22 @@ class Radar extends BaseController
|
||||
{
|
||||
$model = new RadarModel();
|
||||
$ready = $model->tablesExist();
|
||||
$cfg = config(RadarConfig::class);
|
||||
|
||||
return view('radar/index', [
|
||||
'ready' => $ready,
|
||||
'exceptions' => $ready ? $model->listExceptions() : [],
|
||||
'threads' => $ready ? $model->listThreads() : [],
|
||||
'feedUrl' => site_url('v4/radar/feed'),
|
||||
'ready' => $ready,
|
||||
'exceptions' => $ready ? $model->listExceptions() : [],
|
||||
'threads' => $ready ? $model->listThreads() : [],
|
||||
'feedUrl' => site_url('v4/radar/feed'),
|
||||
'syncUrl' => site_url('v4/radar/sync'),
|
||||
'allowlist' => $cfg->mailboxAllowlist,
|
||||
]);
|
||||
}
|
||||
|
||||
public function feed()
|
||||
{
|
||||
$model = new RadarModel();
|
||||
if (!$model->tablesExist()) {
|
||||
if (! $model->tablesExist()) {
|
||||
return $this->response->setJSON([
|
||||
'ok' => false,
|
||||
'threads' => [],
|
||||
@ -38,4 +44,36 @@ class Radar extends BaseController
|
||||
'threads' => $model->listThreads(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Gmail → radar_* (mailbox allowlist uniquement).
|
||||
*/
|
||||
public function sync()
|
||||
{
|
||||
$result = (new RadarSyncService())->sync();
|
||||
|
||||
if ($this->request->isAJAX() || $this->request->getGet('json') !== null) {
|
||||
return $this->response->setJSON($result);
|
||||
}
|
||||
|
||||
$msg = $result['ok'] ? 'Sync OK' : 'Sync en erreur';
|
||||
if (! empty($result['error'])) {
|
||||
$msg .= ' — ' . $result['error'];
|
||||
}
|
||||
foreach ($result['mailboxes'] as $row) {
|
||||
if (! empty($row['ok'])) {
|
||||
$msg .= sprintf(
|
||||
' | %s: +%d / ~%d (lus %d)',
|
||||
$row['mailbox'],
|
||||
$row['inserted'] ?? 0,
|
||||
$row['updated'] ?? 0,
|
||||
$row['fetched'] ?? 0
|
||||
);
|
||||
} else {
|
||||
$msg .= ' | ' . ($row['mailbox'] ?? '?') . ': ' . ($row['error'] ?? 'fail');
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to(site_url('v4/radar'))->with('radar_sync_msg', $msg);
|
||||
}
|
||||
}
|
||||
|
||||
140
v4_ci4/app/Libraries/Radar/GmailReader.php
Normal file
140
v4_ci4/app/Libraries/Radar/GmailReader.php
Normal file
@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Radar;
|
||||
|
||||
/**
|
||||
* Lecture Gmail (readonly) pour une mailbox impersonnée.
|
||||
*/
|
||||
class GmailReader
|
||||
{
|
||||
public function __construct(
|
||||
private GoogleWorkspaceToken $tokenClient,
|
||||
private string $scopes,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* gmail_thread_id:string,
|
||||
* mailbox:string,
|
||||
* subject:string,
|
||||
* from_email:string,
|
||||
* from_name:string,
|
||||
* snippet:string,
|
||||
* gmail_url:string,
|
||||
* received_at:?string
|
||||
* }>
|
||||
*/
|
||||
public function listRecentThreads(string $mailbox, int $lookbackDays, int $maxMessages): array
|
||||
{
|
||||
$access = $this->tokenClient->getAccessToken($mailbox, $this->scopes);
|
||||
$q = 'newer_than:' . max(1, $lookbackDays) . 'd';
|
||||
$url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages?'
|
||||
. http_build_query([
|
||||
'q' => $q,
|
||||
'maxResults' => $maxMessages,
|
||||
]);
|
||||
|
||||
[$code, $body] = $this->tokenClient->http(
|
||||
'GET',
|
||||
$url,
|
||||
['Authorization: Bearer ' . $access]
|
||||
);
|
||||
if ($code !== 200) {
|
||||
throw new \RuntimeException("Gmail messages.list ({$code}): {$body}");
|
||||
}
|
||||
|
||||
$data = json_decode($body, true) ?: [];
|
||||
$ids = $data['messages'] ?? [];
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$byThread = [];
|
||||
foreach ($ids as $m) {
|
||||
$msgId = $m['id'] ?? null;
|
||||
if (! $msgId) {
|
||||
continue;
|
||||
}
|
||||
$detail = $this->getMessageMeta($access, $msgId);
|
||||
if ($detail === null) {
|
||||
continue;
|
||||
}
|
||||
$tid = $detail['gmail_thread_id'];
|
||||
// Garder le message le plus récent du fil dans ce batch
|
||||
if (! isset($byThread[$tid]) || ($detail['internal_ts'] ?? 0) > ($byThread[$tid]['internal_ts'] ?? 0)) {
|
||||
$detail['mailbox'] = $mailbox;
|
||||
$byThread[$tid] = $detail;
|
||||
}
|
||||
}
|
||||
|
||||
$out = array_values($byThread);
|
||||
usort($out, static fn ($a, $b) => ($b['internal_ts'] ?? 0) <=> ($a['internal_ts'] ?? 0));
|
||||
|
||||
foreach ($out as &$row) {
|
||||
unset($row['internal_ts']);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ?array<string, mixed>
|
||||
*/
|
||||
private function getMessageMeta(string $access, string $messageId): ?array
|
||||
{
|
||||
$url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/' . rawurlencode($messageId)
|
||||
. '?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date';
|
||||
|
||||
[$code, $body] = $this->tokenClient->http(
|
||||
'GET',
|
||||
$url,
|
||||
['Authorization: Bearer ' . $access]
|
||||
);
|
||||
if ($code !== 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$msg = json_decode($body, true);
|
||||
if (! is_array($msg)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
foreach ($msg['payload']['headers'] ?? [] as $h) {
|
||||
$headers[strtolower($h['name'] ?? '')] = $h['value'] ?? '';
|
||||
}
|
||||
|
||||
$from = $this->parseFrom($headers['from'] ?? '');
|
||||
$ts = isset($msg['internalDate']) ? (int) floor(((int) $msg['internalDate']) / 1000) : 0;
|
||||
|
||||
return [
|
||||
'gmail_thread_id' => (string) ($msg['threadId'] ?? $messageId),
|
||||
'subject' => (string) ($headers['subject'] ?? '(sans sujet)'),
|
||||
'from_email' => $from['email'],
|
||||
'from_name' => $from['name'],
|
||||
'snippet' => (string) ($msg['snippet'] ?? ''),
|
||||
'gmail_url' => 'https://mail.google.com/mail/u/0/#all/' . rawurlencode((string) ($msg['threadId'] ?? $messageId)),
|
||||
'received_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : null,
|
||||
'internal_ts' => $ts,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{email:string,name:string}
|
||||
*/
|
||||
private function parseFrom(string $from): array
|
||||
{
|
||||
if (preg_match('/^(.*)<([^>]+)>$/', trim($from), $m)) {
|
||||
return [
|
||||
'name' => trim($m[1], " \t\"'"),
|
||||
'email' => strtolower(trim($m[2])),
|
||||
];
|
||||
}
|
||||
|
||||
$email = strtolower(trim($from));
|
||||
|
||||
return ['email' => $email, 'name' => ''];
|
||||
}
|
||||
}
|
||||
102
v4_ci4/app/Libraries/Radar/GoogleWorkspaceToken.php
Normal file
102
v4_ci4/app/Libraries/Radar/GoogleWorkspaceToken.php
Normal file
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Radar;
|
||||
|
||||
/**
|
||||
* JWT + access token Google Workspace (Domain-Wide Delegation).
|
||||
* Même pattern que application/libraries/GCalService.php (MSOP-4).
|
||||
*/
|
||||
class GoogleWorkspaceToken
|
||||
{
|
||||
private string $jsonPath;
|
||||
private string $tokenUri = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
public function __construct(string $jsonPath)
|
||||
{
|
||||
$this->jsonPath = $jsonPath;
|
||||
if (! is_file($this->jsonPath)) {
|
||||
throw new \RuntimeException('Service account JSON introuvable: ' . $this->jsonPath);
|
||||
}
|
||||
}
|
||||
|
||||
public function getAccessToken(string $impersonateEmail, string $scopes): string
|
||||
{
|
||||
$creds = json_decode((string) file_get_contents($this->jsonPath), true);
|
||||
if (! is_array($creds) || empty($creds['client_email']) || empty($creds['private_key'])) {
|
||||
throw new \RuntimeException('service_account.json invalide');
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$header = ['alg' => 'RS256', 'typ' => 'JWT'];
|
||||
$claim = [
|
||||
'iss' => $creds['client_email'],
|
||||
'scope' => $scopes,
|
||||
'aud' => $this->tokenUri,
|
||||
'exp' => $now + 3600,
|
||||
'iat' => $now,
|
||||
'sub' => $impersonateEmail,
|
||||
];
|
||||
|
||||
$unsigned = $this->b64url(json_encode($header)) . '.' . $this->b64url(json_encode($claim));
|
||||
$signature = '';
|
||||
if (! openssl_sign($unsigned, $signature, $creds['private_key'], OPENSSL_ALGO_SHA256)) {
|
||||
throw new \RuntimeException('openssl_sign failed');
|
||||
}
|
||||
$jwt = $unsigned . '.' . $this->b64url($signature);
|
||||
|
||||
[$code, $body] = $this->http(
|
||||
'POST',
|
||||
$this->tokenUri,
|
||||
['Content-Type: application/x-www-form-urlencoded'],
|
||||
http_build_query([
|
||||
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
'assertion' => $jwt,
|
||||
])
|
||||
);
|
||||
|
||||
if ($code !== 200) {
|
||||
throw new \RuntimeException("Token Google error ({$code}): {$body}");
|
||||
}
|
||||
|
||||
$tok = json_decode($body, true);
|
||||
if (empty($tok['access_token'])) {
|
||||
throw new \RuntimeException('Pas d’access_token dans la réponse Google');
|
||||
}
|
||||
|
||||
return $tok['access_token'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
* @return array{0:int,1:string}
|
||||
*/
|
||||
public function http(string $method, string $url, array $headers = [], ?string $body = null): array
|
||||
{
|
||||
$ch = curl_init($url);
|
||||
$opts = [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CUSTOMREQUEST => $method,
|
||||
CURLOPT_TIMEOUT => 60,
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
];
|
||||
if ($body !== null) {
|
||||
$opts[CURLOPT_POSTFIELDS] = $body;
|
||||
}
|
||||
curl_setopt_array($ch, $opts);
|
||||
$res = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
if ($res === false) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
throw new \RuntimeException('cURL error: ' . $err);
|
||||
}
|
||||
curl_close($ch);
|
||||
|
||||
return [$code, $res];
|
||||
}
|
||||
|
||||
private function b64url(string $data): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
126
v4_ci4/app/Libraries/Radar/RadarSyncService.php
Normal file
126
v4_ci4/app/Libraries/Radar/RadarSyncService.php
Normal file
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Radar;
|
||||
|
||||
use App\Models\RadarModel;
|
||||
use Config\Radar as RadarConfig;
|
||||
|
||||
/**
|
||||
* MSOP-4 — sync Gmail allowlist → radar_threads / radar_thread_skills.
|
||||
*/
|
||||
class RadarSyncService
|
||||
{
|
||||
public function __construct(
|
||||
private ?RadarConfig $config = null,
|
||||
private ?RadarModel $model = null,
|
||||
) {
|
||||
$this->config ??= config(RadarConfig::class);
|
||||
$this->model ??= new RadarModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:bool, mailboxes:list<array<string,mixed>>, error?:string}
|
||||
*/
|
||||
public function sync(): array
|
||||
{
|
||||
if (! $this->model->tablesExist()) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'mailboxes' => [],
|
||||
'error' => 'Tables radar_* absentes — exécuter sql/IDEE-4-radar-proto.sql',
|
||||
];
|
||||
}
|
||||
|
||||
$jsonPath = realpath(ROOTPATH . $this->config->serviceAccountJson)
|
||||
?: (ROOTPATH . $this->config->serviceAccountJson);
|
||||
|
||||
try {
|
||||
$tokenClient = new GoogleWorkspaceToken($jsonPath);
|
||||
$reader = new GmailReader($tokenClient, $this->config->gmailScope);
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'mailboxes' => [], 'error' => $e->getMessage()];
|
||||
}
|
||||
|
||||
$results = [];
|
||||
foreach ($this->config->mailboxAllowlist as $mailbox) {
|
||||
$mailbox = strtolower(trim($mailbox));
|
||||
if ($mailbox === '') {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$threads = $reader->listRecentThreads(
|
||||
$mailbox,
|
||||
$this->config->lookbackDays,
|
||||
$this->config->maxMessagesPerMailbox
|
||||
);
|
||||
$inserted = 0;
|
||||
$updated = 0;
|
||||
foreach ($threads as $t) {
|
||||
$skills = $this->guessSkills($t['subject'] . ' ' . $t['snippet']);
|
||||
$status = $skills === [] ? 'a_classer' : 'a_classer';
|
||||
$n = $this->model->upsertThread([
|
||||
'gmail_thread_id' => $t['gmail_thread_id'],
|
||||
'mailbox' => $mailbox,
|
||||
'subject' => mb_substr($t['subject'], 0, 255),
|
||||
'from_email' => mb_substr($t['from_email'], 0, 190),
|
||||
'from_name' => mb_substr($t['from_name'], 0, 190),
|
||||
'gmail_url' => mb_substr($t['gmail_url'], 0, 500),
|
||||
'summary' => mb_substr($t['snippet'], 0, 500),
|
||||
'status' => $status,
|
||||
'confidence' => $skills === [] ? 20 : 45,
|
||||
'is_example' => 0,
|
||||
'received_at' => $t['received_at'],
|
||||
'proposed_client_no' => '',
|
||||
], $skills);
|
||||
if ($n === 'insert') {
|
||||
$inserted++;
|
||||
} else {
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
$results[] = [
|
||||
'mailbox' => $mailbox,
|
||||
'ok' => true,
|
||||
'fetched' => count($threads),
|
||||
'inserted' => $inserted,
|
||||
'updated' => $updated,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$results[] = [
|
||||
'mailbox' => $mailbox,
|
||||
'ok' => false,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$anyOk = false;
|
||||
foreach ($results as $r) {
|
||||
if (! empty($r['ok'])) {
|
||||
$anyOk = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ['ok' => $anyOk || $results === [], 'mailboxes' => $results];
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristique v0 — pas encore Gemini (MSOP-3).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function guessSkills(string $text): array
|
||||
{
|
||||
$t = mb_strtolower($text);
|
||||
$skills = [];
|
||||
if (preg_match('/dossard|bib\b|imprimeur|s[eé]quence|logo\s*doss/', $t)) {
|
||||
$skills[] = 'dossards';
|
||||
}
|
||||
if (preg_match('/inscription|site\s*web|formulaire|chronotrack|ouverture\s+site/', $t)) {
|
||||
$skills[] = 'site';
|
||||
}
|
||||
|
||||
return $skills;
|
||||
}
|
||||
}
|
||||
@ -69,4 +69,74 @@ class RadarModel
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert ou met à jour un fil (dédup gmail_thread_id). Retourne insert|update.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
* @param list<string> $skills
|
||||
*/
|
||||
public function upsertThread(array $data, array $skills = []): string
|
||||
{
|
||||
$db = $this->db();
|
||||
$existing = $db->table('radar_threads')
|
||||
->where('gmail_thread_id', $data['gmail_thread_id'])
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($existing) {
|
||||
$db->table('radar_threads')
|
||||
->where('id', $existing['id'])
|
||||
->update([
|
||||
'mailbox' => $data['mailbox'],
|
||||
'subject' => $data['subject'],
|
||||
'from_email' => $data['from_email'],
|
||||
'from_name' => $data['from_name'],
|
||||
'gmail_url' => $data['gmail_url'],
|
||||
'summary' => $data['summary'],
|
||||
'status' => $data['status'],
|
||||
'confidence' => $data['confidence'],
|
||||
'received_at' => $data['received_at'],
|
||||
'is_example' => 0,
|
||||
]);
|
||||
$threadId = (int) $existing['id'];
|
||||
$op = 'update';
|
||||
} else {
|
||||
$db->table('radar_threads')->insert([
|
||||
'gmail_thread_id' => $data['gmail_thread_id'],
|
||||
'mailbox' => $data['mailbox'],
|
||||
'subject' => $data['subject'],
|
||||
'from_email' => $data['from_email'],
|
||||
'from_name' => $data['from_name'],
|
||||
'gmail_url' => $data['gmail_url'],
|
||||
'summary' => $data['summary'],
|
||||
'status' => $data['status'],
|
||||
'confidence' => $data['confidence'],
|
||||
'is_example' => (int) ($data['is_example'] ?? 0),
|
||||
'received_at' => $data['received_at'],
|
||||
'proposed_client_no' => $data['proposed_client_no'] ?? '',
|
||||
]);
|
||||
$threadId = (int) $db->insertID();
|
||||
$op = 'insert';
|
||||
}
|
||||
|
||||
foreach ($skills as $skill) {
|
||||
$skill = strtolower(trim($skill));
|
||||
if ($skill === '') {
|
||||
continue;
|
||||
}
|
||||
$exists = $db->table('radar_thread_skills')
|
||||
->where('thread_id', $threadId)
|
||||
->where('skill', $skill)
|
||||
->countAllResults();
|
||||
if ($exists === 0) {
|
||||
$db->table('radar_thread_skills')->insert([
|
||||
'thread_id' => $threadId,
|
||||
'skill' => $skill,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $op;
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,9 @@ $ready = $ready ?? false;
|
||||
$exceptions = $exceptions ?? [];
|
||||
$threads = $threads ?? [];
|
||||
$feedUrl = $feedUrl ?? site_url('v4/radar/feed');
|
||||
$syncUrl = $syncUrl ?? site_url('v4/radar/sync');
|
||||
$allowlist = $allowlist ?? [];
|
||||
$syncMsg = session()->getFlashdata('radar_sync_msg');
|
||||
|
||||
$statusLabel = static function (string $status): string {
|
||||
return match ($status) {
|
||||
@ -39,25 +42,41 @@ $statusClass = static function (string $status): string {
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-zinc-500">CRM v4 · IDEE-5</p>
|
||||
<h1 class="text-2xl font-semibold text-zinc-900 dark:text-white">Radar mail</h1>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Tout <span class="font-medium">@ms1timing.com</span> moins les exceptions.
|
||||
Un fil peut porter plusieurs sujets. Le CRM est la vérité — on n’invente pas un client.
|
||||
MSOP-4 v0 : lecture allowlist
|
||||
<?php if ($allowlist !== []): ?>
|
||||
(<span class="font-mono text-xs"><?= esc(implode(', ', $allowlist)) ?></span>)
|
||||
<?php endif; ?>
|
||||
— domaine entier plus tard. Le CRM est la vérité.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-zinc-500">Fil mis à jour</p>
|
||||
<p id="radar-clock" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">—</p>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<?php if ($ready): ?>
|
||||
<a href="<?= esc($syncUrl) ?>"
|
||||
class="inline-flex items-center rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-500">
|
||||
Sync Gmail (v0)
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-zinc-500">Fil mis à jour</p>
|
||||
<p id="radar-clock" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">—</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($syncMsg): ?>
|
||||
<div class="rounded-xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm text-indigo-950 dark:border-indigo-800 dark:bg-indigo-950/40 dark:text-indigo-100">
|
||||
<?= esc($syncMsg) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!$ready): ?>
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
Tables absentes. Exécuter <code class="font-mono">sql/IDEE-4-radar-proto.sql</code> sur la BD CRM de <strong>dev</strong>, puis recharger.
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
|
||||
Moteur Gmail Push + Gemini : pas encore branché. Cet écran lit la table
|
||||
<code class="font-mono">radar_threads</code> (deux exemples proto pour valider la saveur).
|
||||
Périmètre cible : domaine entier, pas une seule boîte.
|
||||
Sync = Gmail readonly sur la mailbox allowlist (SA + DWD). Analyse IA (MSOP-3) pas encore.
|
||||
Si erreur token : ajouter le scope <code class="font-mono">gmail.readonly</code> au SA dans Google Admin.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user