diff --git a/.cursor/rules/entreprise/00-git-modele.mdc b/.cursor/rules/entreprise/00-git-modele.mdc index 42bcc9b5..73303e0b 100644 --- a/.cursor/rules/entreprise/00-git-modele.mdc +++ b/.cursor/rules/entreprise/00-git-modele.mdc @@ -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` diff --git a/.cursor/rules/entreprise/00-jamais-push.mdc b/.cursor/rules/entreprise/00-jamais-push.mdc new file mode 100644 index 00000000..fcebfcdd --- /dev/null +++ b/.cursor/rules/entreprise/00-jamais-push.mdc @@ -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. diff --git a/.cursor/rules/entreprise/00-workflow.mdc b/.cursor/rules/entreprise/00-workflow.mdc index e8976d41..6bcf7908 100644 --- a/.cursor/rules/entreprise/00-workflow.mdc +++ b/.cursor/rules/entreprise/00-workflow.mdc @@ -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/`). diff --git a/.cursor/rules/ops/20-git-deploy-playbook.mdc b/.cursor/rules/ops/20-git-deploy-playbook.mdc index ded927ca..0ebf99b6 100644 --- a/.cursor/rules/ops/20-git-deploy-playbook.mdc +++ b/.cursor/rules/ops/20-git-deploy-playbook.mdc @@ -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 diff --git a/.cursor/rules/projet/10-radar-mail.mdc b/.cursor/rules/projet/10-radar-mail.mdc index 7d5ab1f9..04dc1406 100644 --- a/.cursor/rules/projet/10-radar-mail.mdc +++ b/.cursor/rules/projet/10-radar-mail.mdc @@ -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. diff --git a/sql/MSOP-4-gmail-v0-notes.sql b/sql/MSOP-4-gmail-v0-notes.sql new file mode 100644 index 00000000..bdea5970 --- /dev/null +++ b/sql/MSOP-4-gmail-v0-notes.sql @@ -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). diff --git a/v4_ci4/app/Commands/RadarSync.php b/v4_ci4/app/Commands/RadarSync.php new file mode 100644 index 00000000..0fbeff55 --- /dev/null +++ b/v4_ci4/app/Commands/RadarSync.php @@ -0,0 +1,47 @@ +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'); + } + } +} diff --git a/v4_ci4/app/Config/Radar.php b/v4_ci4/app/Config/Radar.php new file mode 100644 index 00000000..597d003a --- /dev/null +++ b/v4_ci4/app/Config/Radar.php @@ -0,0 +1,34 @@ + + */ + 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'; +} diff --git a/v4_ci4/app/Config/Routes.php b/v4_ci4/app/Config/Routes.php index 5da96127..a80a47ab 100644 --- a/v4_ci4/app/Config/Routes.php +++ b/v4_ci4/app/Config/Routes.php @@ -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'); diff --git a/v4_ci4/app/Controllers/Radar.php b/v4_ci4/app/Controllers/Radar.php index f85a26c4..6b5a1927 100644 --- a/v4_ci4/app/Controllers/Radar.php +++ b/v4_ci4/app/Controllers/Radar.php @@ -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); + } } diff --git a/v4_ci4/app/Libraries/Radar/GmailReader.php b/v4_ci4/app/Libraries/Radar/GmailReader.php new file mode 100644 index 00000000..af75607b --- /dev/null +++ b/v4_ci4/app/Libraries/Radar/GmailReader.php @@ -0,0 +1,140 @@ + + */ + 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 + */ + 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' => '']; + } +} diff --git a/v4_ci4/app/Libraries/Radar/GoogleWorkspaceToken.php b/v4_ci4/app/Libraries/Radar/GoogleWorkspaceToken.php new file mode 100644 index 00000000..7d0b0469 --- /dev/null +++ b/v4_ci4/app/Libraries/Radar/GoogleWorkspaceToken.php @@ -0,0 +1,102 @@ +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 $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), '+/', '-_'), '='); + } +} diff --git a/v4_ci4/app/Libraries/Radar/RadarSyncService.php b/v4_ci4/app/Libraries/Radar/RadarSyncService.php new file mode 100644 index 00000000..c1754391 --- /dev/null +++ b/v4_ci4/app/Libraries/Radar/RadarSyncService.php @@ -0,0 +1,126 @@ +config ??= config(RadarConfig::class); + $this->model ??= new RadarModel(); + } + + /** + * @return array{ok:bool, mailboxes:list>, 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 + */ + 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; + } +} diff --git a/v4_ci4/app/Models/RadarModel.php b/v4_ci4/app/Models/RadarModel.php index 753ea375..80ed92a0 100644 --- a/v4_ci4/app/Models/RadarModel.php +++ b/v4_ci4/app/Models/RadarModel.php @@ -69,4 +69,74 @@ class RadarModel return $rows; } + + /** + * Insert ou met à jour un fil (dédup gmail_thread_id). Retourne insert|update. + * + * @param array $data + * @param list $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; + } } diff --git a/v4_ci4/app/Views/radar/index.php b/v4_ci4/app/Views/radar/index.php index 23ff42e8..ab03101d 100644 --- a/v4_ci4/app/Views/radar/index.php +++ b/v4_ci4/app/Views/radar/index.php @@ -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 {

CRM v4 · IDEE-5

Radar mail

- Tout @ms1timing.com 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 + + () + + — domaine entier plus tard. Le CRM est la vérité.

-
-

Fil mis à jour

-

+
+ + + Sync Gmail (v0) + + +
+

Fil mis à jour

+

+
+ +
+ +
+ +
Tables absentes. Exécuter sql/IDEE-4-radar-proto.sql sur la BD CRM de dev, puis recharger.
- Moteur Gmail Push + Gemini : pas encore branché. Cet écran lit la table - radar_threads (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 gmail.readonly au SA dans Google Admin.