Compare commits

33 Commits
main ... dev

Author SHA1 Message Date
70288da4a2 Refactor AI request handling: optimize timeout settings, implement IPv4 resolution to address connection issues, and improve error messaging for enhanced clarity on network errors. 2026-08-21 14:58:52 -04:00
991b220662 Update AI configuration and HTTP handling: reduce timeout settings for AI requests, implement IPv4 resolution to prevent connection issues, and enhance error messaging for better clarity on network-related errors. 2026-08-21 14:55:24 -04:00
68f725c798 Update Logger configuration: adjust logging threshold for production environments, clarify comments regarding logging verbosity, and remove unnecessary log levels to optimize performance on shared hosting. 2026-08-21 14:51:44 -04:00
efdd90da45 Refactor Auth filter: streamline token validation and session handling, enhance response for unauthenticated requests with JSON support, and improve code readability. Update default layout to ensure lucide icons are created only if the function is available. 2026-08-21 14:47:53 -04:00
38448d828b Refactor Radar configuration and GmailReader: adjust max message limits for performance, implement batch processing for Gmail message metadata retrieval, and enhance error handling in the UI. Update connection timeout settings for improved responsiveness. 2026-08-21 14:45:13 -04:00
e7d525804b Enhance Radar functionality: add new columns for AI analysis and Gmail message ID in the radar_threads table, update Radar controller to handle separate analysis requests, and improve UI to reflect pending AI analyses. Adjust sync service to store Gmail message IDs for future analysis and refine comments for clarity. 2026-08-21 14:41:20 -04:00
dfe85296a8 Refactor RadarSyncService and RadarModel: improve AI analysis handling by checking for analysis columns before executing AI logic, and update comments for clarity. Adjust view logic to ensure count display is only updated when the element exists. 2026-08-21 14:33:28 -04:00
b6e8961bf8 Refactor Radar functionality: replace Gemini client with AiClient for dossards analysis, update sync service to utilize AI platform, and enhance comments for clarity. Remove deprecated GeminiClient class and adjust related methods for improved data handling. 2026-08-21 14:29:22 -04:00
1011f47aff Update Radar configuration: change Gemini model from 2.0-flash to 2.5-flash to reflect upcoming changes and improve clarity in comments. 2026-08-21 12:05:18 -04:00
2cfb157e82 Update .gitignore to remove sensitive credential files and clarify comments in Radar configuration regarding the Gemini API key file handling. 2026-08-21 12:03:47 -04:00
40272afde1 Update Radar configuration: clarify Gemini API key file path and handling instructions in comments for better developer guidance. 2026-08-21 12:01:52 -04:00
84e50b42e0 Enhance Radar functionality: integrate Gemini API for AI analysis on dossards, update sync service to handle AI analysis results, and improve data handling in the Radar model. Add new methods for analysis and ensure UI reflects AI analysis status. Update .gitignore to include sensitive credential files. 2026-08-21 11:59:05 -04:00
1b5284a8b7 Enhance Radar sync feedback: implement fallback for sync messages using query strings and update view logic to ensure consistent display of sync results. Improve styling for better visibility in the UI. 2026-08-21 11:39:33 -04:00
df50d8535c Refactor Radar UI: update status classes and button styles for improved consistency and user experience. Replace indigo color scheme with primary and zinc colors in various UI elements. 2026-08-21 11:35:26 -04:00
b9320aa717 Enhance GoogleWorkspaceToken class: improve error handling for HTTP requests and streamline response parsing. Update related tests for better coverage and reliability. 2026-08-21 09:14:28 -04:00
728445e8ba Implement HTTP handling in GoogleWorkspaceToken class: add support for HTTP requests using streams when cURL is unavailable. Introduce private methods for cURL and stream handling, enhancing compatibility with environments lacking cURL support. 2026-08-21 09:11:16 -04:00
b6aa436588 Refactor GmailReader and GoogleWorkspaceToken classes: add namespace prefixes to built-in functions for improved clarity and consistency. Update RadarSyncService to apply similar changes for function calls. Enhance code readability and maintainability. 2026-08-21 09:10:12 -04:00
55e6edcde4 Refactor Radar UI: enhance sync feedback section with improved layout and add sync button for Gmail allowlist. Update styling for better user experience. 2026-08-21 09:06:31 -04:00
d2488df4d9 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. 2026-08-21 09:03:26 -04:00
7f9749ed29 MSOP-1: restore auth on /v4/radar; keep post-login redirect on current host.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 08:45:02 -04:00
67eeb0cdd5 Update logo assets: replace logo.png and remove logoms1.png 2026-08-21 08:42:33 -04:00
a60c22f718 Fix CI4 DB group: use defaultdev on CRM DEV (Radar was reading prod).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 19:04:07 -04:00
84e9143f59 TEMP DEV: open /v4/radar without auth; keep login redirects on current host.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 18:59:33 -04:00
0b517c4647 Revert config base_url to pre-44ac1770 (restore DEV server config state).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 18:43:26 -04:00
44ac1770db Update base URL and version in configuration files for development environment 2026-08-20 18:02:48 -04:00
30998edad5 Update project documentation in 10-stack.mdc with JIRA prefix details, add new routes for Radar functionality in Routes.php, and include Radar link in the sidebar menu of men_list.php. 2026-08-20 17:09:59 -04:00
d41bdad079 Update .gitignore to exclude VSCode settings and enhance documentation in 20-parametres.mdc with SSH and database details. 2026-07-20 16:01:47 -04:00
9dd279794c Ajouter les règles agent entreprise/projet/ops pour le CRM.
Documente le layout public_html et le playbook de déploiement Gitea.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-19 12:06:06 -04:00
6b1aa0764e premier test upload sur site web git 2026-06-01 11:29:53 -04:00
f6a2444d2c premier test upload sur site web git 2026-06-01 11:27:20 -04:00
8d3da5da60 premier test upload sur site web git 2026-06-01 11:27:20 -04:00
83e9df906a test 2026-06-01 10:00:17 -04:00
eb4d615118 update 2026-05-27 12:39:02 -04:00
56 changed files with 2820 additions and 48 deletions

View File

@ -0,0 +1,21 @@
---
description: Checklist agent — monter un nouveau projet sur le modèle Progiweb
alwaysApply: true
---
# Checklist nouveau projet (agent)
Quand l'utilisateur demande de reproduire le modèle Reader / Inscription / CRM :
1. Créer la structure `.cursor/rules/{entreprise,projet,ops}/` (copier `entreprise/` depuis un projet déjà aligné).
2. Remplir `ops/20-parametres.mdc` (URLs Gitea, Bitbucket, chemins serveur, webhook, SSH).
3. Remplir `ops/20-git-deploy-playbook.mdc` (layout git root : `$HOME` vs `public_html`).
4. Remplir `projet/` (stack, conventions, préfixe JIRA).
5. Windows : remote Gitea, branche `dev`, `.gitignore` secrets / runtime.
6. Versionner `deploy_dev.php` au bon endroit (docroot) **avant** d'activer le webhook.
7. Serveur : `~/deploy/deploy_dev.sh` (chemins absolus), git init/fetch sur le bon root, tester `curl` webhook.
8. Gitea : webhook push → URL `deploy_dev.php` ; miroir Bitbucket avec sync **à chaque soumission** + intervalle filet (ex. `1h`).
9. Test bout en bout : push `dev` → commit visible sur Gitea, serveur, Bitbucket.
10. Secrets uniquement dans 1Password (référencer le titre de l'item dans `ops/20-parametres.mdc`).
Ne pas inventer Bitbucket SSH sur le serveur : le serveur tire depuis Gitea local (`127.0.0.1:3000`) en général.

View File

@ -0,0 +1,46 @@
---
description: Progiweb — modèle Git / Gitea / Bitbucket / serveur (entreprise)
alwaysApply: true
---
# Modèle Git entreprise (Progiweb)
Référentiel agent. Détails par projet → `ops/20-parametres.mdc` et `ops/20-git-deploy-playbook.mdc`.
## Rôles
| Système | Rôle |
|---|---|
| **Gitea** (`git.progiweb.ca`) | Source de vérité — push depuis Windows |
| **Bitbucket** | Miroir / backup opérationnel hors serveurs |
| **Serveur (cPanel)** | Déploiement uniquement — **jamais** de commits sur le serveur |
## Branches
- Travail quotidien : **`dev`**
- Prod : **`master`** (quand le projet l'utilise ; ne pas inventer le flux prod)
- Ne pas pousser `master` sans demande explicite
## Flux nominal
```
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`
- Déclencheur versionné dans le **docroot** : `deploy_dev.php` (sinon `git clean -fd` le détruit)
- Le script fait : `git fetch` → `git reset --hard FETCH_HEAD` → `git clean` **ciblé** (jamais un clean aveugle sur tout le home cPanel)
- Via PHP/Apache, **`HOME` est souvent absent** → chemins **absolus** dans le `.sh`
- Auth Gitea Windows : token (éviter OAuth HTTP « Bad Request »)
- Auth miroir Bitbucket : user `x-token-auth` + Repository Access Token (1Password) — pas le mot de passe web
## Nouveau projet
Suivre `entreprise/00-checklist-nouveau-projet.mdc` et recopier `entreprise/` + remplir `ops/` + `projet/`.

View 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 », « cest 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.

View File

@ -0,0 +1,35 @@
---
description: Progiweb — workflow agent (entreprise). Toujours s'y référer.
alwaysApply: true
---
# Workflow entreprise (Progiweb)
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/`).
- Ne pas bloquer une simple question ou une lecture seule.
## Portée
- **Changement minimal** : uniquement ce qui est demandé.
- Pas de refactor large, renommage massif ou nettoyage opportuniste sans accord explicite.
- Cohérence locale (fichier / module) plutôt que modernisation globale.
## Incohérences voisines
1. Signaler brièvement.
2. Proposer une correction ciblée.
3. Attendre confirmation avant de sortir du périmètre.
## Fin de tâche
- Résumer : quoi, où, déploiement / SQL / étapes manuelles restantes.
- Ne pas créer de README ou docs hors demande explicite (les règles agent vivent dans `.cursor/rules/`).

View File

@ -0,0 +1,30 @@
---
description: CRM MS1 — playbook Git/deploy + état connu (ops)
alwaysApply: true
---
# Playbook Git / deploy — CRM MS1
## Layout (≠ Reader)
- Dépôt Git serveur = **`~/public_html`** (comme un site entier dans le docroot).
- `deploy_dev.php` doit être **versionné** à la racine du dépôt — sinon chaque `git clean -fd` l'efface.
## Philosophie
- 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
- `git clean -fd` est agressif : tout fichier non versionné dans `public_html` disparaît.
- Si le push Windows échoue : auth Gitea (token), pas OAuth HTTP cassé.
- Miroir Bitbucket : sync à chaque soumission + intervalle filet `1h` (aligner sur Reader).
- Tunnel SQL / Cursor : SSH **7822**.
## Checklist locale agent
1. `deploy_dev.php` présent et tracké avant d'activer le webhook.
2. Tester `curl` / hit sur `deploy_dev.php` après correction des chemins absolus dans le `.sh`.
3. Vérifier Gitea + serveur sur le même SHA `dev`.

View File

@ -0,0 +1,43 @@
---
description: CRM MS1 — paramètres figés (ops)
alwaysApply: true
---
# Paramètres — CRM MS1
Secrets → 1Password uniquement.
## Git / remotes
| Clé | Valeur |
|---|---|
| Gitea (Windows) | `http://git.progiweb.ca/stephan/crm-ms1.git` |
| Gitea (serveur, typique) | `http://127.0.0.1:3000/stephan/crm-ms1.git` |
| Branche travail | `dev` |
| Autre branche remote vue | `main` (historique) — travail sur **`dev`** |
| Bitbucket | Ancien ; source de vérité = **Gitea** |
## Webhook / deploy (dev)
| Clé | Valeur |
|---|---|
| URL webhook prévue | `https://ms1crmdev.progiweb.net/deploy_dev.php` |
| Script | `~/deploy/deploy_dev.sh` |
| Git root serveur | **`~/public_html`** (tout le CRM = docroot) |
| Comportement script | fetch → reset --hard → `git clean -fd` |
## SSH / SQLTools
| Clé | Valeur |
|---|---|
| Port SSH | **7822** (pas 3306 pour le tunnel) |
| Host SSH | `ms1crmdev.progiweb.net` |
| User SSH / home | `devcrmms1` / `/home/devcrmms1` |
| cPanel | `https://serveur-prod.progiweb.net:2083` |
| Exemple SQLTools | `.vscode/settings.json.example` → copier en `settings.json` (gitignored) |
| BD CRM dev | `devcrmms1_dev` |
| BD Inscription (2e groupe CI) | `devinscription_prod` (même host MySQL si grants OK) |
## 1Password / auth
- Même modèle Reader : token Gitea pour push Windows ; miroir Bitbucket si activé = `x-token-auth` + access token.

View File

@ -0,0 +1,13 @@
---
description: Couche IA plateforme CRM (20 ans)
alwaysApply: true
---
# IA plateforme — CRM MS1
- Code : `v4_ci4/app/Libraries/Ai/` + `Config/Ai.php`.
- Les features (Radar, plus tard Projets/Clients/…) passent par **`AiFactory::client()` / `AiClient`**. Jamais dappel vendor dans un module métier.
- Provider primary : **Gemini** (adaptateur). Autres providers = nouveaux adaptateurs, même contrat.
- Modèle par défaut : alias stable `gemini-flash-latest` (éviter les IDs versionnés qui meurent).
- Clé : `application/credentials/gemini_api_key.txt` (même flux Git que le SA pour linstant ; viser projet GCP MS1 facturé, pas free perso long terme).
- Agent : pas de patch model-string au feeling ; pas de commit/push sans demande.

View File

@ -0,0 +1,10 @@
---
description: CRM MS1 — documentation agent
alwaysApply: true
---
# Documentation agent — CRM MS1
- Documenter la logique métier non évidente.
- Référencer le ticket JIRA dans le code / SQL quand fourni.
- Fin de tâche : quoi / où / déploiement.

View File

@ -0,0 +1,11 @@
---
description: Conventions PHP CRM MS1
globs: **/*.php,application/**/*.php,v4/**/*.php,v4_ci4/**/*.php
alwaysApply: false
---
# Conventions PHP — CRM MS1
- Suivre le fichier ouvert (style local).
- Changement minimal ; pas de modernisation globale sans accord.
- Pas de secrets dans le code ; pas de `var_dump` laissé en prod.

View File

@ -0,0 +1,16 @@
---
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**. Analyse IA : **MSOP-3**. Moteur Gmail : **MSOP-4**.
- UI : CI4 / Raven `/v4/radar` — pas SmartAdmin.
- Cible long terme : tout `@ms1timing.com` moins `radar_exceptions`.
- **MSOP-4 v0** : allowlist **une** mailbox — `leith.s@ms1timing.com`.
- **MSOP-3** : analyse dossards + delta via **`AiClient`** (couche `Libraries/Ai`), pas un client Gemini collé au Radar. SQL : `sql/MSOP-3-radar-analysis.sql`.
- Un fil = N skills. Vérité = CRM (on ninvente pas un client). LIA signale, napprouve pas.
- Pas dans MS1 Inscription.
- Agent : **pas de commit/push** sauf demande explicite.

View File

@ -0,0 +1,11 @@
---
description: CRM MS1 — stack et préfixe JIRA (projet)
alwaysApply: true
---
# Projet CRM MS1 — stack
- Stack : PHP applicatif CRM (`application/`, `v4/`, `v4_ci4/`, etc.).
- Préfixe JIRA développement : **`MSOP`** (espace MS1 Ops). Idées = `IDEE` (pas de code). Inscription = `MSIN`.
- Workspace local typique : `C:\dev_v4\crm-ms1`.
- Ne pas committer `credentials.json` ni secrets.

4
.gitignore vendored
View File

@ -1,8 +1,12 @@
# IDE — ne jamais committer les connexions SQL (copier settings.json.example)
.vscode/settings.json
# Runtime / data # Runtime / data
data/ data/
logs/ logs/
error_log error_log
# CI4 writable runtime # CI4 writable runtime
*/writable/logs/ */writable/logs/
*/writable/cache/ */writable/cache/

47
.vscode/settings.json.example vendored Normal file
View File

@ -0,0 +1,47 @@
{
// Copier vers settings.json (gitignored) et renseigner vos identifiants locaux.
"sqltools.connections": [
{
"mysqlOptions": {
"authProtocol": "default",
"enableSsl": "Disabled"
},
"ssh": "Enabled",
"previewLimit": 50,
"server": "localhost",
"port": 3306,
"sshOptions": {
"port": 7822,
"host": "ms1crmdev.progiweb.net",
"username": "YOUR_SSH_USER",
"password": "YOUR_SSH_PASSWORD"
},
"driver": "MariaDB",
"name": "CRM MS1 — dev",
"username": "YOUR_DB_USER",
"password": "YOUR_DB_PASSWORD",
"database": "devcrmms1_dev"
},
{
"mysqlOptions": {
"authProtocol": "default",
"enableSsl": "Disabled"
},
"ssh": "Enabled",
"previewLimit": 50,
"server": "localhost",
"port": 3306,
"sshOptions": {
"port": 7822,
"host": "ms1crmdev.progiweb.net",
"username": "YOUR_SSH_USER",
"password": "YOUR_SSH_PASSWORD"
},
"driver": "MariaDB",
"name": "MS1 Inscription (via CRM host)",
"username": "YOUR_DB_USER",
"password": "YOUR_DB_PASSWORD",
"database": "devinscription_prod"
}
]
}

View File

View File

@ -23,7 +23,7 @@ defined('BASEPATH') OR exit('No direct script access allowed');
| a PHP script and you can easily do that on your own. | a PHP script and you can easily do that on your own.
| |
*/ */
$config['base_url'] = 'https://ms1crm.interne.ca/'; $config['base_url'] = 'https://ms1crmdev.progiweb.net/';
$config['titre']='Ms1-CRM-dev'; $config['titre']='Ms1-CRM-dev';
$config['emailreservation']='mayrand.p@ms1timing.com'; $config['emailreservation']='mayrand.p@ms1timing.com';
/* /*

View File

@ -0,0 +1,118 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/*
| -------------------------------------------------------------------
| DATABASE CONNECTIVITY SETTINGS
| -------------------------------------------------------------------
| This file will contain the settings needed to access your database.
|
| For complete instructions please consult the 'Database Connection'
| page of the User Guide.
|
| -------------------------------------------------------------------
| EXPLANATION OF VARIABLES
| -------------------------------------------------------------------
|
| ['dsn'] The full DSN string describe a connection to the database.
| ['hostname'] The hostname of your database server.
| ['username'] The username used to connect to the database
| ['password'] The password used to connect to the database
| ['database'] The name of the database you want to connect to
| ['dbdriver'] The database driver. e.g.: mysqli.
| Currently supported:
| cubrid, ibase, mssql, mysql, mysqli, oci8,
| odbc, pdo, postgre, sqlite, sqlite3, sqlsrv
| ['dbprefix'] You can add an optional prefix, which will be added
| to the table name when using the Query Builder class
| ['pconnect'] TRUE/FALSE - Whether to use a persistent connection
| ['db_debug'] TRUE/FALSE - Whether database errors should be displayed.
| ['cache_on'] TRUE/FALSE - Enables/disables query caching
| ['cachedir'] The path to the folder where cache files should be stored
| ['char_set'] The character set used in communicating with the database
| ['dbcollat'] The character collation used in communicating with the database
| NOTE: For MySQL and MySQLi databases, this setting is only used
| as a backup if your server is running PHP < 5.2.3 or MySQL < 5.0.7
| (and in table creation queries made with DB Forge).
| There is an incompatibility in PHP with mysql_real_escape_string() which
| can make your site vulnerable to SQL injection if you are using a
| multi-byte character set and are running versions lower than these.
| Sites using Latin-1 or UTF-8 database character set and collation are unaffected.
| ['swap_pre'] A default table prefix that should be swapped with the dbprefix
| ['encrypt'] Whether or not to use an encrypted connection.
|
| 'mysql' (deprecated), 'sqlsrv' and 'pdo/sqlsrv' drivers accept TRUE/FALSE
| 'mysqli' and 'pdo/mysql' drivers accept an array with the following options:
|
| 'ssl_key' - Path to the private key file
| 'ssl_cert' - Path to the public key certificate file
| 'ssl_ca' - Path to the certificate authority file
| 'ssl_capath' - Path to a directory containing trusted CA certificates in PEM format
| 'ssl_cipher' - List of *allowed* ciphers to be used for the encryption, separated by colons (':')
| 'ssl_verify' - TRUE/FALSE; Whether verify the server certificate or not
|
| ['compress'] Whether or not to use client compression (MySQL only)
| ['stricton'] TRUE/FALSE - forces 'Strict Mode' connections
| - good for ensuring strict SQL while developing
| ['ssl_options'] Used to set various SSL options that can be used when making SSL connections.
| ['failover'] array - A array with 0 or more data for connections if the main should fail.
| ['save_queries'] TRUE/FALSE - Whether to "save" all executed queries.
| NOTE: Disabling this will also effectively disable both
| $this->db->last_query() and profiling of DB queries.
| When you run a query, with this setting set to TRUE (default),
| CodeIgniter will store the SQL statement for debugging purposes.
| However, this may cause high memory usage, especially if you run
| a lot of SQL queries ... disable this to avoid that problem.
|
| The $active_group variable lets you choose which connection group to
| make active. By default there is only one group (the 'default' group).
|
| The $query_builder variables lets you determine whether or not to load
| the query builder class.
*/
$active_group = 'default';
$query_builder = TRUE;
$db['default'] = array(
'dsn' => '',
'hostname' => 'localhost',
'username' => 'devcrmms1_bd',
'password' => '.vB==c$s_Yk7',
'database' => 'devcrmms1_dev',
'dbdriver' => 'mysqli',
'dbprefix' => '',
'pconnect' => FALSE,
'db_debug' => (ENVIRONMENT !== 'dev'),
'cache_on' => FALSE,
'cachedir' => '',
'char_set' => 'utf8',
'dbcollat' => 'utf8_general_ci',
'swap_pre' => '',
'encrypt' => FALSE,
'compress' => FALSE,
'stricton' => FALSE,
'failover' => array(),
'save_queries' => TRUE
);
$db['ms1inscription'] = array(
'dsn' => '',
'hostname' => 'localhost',
'username' => 'devinscription_bd',
'password' => 'x$@_v0-l_I((',
'database' => 'devinscription_prod',
'dbdriver' => 'mysqli',
'dbprefix' => '',
'pconnect' => FALSE,
'db_debug' => (ENVIRONMENT !== 'dev'),
'cache_on' => FALSE,
'cachedir' => '',
'char_set' => 'utf8',
'dbcollat' => 'utf8_general_ci',
'swap_pre' => '',
'encrypt' => FALSE,
'compress' => FALSE,
'stricton' => FALSE,
'failover' => array(),
'save_queries' => TRUE
);

View File

@ -90,10 +90,19 @@ class Login extends CI_Controller {
if ($page) { if ($page) {
setcookie('derniere_page','',time()-3600,'/'); setcookie('derniere_page','',time()-3600,'/');
header("Location: ".$page); // Path only — rester sur le host où on sest logué (pas prod via URL absolue).
if (preg_match('#^https?://#i', $page)) {
$parts = parse_url($page);
$page = ($parts['path'] ?? '/')
. (isset($parts['query']) ? '?'.$parts['query'] : '');
}
if ($page === '' || $page[0] !== '/') {
$page = '/';
}
header('Location: '.$page);
exit; exit;
} }
redirect('/'); redirect('/');
} }

View File

@ -0,0 +1 @@
AQ.Ab8RN6IZXKEJWxsI5iu-aFti8BlaIczY1T5yVFSK19L4S3bjRQ

View File

@ -0,0 +1,7 @@
# Colle ici la clé API Gemini (une seule ligne).
# Google AI Studio → Create API key (projet fast-academy-472418-r4 si possible).
# Activer aussi « Generative Language API » sur le projet GCP.
#
# Chemin attendu par Radar :
# application/credentials/gemini_api_key.txt
AQ.Ab8RN6IZXKEJWxsI5iu-aFti8BlaIczY1T5yVFSK19L4S3bjRQ

2
deploy_dev.php Normal file
View File

@ -0,0 +1,2 @@
<?php
echo shell_exec('/home/devcrmms1/deploy/deploy_dev.sh 2>&1');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

BIN
img/logoexp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

2
sl.php
View File

@ -1,4 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">

129
sql/IDEE-4-radar-proto.sql Normal file
View File

@ -0,0 +1,129 @@
-- IDEE-4 / IDEE-5 — Radar mail proto (CRM)
-- Tables pour lécran /v4/radar. Moteur Gmail Push + Gemini : story suivante.
-- Exécuter sur la BD CRM de DEV (devcrmms1_dev), pas en prod.
CREATE TABLE IF NOT EXISTS radar_exceptions (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
kind ENUM('mailbox', 'sender') NOT NULL DEFAULT 'mailbox',
value VARCHAR(190) NOT NULL,
note VARCHAR(255) NOT NULL DEFAULT '',
active TINYINT(1) NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_radar_exc (kind, value)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS radar_threads (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
gmail_thread_id VARCHAR(64) NOT NULL,
mailbox VARCHAR(190) NOT NULL DEFAULT '',
subject VARCHAR(255) NOT NULL DEFAULT '',
from_email VARCHAR(190) NOT NULL DEFAULT '',
from_name VARCHAR(190) NOT NULL DEFAULT '',
gmail_url VARCHAR(500) NOT NULL DEFAULT '',
client_id INT UNSIGNED NULL DEFAULT NULL,
projet_id INT UNSIGNED NULL DEFAULT NULL,
proposed_client_no VARCHAR(32) NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'a_classer',
summary VARCHAR(500) NOT NULL DEFAULT '',
confidence TINYINT UNSIGNED NOT NULL DEFAULT 0,
is_example TINYINT(1) NOT NULL DEFAULT 0,
received_at DATETIME NULL DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_radar_thread (gmail_thread_id),
KEY idx_radar_status (status),
KEY idx_radar_received (received_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS radar_thread_skills (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
thread_id INT UNSIGNED NOT NULL,
skill VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_radar_skill (thread_id, skill),
KEY idx_radar_skill (skill)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS radar_rules (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
match_type ENUM('email', 'domain') NOT NULL DEFAULT 'email',
match_value VARCHAR(190) NOT NULL,
client_id INT UNSIGNED NULL DEFAULT NULL,
projet_id INT UNSIGNED NULL DEFAULT NULL,
skill VARCHAR(32) NOT NULL DEFAULT '',
source VARCHAR(32) NOT NULL DEFAULT 'human',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_radar_rule (match_type, match_value, skill)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO radar_exceptions (kind, value, note, active)
SELECT 'mailbox', 'confirmation@ms1timing.com', 'Mails transactionnels inscription — ne pas ouvrir', 1
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM radar_exceptions
WHERE kind = 'mailbox' AND value = 'confirmation@ms1timing.com'
);
INSERT INTO radar_threads (
gmail_thread_id, mailbox, subject, from_email, from_name, gmail_url,
proposed_client_no, status, summary, confidence, is_example, received_at
)
SELECT
'proto-ex-1',
'desjardins.lp@ms1timing.com',
'Logos dossards + fichiers départ site',
'marie@course-exemple.ca',
'Marie Exemple',
'',
'1842',
'a_classer',
'Deux sujets dans le même fil : logos pour limprimeur, et fichiers à déposer pour le site dinscription.',
72,
1,
NOW()
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM radar_threads WHERE gmail_thread_id = 'proto-ex-1');
INSERT INTO radar_thread_skills (thread_id, skill)
SELECT t.id, 'dossards' FROM radar_threads t
WHERE t.gmail_thread_id = 'proto-ex-1'
AND NOT EXISTS (
SELECT 1 FROM radar_thread_skills s WHERE s.thread_id = t.id AND s.skill = 'dossards'
);
INSERT INTO radar_thread_skills (thread_id, skill)
SELECT t.id, 'site' FROM radar_threads t
WHERE t.gmail_thread_id = 'proto-ex-1'
AND NOT EXISTS (
SELECT 1 FROM radar_thread_skills s WHERE s.thread_id = t.id AND s.skill = 'site'
);
INSERT INTO radar_threads (
gmail_thread_id, mailbox, subject, from_email, from_name, gmail_url,
proposed_client_no, status, summary, confidence, is_example, received_at
)
SELECT
'proto-ex-2',
'info@ms1timing.com',
'Question impression dossards',
'nouveau@inconnu.example',
'Contact inconnu',
'',
'',
'a_mettre_dans_crm',
'Adresse absente du CRM. Créer le contact / projet, puis relancer — le fil doit se recoller tout seul.',
40,
1,
DATE_SUB(NOW(), INTERVAL 25 MINUTE)
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM radar_threads WHERE gmail_thread_id = 'proto-ex-2');
INSERT INTO radar_thread_skills (thread_id, skill)
SELECT t.id, 'dossards' FROM radar_threads t
WHERE t.gmail_thread_id = 'proto-ex-2'
AND NOT EXISTS (
SELECT 1 FROM radar_thread_skills s WHERE s.thread_id = t.id AND s.skill = 'dossards'
);

View File

@ -0,0 +1,14 @@
-- MSOP-3 — colonnes analyse IA dossards (dev)
-- Exécuter sur la BD CRM de DEV après IDEE-4-radar-proto.sql
-- Si « Duplicate column » sur analysis : OK déjà fait — lancer seulement le ADD gmail_message_id.
ALTER TABLE radar_threads
ADD COLUMN analysis TEXT NULL AFTER summary,
ADD COLUMN analysis_delta TEXT NULL AFTER analysis;
ALTER TABLE radar_threads
MODIFY summary VARCHAR(1000) NOT NULL DEFAULT '';
-- Id message Gmail pour relire le corps lors de lanalyse IA (hors Sync).
ALTER TABLE radar_threads
ADD COLUMN gmail_message_id VARCHAR(64) NOT NULL DEFAULT '' AFTER gmail_thread_id;

View File

@ -0,0 +1,5 @@
-- MSOP-3 suite — uniquement si analysis existe déjà (erreur 1060 avant).
-- À lancer une fois sur DEV.
ALTER TABLE radar_threads
ADD COLUMN gmail_message_id VARCHAR(64) NOT NULL DEFAULT '' AFTER gmail_thread_id;

View 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).

View File

@ -0,0 +1,39 @@
<?php
namespace App\Commands;
use App\Libraries\Ai\AiFactory;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
/**
* Test connectivité Gemini depuis le serveur (pas depuis Windows).
* Usage : php spark radar:ai-ping
*/
class RadarAiPing extends BaseCommand
{
protected $group = 'Radar';
protected $name = 'radar:ai-ping';
protected $description = 'Ping API Gemini (complete court) pour valider le réseau serveur.';
public function run(array $params)
{
CLI::write('Test AiFactory → Gemini…', 'yellow');
try {
$ai = AiFactory::client();
if (! $ai->isConfigured()) {
CLI::error('Clé absente (gemini_api_key.txt)');
return;
}
CLI::write('Provider=' . $ai->provider() . ' model=' . $ai->model(), 'white');
$t0 = \microtime(true);
$res = $ai->complete('Réponds uniquement: PONG');
$ms = (int) \round((\microtime(true) - $t0) * 1000);
CLI::write('OK en ' . $ms . ' ms — ' . \mb_substr($res->text, 0, 80), 'green');
} catch (\Throwable $e) {
CLI::error($e->getMessage());
CLI::write('Si timeout 0 bytes : firewall/IPv6 sortant vers generativelanguage.googleapis.com', 'yellow');
}
}
}

View 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');
}
}
}

37
v4_ci4/app/Config/Ai.php Normal file
View File

@ -0,0 +1,37 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* Couche IA plateforme CRM (20 ans) — pas spécifique Radar.
* Les features (Radar, plus tard Projets/Clients/…) consomment AiClient, jamais un vendor.
*/
class Ai extends BaseConfig
{
/**
* Provider actif : gemini | (openai|anthropic plus tard).
*/
public string $provider = 'gemini';
/**
* Modèle. Préférer un alias stable (*-latest) pour limiter le churn dIDs Google.
* Validé via models.list sur la clé du projet (août 2026) : gemini-flash-latest existe.
*/
public string $model = 'gemini-flash-latest';
/**
* Clé API Gemini — même dossier que le SA Calendar.
* Relatif à ROOTPATH (v4_ci4/).
*/
public string $geminiApiKeyFile = '../application/credentials/gemini_api_key.txt';
public string $geminiBaseUrl = 'https://generativelanguage.googleapis.com/v1beta';
public int $timeoutSeconds = 25;
public int $maxOutputTokens = 2048;
public float $temperature = 0.2;
}

View File

@ -17,7 +17,7 @@ class App extends BaseConfig
* E.g., http://example.com/ * E.g., http://example.com/
* public string $baseURL = 'https://ms1crmdev.progiweb.net/'; * public string $baseURL = 'https://ms1crmdev.progiweb.net/';
*/ */
public string $baseURL = 'https://ms1crm.interne.ca/'; public string $baseURL = 'https://ms1crmdev.progiweb.net/';
/** /**
* Allowed Hostnames in the Site URL other than the hostname in the baseURL. * Allowed Hostnames in the Site URL other than the hostname in the baseURL.

View File

@ -8,7 +8,8 @@ class Database extends Config
{ {
public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR; public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR;
public string $defaultGroup = 'default'; // Sur ms1crmdev : utiliser defaultdev (sinon CI4 lit ms1crm_prod → Radar « tables absentes »).
public string $defaultGroup = 'defaultdev';
public array $defaultdev = [ public array $defaultdev = [
'DSN' => '', 'DSN' => '',

View File

@ -72,7 +72,8 @@ class Filters extends BaseFilters
* } * }
*/ */
public array $globals = [ public array $globals = [
'before' => ['auth' 'before' => [
'auth',
// 'honeypot', // 'honeypot',
// 'csrf', // 'csrf',
// 'invalidchars', // 'invalidchars',

View File

@ -34,12 +34,12 @@ class Logger extends BaseConfig
* *
* array(1, 2, 3, 8) = Emergency, Alert, Critical, and Debug messages * array(1, 2, 3, 8) = Emergency, Alert, Critical, and Debug messages
* *
* For a live site you'll usually enable Critical or higher (3) to be logged otherwise * CRM sur hébergement partagé : JAMAIS threshold 9 (debug).
* your log files will fill up very fast. * development + Radar/feed en boucle = logs qui peuvent saturer le quota.
* *
* @var int|list<int> * @var int|list<int>
*/ */
public $threshold = (ENVIRONMENT === 'production') ? 4 : 9; public $threshold = 4;
/** /**
* -------------------------------------------------------------------------- * --------------------------------------------------------------------------
@ -88,11 +88,8 @@ class Logger extends BaseConfig
'critical', 'critical',
'alert', 'alert',
'emergency', 'emergency',
'debug',
'error', 'error',
'info', // pas debug/info/notice — trop verbeux sur cPanel
'notice',
'warning',
], ],
/* /*

View File

@ -0,0 +1,38 @@
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
/**
* MSOP-4 / MSOP-3 — Radar (Gmail + consommation Ai plateforme).
* Clé / modèle LLM → Config\Ai (pas ici).
*/
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 — Sync doit rester rapide). */
public int $maxMessagesPerMailbox = 15;
/**
* 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';
/** Max analyses IA dossards par clic « Analyser » (1 = ~3060 s, page ne meurt pas). */
public int $maxAiAnalysesPerSync = 1;
}

View File

@ -7,6 +7,12 @@ use CodeIgniter\Router\RouteCollection;
*/ */
$routes->get('/', 'Home::index'); $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 — Gmail allowlist (rapide)
$routes->get('radar/analyze', 'Radar::analyze'); // MSOP-3 — IA dossards (séparé)
$routes->get('inventaire/redirect/(:segment)', 'Inventaire::redirect/$1'); $routes->get('inventaire/redirect/(:segment)', 'Inventaire::redirect/$1');
$routes->get('inventaire/test/(:segment)', 'Inventaire::test/$1'); $routes->get('inventaire/test/(:segment)', 'Inventaire::test/$1');

View File

@ -0,0 +1,134 @@
<?php
namespace App\Controllers;
use App\Libraries\Radar\RadarSyncService;
use App\Models\RadarModel;
use Config\Radar as RadarConfig;
/**
* IDEE-5 / MSOP-2 — proto écran Radar (CI4 / Raven).
* MSOP-4 — sync Gmail. MSOP-3 — analyse IA (route séparée).
*/
class Radar extends BaseController
{
public function index()
{
$model = new RadarModel();
$ready = $model->tablesExist();
$cfg = config(RadarConfig::class);
$syncMsg = session()->getFlashdata('radar_sync_msg');
if ($syncMsg === null || $syncMsg === '') {
$syncMsg = $this->request->getGet('sync_msg');
}
$pendingAi = 0;
if ($ready && $model->hasAnalysisColumns()) {
$pendingAi = $model->countThreadsNeedingDossardAnalysis();
}
return view('radar/index', [
'ready' => $ready,
'exceptions' => $ready ? $model->listExceptions() : [],
'threads' => $ready ? $model->listThreads() : [],
'feedUrl' => site_url('v4/radar/feed'),
'syncUrl' => site_url('v4/radar/sync'),
'analyzeUrl' => site_url('v4/radar/analyze'),
'pendingAi' => $pendingAi,
'allowlist' => $cfg->mailboxAllowlist,
'syncMsg' => is_string($syncMsg) ? $syncMsg : null,
]);
}
public function feed()
{
$model = new RadarModel();
if (! $model->tablesExist()) {
return $this->response->setJSON([
'ok' => false,
'threads' => [],
]);
}
return $this->response->setJSON([
'ok' => true,
'at' => date('c'),
'threads' => $model->listThreads(),
]);
}
/**
* Sync Gmail → radar_* (rapide, sans LLM).
*/
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');
}
}
$msg .= ' — pour lIA : bouton Analyser dossards';
return $this->redirectWithMsg($msg);
}
/**
* MSOP-3 — analyse IA des fils dossards en attente (peut prendre 12 min).
*/
public function analyze()
{
$result = (new RadarSyncService())->analyzePending();
if ($this->request->isAJAX() || $this->request->getGet('json') !== null) {
return $this->response->setJSON($result);
}
if (! empty($result['error'])) {
$msg = 'Analyse IA en erreur — ' . $result['error'];
} else {
$msg = sprintf(
'Analyse IA : %d fait(s), %d encore en attente [%s]',
(int) ($result['ran'] ?? 0),
(int) ($result['pending'] ?? 0),
(string) ($result['model'] ?? '')
);
if (! empty($result['errors'])) {
$msg .= ', ' . (int) $result['errors'] . ' erreur(s)';
}
if (! empty($result['note']) && (int) ($result['errors'] ?? 0) > 0) {
$msg .= ' (' . \mb_substr((string) $result['note'], 0, 120) . ')';
}
if ((int) ($result['pending'] ?? 0) > 0) {
$msg .= ' — recliquer Analyser pour continuer';
}
}
return $this->redirectWithMsg($msg);
}
private function redirectWithMsg(string $msg)
{
$q = rawurlencode(mb_substr($msg, 0, 800));
return redirect()->to(site_url('v4/radar') . '?sync_msg=' . $q)->with('radar_sync_msg', $msg);
}
}

View File

@ -13,19 +13,11 @@ class Auth implements FilterInterface
{ {
$token = $_COOKIE['auth_token'] ?? null; $token = $_COOKIE['auth_token'] ?? null;
// Aucun token → logout CI4 et redirection if (! $token) {
if (!$token) {
session()->destroy(); session()->destroy();
setcookie('derniere_page', $_SERVER['REQUEST_URI'] ?? '/', time() + 3600, '/');
setcookie( return $this->deny($request);
'derniere_page',
$_SERVER['REQUEST_URI'],
time() + 3600,
'/'
);
return redirect()->to('/login');
} }
$db = Database::connect(); $db = Database::connect();
@ -36,46 +28,61 @@ class Auth implements FilterInterface
->get() ->get()
->getRow(); ->getRow();
// Token invalide → logout CI4 if (! $row) {
if (!$row) {
session()->destroy(); session()->destroy();
setcookie('derniere_page', $_SERVER['REQUEST_URI'] ?? '/', time() + 3600, '/');
setcookie( return $this->deny($request);
'derniere_page',
$_SERVER['REQUEST_URI'],
time() + 3600,
'/'
);
return redirect()->to('/login');
} }
// Charger utilisateur
$user = $db->table('usa_usagers') $user = $db->table('usa_usagers')
->where('usa_id', $row->user_id) ->where('usa_id', $row->user_id)
->get() ->get()
->getRow(); ->getRow();
if (!$user) { if (! $user) {
session()->destroy(); session()->destroy();
return redirect()->to('/login'); return $this->deny($request);
} }
// Si session CI4 inexistante → la créer if (! session()->get('usa_id')) {
if (!session()->get('usa_id')) {
session()->set([ session()->set([
'usa_id' => $user->usa_id, 'usa_id' => $user->usa_id,
'usa_nom' => $user->usa_nom 'usa_nom' => $user->usa_nom,
]); ]);
} }
} }
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
{ {
} }
/**
* Fetch/JSON → 401 JSON (évite Unexpected token '<' sur du HTML login).
* Navigation normale → redirect login.
*/
private function deny(RequestInterface $request)
{
$wantsJson = $request->isAJAX()
|| $request->getGet('json') !== null
|| str_contains((string) $request->getHeaderLine('Accept'), 'application/json');
if ($wantsJson) {
return service('response')
->setStatusCode(401)
->setJSON(['ok' => false, 'error' => 'non authentifié']);
}
return redirect()->to($this->loginUrl());
}
private function loginUrl(): string
{
$https = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
return ($https ? 'https' : 'http') . '://' . $host . '/login';
}
} }

View File

@ -0,0 +1,29 @@
<?php
namespace App\Libraries\Ai;
/**
* Contrat stable CRM — les modules métier ne voient que ça.
*/
interface AiClient
{
public function provider(): string;
public function model(): string;
public function isConfigured(): bool;
/**
* Complétion texte simple (prompt utilisateur).
*
* @param array{system?:string, temperature?:float, maxOutputTokens?:int} $options
*/
public function complete(string $prompt, array $options = []): AiResponse;
/**
* Modèles supportant generateContent (ops / diagnostic).
*
* @return list<string>
*/
public function listModels(): array;
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Libraries\Ai;
/**
* Erreur IA plateforme (fournisseur / config / réseau).
*/
class AiException extends \RuntimeException
{
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Libraries\Ai;
use App\Libraries\Ai\Providers\GeminiProvider;
use Config\Ai as AiConfig;
/**
* Point dentrée CRM : un seul endroit pour obtenir le client IA actif.
*/
final class AiFactory
{
public static function client(?AiConfig $config = null): AiClient
{
$config ??= config(AiConfig::class);
return match ($config->provider) {
'gemini' => new GeminiProvider($config),
default => throw new AiException(
'Provider IA inconnu: ' . $config->provider . ' (attendus: gemini)'
),
};
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Libraries\Ai;
/**
* HTTP minimal pour les providers IA (curl, fallback stream).
* Indépendant du service account Gmail.
*/
final class AiHttp
{
/**
* @param list<string> $headers
* @return array{0:int,1:string}
*/
public function request(string $method, string $url, array $headers = [], ?string $body = null, int $timeout = 90): array
{
if (\function_exists('curl_init')) {
return $this->viaCurl($method, $url, $headers, $body, $timeout);
}
return $this->viaStream($method, $url, $headers, $body, $timeout);
}
/**
* @param list<string> $headers
* @return array{0:int,1:string}
*/
private function viaCurl(string $method, string $url, array $headers, ?string $body, int $timeout): array
{
$ch = \curl_init($url);
$opts = [
\CURLOPT_RETURNTRANSFER => true,
\CURLOPT_CUSTOMREQUEST => \strtoupper($method),
\CURLOPT_CONNECTTIMEOUT => 10,
\CURLOPT_TIMEOUT => $timeout,
\CURLOPT_HTTPHEADER => $headers,
// cPanel : IPv6 cassé → hang 90s / 0 bytes. Forcer IPv4.
\CURLOPT_IPRESOLVE => \CURL_IPRESOLVE_V4,
];
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 AiException(
'Serveur → Gemini timeout/réseau (' . $err . '). '
. 'Souvent IPv6 ou firewall sortant vers generativelanguage.googleapis.com.'
);
}
\curl_close($ch);
return [$code, $res];
}
/**
* @param list<string> $headers
* @return array{0:int,1:string}
*/
private function viaStream(string $method, string $url, array $headers, ?string $body, int $timeout): array
{
$opts = [
'http' => [
'method' => \strtoupper($method),
'header' => \implode("\r\n", $headers),
'timeout' => $timeout,
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
];
if ($body !== null) {
$opts['http']['content'] = $body;
}
$ctx = \stream_context_create($opts);
$res = @\file_get_contents($url, false, $ctx);
$code = 0;
if (isset($http_response_header) && \is_array($http_response_header)) {
foreach ($http_response_header as $line) {
if (\preg_match('#^HTTP/\S+\s+(\d+)#', $line, $m)) {
$code = (int) $m[1];
break;
}
}
}
if ($res === false) {
throw new AiException('AI HTTP stream error for ' . $url);
}
return [$code, $res];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Libraries\Ai;
/**
* Réponse normalisée — indépendante du fournisseur.
*/
final class AiResponse
{
public function __construct(
public readonly string $text,
public readonly string $provider,
public readonly string $model,
public readonly ?string $raw = null,
) {
}
}

View File

@ -0,0 +1,161 @@
<?php
namespace App\Libraries\Ai\Providers;
use App\Libraries\Ai\AiClient;
use App\Libraries\Ai\AiException;
use App\Libraries\Ai\AiHttp;
use App\Libraries\Ai\AiResponse;
use Config\Ai as AiConfig;
/**
* Adaptateur Gemini (Generative Language API).
* Seul endroit du CRM qui parle lAPI Google Generative.
*/
final class GeminiProvider implements AiClient
{
private AiHttp $http;
public function __construct(
private AiConfig $config,
?AiHttp $http = null,
) {
$this->http = $http ?? new AiHttp();
}
public function provider(): string
{
return 'gemini';
}
public function model(): string
{
return $this->config->model;
}
public function isConfigured(): bool
{
return $this->apiKey() !== '';
}
public function complete(string $prompt, array $options = []): AiResponse
{
$key = $this->apiKey();
if ($key === '') {
throw new AiException('Clé Gemini absente — application/credentials/gemini_api_key.txt');
}
$model = $options['model'] ?? $this->config->model;
$url = \rtrim($this->config->geminiBaseUrl, '/')
. '/models/' . \rawurlencode($model)
. ':generateContent?key=' . \rawurlencode($key);
$contents = [];
if (! empty($options['system'])) {
// systemInstruction (API v1beta)
$payload = [
'systemInstruction' => [
'parts' => [['text' => (string) $options['system']]],
],
'contents' => [
['role' => 'user', 'parts' => [['text' => $prompt]]],
],
];
} else {
$payload = [
'contents' => [
['role' => 'user', 'parts' => [['text' => $prompt]]],
],
];
}
$payload['generationConfig'] = [
'temperature' => $options['temperature'] ?? $this->config->temperature,
'maxOutputTokens' => $options['maxOutputTokens'] ?? $this->config->maxOutputTokens,
];
$json = \json_encode($payload, \JSON_UNESCAPED_UNICODE);
[$code, $body] = $this->http->request(
'POST',
$url,
['Content-Type: application/json'],
$json ?: '{}',
$this->config->timeoutSeconds
);
if ($code !== 200) {
throw new AiException($this->shortError($code, $body));
}
$data = \json_decode($body, true);
$text = '';
foreach ($data['candidates'][0]['content']['parts'] ?? [] as $part) {
if (! empty($part['text'])) {
$text .= $part['text'];
}
}
$text = \trim($text);
if ($text === '') {
throw new AiException('Gemini: réponse vide (model=' . $model . ')');
}
return new AiResponse($text, $this->provider(), $model, $body);
}
public function listModels(): array
{
$key = $this->apiKey();
if ($key === '') {
return [];
}
$url = \rtrim($this->config->geminiBaseUrl, '/')
. '/models?key=' . \rawurlencode($key) . '&pageSize=100';
[$code, $body] = $this->http->request('GET', $url, [], null, 60);
if ($code !== 200) {
throw new AiException($this->shortError($code, $body));
}
$data = \json_decode($body, true) ?: [];
$out = [];
foreach ($data['models'] ?? [] as $m) {
$methods = $m['supportedGenerationMethods'] ?? [];
if (! \in_array('generateContent', $methods, true)) {
continue;
}
$name = (string) ($m['name'] ?? '');
if (\str_starts_with($name, 'models/')) {
$name = \substr($name, 7);
}
if ($name !== '') {
$out[] = $name;
}
}
\sort($out);
return $out;
}
private function apiKey(): string
{
$path = \realpath(ROOTPATH . $this->config->geminiApiKeyFile)
?: (ROOTPATH . $this->config->geminiApiKeyFile);
if (! \is_file($path)) {
return '';
}
return \trim((string) \file_get_contents($path));
}
private function shortError(int $code, string $body): string
{
$msg = $body;
$j = \json_decode($body, true);
if (\is_array($j) && ! empty($j['error']['message'])) {
$msg = (string) $j['error']['message'];
}
return 'Gemini HTTP ' . $code . ': ' . \mb_substr($msg, 0, 240);
}
}

View File

@ -0,0 +1,299 @@
<?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 = [];
foreach ($data['messages'] ?? [] as $m) {
if (! empty($m['id'])) {
$ids[] = (string) $m['id'];
}
}
if ($ids === []) {
return [];
}
$details = $this->getMessagesMetaBatch($access, $ids);
$byThread = [];
foreach ($details as $detail) {
$tid = $detail['gmail_thread_id'];
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;
}
/**
* Batch Gmail (1 requête HTTP) au lieu de N gets séquentiels qui bloquent la page.
*
* @param list<string> $messageIds
* @return list<array<string, mixed>>
*/
private function getMessagesMetaBatch(string $access, array $messageIds): array
{
$boundary = 'batch_radar_' . \bin2hex(\random_bytes(6));
$parts = '';
foreach ($messageIds as $i => $messageId) {
$path = '/gmail/v1/users/me/messages/' . \rawurlencode($messageId)
. '?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date';
$parts .= '--' . $boundary . "\r\n"
. "Content-Type: application/http\r\n"
. 'Content-ID: <item' . $i . ">\r\n\r\n"
. 'GET ' . $path . " HTTP/1.1\r\n\r\n";
}
$parts .= '--' . $boundary . "--\r\n";
[$code, $body] = $this->tokenClient->http(
'POST',
'https://gmail.googleapis.com/batch/gmail/v1',
[
'Authorization: Bearer ' . $access,
'Content-Type: multipart/mixed; boundary=' . $boundary,
],
$parts
);
if ($code !== 200) {
// Fallback séquentiel limité (ne pas bloquer 40×60s)
$out = [];
foreach (\array_slice($messageIds, 0, 8) as $messageId) {
$detail = $this->getMessageMeta($access, $messageId);
if ($detail !== null) {
$out[] = $detail;
}
}
return $out;
}
return $this->parseBatchMessageMetas($body);
}
/**
* @return list<array<string, mixed>>
*/
private function parseBatchMessageMetas(string $batchBody): array
{
$out = [];
// Chaque sous-réponse JSON message Gmail
if (\preg_match_all('/\{[\s\S]*?"id"\s*:\s*"[^"]+"[\s\S]*?\}/m', $batchBody, $matches)) {
foreach ($matches[0] as $json) {
$msg = \json_decode($json, true);
if (! \is_array($msg) || empty($msg['id'])) {
continue;
}
$parsed = $this->messageArrayToMeta($msg, (string) $msg['id']);
if ($parsed !== null) {
$out[] = $parsed;
}
}
}
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;
}
return $this->messageArrayToMeta($msg, $messageId);
}
/**
* @param array<string, mixed> $msg
* @return ?array<string, mixed>
*/
private function messageArrayToMeta(array $msg, string $messageId): ?array
{
$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_message_id' => $messageId,
'gmail_thread_id' => (string) ($msg['threadId'] ?? $messageId),
'subject' => self::decodeText((string) ($headers['subject'] ?? '(sans sujet)')),
'from_email' => $from['email'],
'from_name' => self::decodeText($from['name']),
'snippet' => self::decodeText((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,
];
}
/**
* Corps texte du message (pour analyse IA). format=full.
*/
public function getPlainBody(string $mailbox, string $messageId): string
{
$access = $this->tokenClient->getAccessToken($mailbox, $this->scopes);
$url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/' . \rawurlencode($messageId)
. '?format=full';
[$code, $body] = $this->tokenClient->http(
'GET',
$url,
['Authorization: Bearer ' . $access]
);
if ($code !== 200) {
throw new \RuntimeException("Gmail messages.get ({$code}): {$body}");
}
$msg = \json_decode($body, true);
if (! \is_array($msg)) {
return '';
}
$text = $this->extractPlainFromPayload($msg['payload'] ?? []);
if ($text === '' && ! empty($msg['snippet'])) {
$text = (string) $msg['snippet'];
}
return self::decodeText(\mb_substr($text, 0, 12000));
}
/**
* @param array<string, mixed> $payload
*/
private function extractPlainFromPayload(array $payload): string
{
$mime = \strtolower((string) ($payload['mimeType'] ?? ''));
if ($mime === 'text/plain' && ! empty($payload['body']['data'])) {
return $this->b64urlDecode((string) $payload['body']['data']);
}
foreach ($payload['parts'] ?? [] as $part) {
if (! \is_array($part)) {
continue;
}
$found = $this->extractPlainFromPayload($part);
if ($found !== '') {
return $found;
}
}
if ($mime === 'text/html' && ! empty($payload['body']['data'])) {
$html = $this->b64urlDecode((string) $payload['body']['data']);
return \trim(\html_entity_decode(\strip_tags($html), \ENT_QUOTES | \ENT_HTML5, 'UTF-8'));
}
return '';
}
private function b64urlDecode(string $data): string
{
$raw = \strtr($data, '-_', '+/');
$pad = \strlen($raw) % 4;
if ($pad > 0) {
$raw .= \str_repeat('=', 4 - $pad);
}
$out = \base64_decode($raw, true);
return $out === false ? '' : $out;
}
/** Gmail renvoie souvent des entités HTML dans snippet / headers. */
public static function decodeText(string $text): string
{
if ($text === '') {
return '';
}
return \html_entity_decode($text, \ENT_QUOTES | \ENT_HTML5, 'UTF-8');
}
/**
* @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' => ''];
}
}

View File

@ -0,0 +1,174 @@
<?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 daccess_token dans la réponse Google');
}
return $tok['access_token'];
}
/**
* HTTP sans dépendre de lext curl (souvent absente en cPanel).
*
* @param list<string> $headers
* @return array{0:int,1:string}
*/
public function http(string $method, string $url, array $headers = [], ?string $body = null): array
{
if (\function_exists('curl_init')) {
return $this->httpCurl($method, $url, $headers, $body);
}
return $this->httpStream($method, $url, $headers, $body);
}
/**
* @param list<string> $headers
* @return array{0:int,1:string}
*/
private function httpCurl(string $method, string $url, array $headers, ?string $body): array
{
$ch = \curl_init($url);
$opts = [
\CURLOPT_RETURNTRANSFER => true,
\CURLOPT_CUSTOMREQUEST => $method,
\CURLOPT_CONNECTTIMEOUT => 8,
\CURLOPT_TIMEOUT => 25,
\CURLOPT_HTTPHEADER => $headers,
// cPanel : IPv6 cassé → hang / 0 bytes. Forcer IPv4 (comme AiHttp).
\CURLOPT_IPRESOLVE => \CURL_IPRESOLVE_V4,
];
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];
}
/**
* @param list<string> $headers
* @return array{0:int,1:string}
*/
private function httpStream(string $method, string $url, array $headers, ?string $body): array
{
$headerLines = $headers;
if ($body !== null && $body !== '') {
$hasContentType = false;
foreach ($headerLines as $h) {
if (\stripos($h, 'Content-Type:') === 0) {
$hasContentType = true;
break;
}
}
if (! $hasContentType) {
$headerLines[] = 'Content-Type: application/x-www-form-urlencoded';
}
}
$opts = [
'http' => [
'method' => \strtoupper($method),
'header' => \implode("\r\n", $headerLines),
'timeout' => 60,
'ignore_errors' => true,
],
'ssl' => [
'verify_peer' => true,
'verify_peer_name' => true,
],
];
if ($body !== null) {
$opts['http']['content'] = $body;
}
$ctx = \stream_context_create($opts);
$res = @\file_get_contents($url, false, $ctx);
$code = 0;
if (isset($http_response_header) && \is_array($http_response_header)) {
foreach ($http_response_header as $line) {
if (\preg_match('#^HTTP/\S+\s+(\d+)#', $line, $m)) {
$code = (int) $m[1];
break;
}
}
}
if ($res === false) {
throw new \RuntimeException('HTTP stream error for ' . $url);
}
return [$code, $res];
}
private function b64url(string $data): string
{
return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '=');
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace App\Libraries\Radar;
use App\Libraries\Ai\AiClient;
use App\Libraries\Ai\AiException;
use App\Libraries\Ai\AiFactory;
/**
* MSOP-3 — analyse métier dossards + delta.
* Utilise la couche IA plateforme (AiClient), pas un vendor direct.
*/
class RadarAnalyzeService
{
public function __construct(private ?AiClient $ai = null)
{
$this->ai ??= AiFactory::client();
}
public function isEnabled(): bool
{
return $this->ai->isConfigured();
}
/**
* @param list<array{subject:string,summary:string,analysis:?string,received_at:?string}> $priors
* @return array{analysis:string, delta:string, confidence:int}
*/
public function analyzeDossards(
string $subject,
string $fromEmail,
string $body,
array $priors,
): array {
$hist = '';
foreach ($priors as $i => $p) {
$n = $i + 1;
$hist .= "--- Mail dossards #{$n} ({$p['received_at']}) ---\n"
. 'Sujet: ' . ($p['subject'] ?? '') . "\n"
. 'Analyse/snippet: ' . (($p['analysis'] ?: $p['summary']) ?? '') . "\n\n";
}
$isFirst = $priors === [];
$system = <<<'SYS'
Tu es l'assistant Radar du CRM MS1 Timing (chronométrage / dossards).
Tu SIGNALES seulement. Tu n'APPROUVES JAMAIS une impression ni un Go.
Réponds UNIQUEMENT en JSON valide UTF-8, sans markdown autour, schéma :
{"analysis":"string","delta":"string","confidence":0}
SYS;
$prompt = <<<PROMPT
- analysis : analyse métier dossards courte (FR), points utiles : logos, séquences, imprimeur, délais, fichiers, questions ouvertes, signaux faibles vs forts. Pas de copier-coller du mail. Max ~1200 caractères.
- delta : si 1er mail → chaîne vide "". Sinon : ce qui change / stable / nouveau / contradictoire vs l'historique. Max ~800 caractères.
- confidence : entier 0-100 (fiabilité de ton analyse, pas un feu vert).
Contexte :
Expéditeur : {$fromEmail}
Sujet : {$subject}
Premier mail dossards pour cet expéditeur : {$this->boolFr($isFirst)}
Historique dossards déjà en Radar (du plus ancien au plus récent) :
{$hist}
Mail courant (corps) :
{$body}
PROMPT;
try {
$out = $this->ai->complete($prompt, ['system' => $system]);
} catch (AiException $e) {
throw new \RuntimeException($e->getMessage(), (int) $e->getCode(), $e);
}
$text = $out->text;
if (\preg_match('/\{.*\}/s', $text, $m)) {
$text = $m[0];
}
$json = \json_decode($text, true);
if (! \is_array($json)) {
return [
'analysis' => \mb_substr($out->text, 0, 2000),
'delta' => $isFirst ? '' : 'Delta non structuré — voir analyse.',
'confidence' => 40,
];
}
$analysis = \trim((string) ($json['analysis'] ?? ''));
$delta = \trim((string) ($json['delta'] ?? ''));
$conf = (int) ($json['confidence'] ?? 50);
$conf = \max(0, \min(100, $conf));
if ($isFirst) {
$delta = '';
}
return [
'analysis' => \mb_substr($analysis !== '' ? $analysis : $out->text, 0, 4000),
'delta' => \mb_substr($delta, 0, 2000),
'confidence' => $conf,
];
}
private function boolFr(bool $v): string
{
return $v ? 'oui' : 'non';
}
}

View File

@ -0,0 +1,257 @@
<?php
namespace App\Libraries\Radar;
use App\Libraries\Ai\AiFactory;
use App\Models\RadarModel;
use Config\Radar as RadarConfig;
/**
* MSOP-4 — sync Gmail allowlist → radar_threads.
* MSOP-3 — analyse IA dossards via couche Ai (appel séparé, pas dans le Sync HTTP).
*/
class RadarSyncService
{
public function __construct(
private ?RadarConfig $config = null,
private ?RadarModel $model = null,
) {
$this->config ??= config(RadarConfig::class);
$this->model ??= new RadarModel();
}
/**
* Lecture Gmail uniquement (rapide). Pas dappel LLM ici.
*
* @return array{ok:bool, mailboxes:list<array<string,mixed>>, error?:string}
*/
public function sync(): array
{
@\set_time_limit(90);
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']);
$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, 1000),
'status' => 'a_classer',
'confidence' => $skills === [] ? 20 : 45,
'is_example' => 0,
'received_at' => $t['received_at'],
'proposed_client_no' => '',
'gmail_message_id' => $t['gmail_message_id'] ?? '',
], $skills);
// Stocker lid message pour lanalyse IA ultérieure
if (! empty($t['gmail_message_id'])) {
$this->model->saveGmailMessageId($n['id'], (string) $t['gmail_message_id']);
}
if ($n['op'] === '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,
];
}
/**
* MSOP-3 — analyse les fils dossards sans analysis (batch limité).
*
* @return array{ok:bool, ran:int, pending:int, errors:int, model:string, note:string, error?:string}
*/
public function analyzePending(): array
{
if (! $this->model->tablesExist()) {
return [
'ok' => false,
'ran' => 0,
'pending' => 0,
'errors' => 0,
'model' => '',
'note' => '',
'error' => 'Tables radar_* absentes',
];
}
if (! $this->model->hasAnalysisColumns()) {
return [
'ok' => false,
'ran' => 0,
'pending' => 0,
'errors' => 0,
'model' => '',
'note' => '',
'error' => 'Colonnes analysis absentes — sql/MSOP-3-radar-analysis.sql',
];
}
try {
$ai = AiFactory::client();
} catch (\Throwable $e) {
return [
'ok' => false,
'ran' => 0,
'pending' => 0,
'errors' => 0,
'model' => '',
'note' => '',
'error' => $e->getMessage(),
];
}
if (! $ai->isConfigured()) {
return [
'ok' => false,
'ran' => 0,
'pending' => 0,
'errors' => 0,
'model' => '',
'note' => '',
'error' => 'IA non configurée (clé absente)',
];
}
@\set_time_limit(300);
$jsonPath = \realpath(ROOTPATH . $this->config->serviceAccountJson)
?: (ROOTPATH . $this->config->serviceAccountJson);
$tokenClient = new GoogleWorkspaceToken($jsonPath);
$reader = new GmailReader($tokenClient, $this->config->gmailScope);
$analyzer = new RadarAnalyzeService($ai);
$budget = $this->config->maxAiAnalysesPerSync;
$pending = $this->model->listThreadsNeedingDossardAnalysis($budget + 20);
$ran = 0;
$errors = 0;
$note = '';
foreach ($pending as $row) {
if ($ran + $errors >= $budget) {
break;
}
$msgId = (string) ($row['gmail_message_id'] ?? '');
if ($msgId === '') {
// Pas did message stocké : on ne peut pas relire le corps ; skip propre.
$errors++;
$note = 'gmail_message_id manquant — re-Sync Gmail puis Analyser';
continue;
}
try {
$body = $reader->getPlainBody((string) $row['mailbox'], $msgId);
$priors = $this->model->listPriorDossardThreads(
(string) $row['from_email'],
(string) $row['gmail_thread_id']
);
$aiResult = $analyzer->analyzeDossards(
(string) $row['subject'],
(string) $row['from_email'],
$body !== '' ? $body : (string) $row['summary'],
$priors
);
$this->model->saveAnalysis(
(int) $row['id'],
$aiResult['analysis'],
$aiResult['delta'],
$aiResult['confidence']
);
$ran++;
} catch (\Throwable $e) {
$errors++;
$note = $e->getMessage();
}
}
$still = $this->model->countThreadsNeedingDossardAnalysis();
return [
'ok' => $errors === 0 || $ran > 0,
'ran' => $ran,
'pending' => $still,
'errors' => $errors,
'model' => $ai->model(),
'note' => $note,
];
}
/**
* @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;
}
}

View File

@ -0,0 +1,272 @@
<?php
namespace App\Models;
use Config\Database;
/**
* IDEE-5 / MSOP-4 / MSOP-3 — radar_threads + skills + analyse IA.
*/
class RadarModel
{
private function db()
{
return Database::connect();
}
public function tablesExist(): bool
{
$row = $this->db()->query("SHOW TABLES LIKE 'radar_threads'")->getRowArray();
return ! empty($row);
}
public function hasAnalysisColumns(): bool
{
static $cached = null;
if ($cached !== null) {
return $cached;
}
$row = $this->db()->query("SHOW COLUMNS FROM radar_threads LIKE 'analysis'")->getRowArray();
$cached = ! empty($row);
return $cached;
}
public function hasGmailMessageIdColumn(): bool
{
static $cached = null;
if ($cached !== null) {
return $cached;
}
$row = $this->db()->query("SHOW COLUMNS FROM radar_threads LIKE 'gmail_message_id'")->getRowArray();
$cached = ! empty($row);
return $cached;
}
public function saveGmailMessageId(int $threadId, string $messageId): void
{
if (! $this->hasGmailMessageIdColumn() || $messageId === '') {
return;
}
$this->db()->table('radar_threads')->where('id', $threadId)->update([
'gmail_message_id' => \mb_substr($messageId, 0, 64),
]);
}
/**
* Fils dossards sans analyse IA, du plus récent au plus ancien.
*
* @return list<array<string, mixed>>
*/
public function listThreadsNeedingDossardAnalysis(int $limit = 20): array
{
if (! $this->hasAnalysisColumns()) {
return [];
}
$msgCol = $this->hasGmailMessageIdColumn() ? ', t.gmail_message_id' : ', \'\' AS gmail_message_id';
$sql = 'SELECT t.id, t.gmail_thread_id, t.mailbox, t.subject, t.from_email, t.summary'
. $msgCol
. ' FROM radar_threads t
INNER JOIN radar_thread_skills s ON s.thread_id = t.id AND s.skill = ?
WHERE t.is_example = 0
AND (t.analysis IS NULL OR t.analysis = \'\')
ORDER BY t.received_at DESC, t.id DESC
LIMIT ' . (int) $limit;
return $this->db()->query($sql, ['dossards'])->getResultArray();
}
public function countThreadsNeedingDossardAnalysis(): int
{
if (! $this->hasAnalysisColumns()) {
return 0;
}
$sql = 'SELECT COUNT(*) AS c FROM radar_threads t
INNER JOIN radar_thread_skills s ON s.thread_id = t.id AND s.skill = ?
WHERE t.is_example = 0
AND (t.analysis IS NULL OR t.analysis = \'\')';
$row = $this->db()->query($sql, ['dossards'])->getRowArray();
return (int) ($row['c'] ?? 0);
}
public function listExceptions(): array
{
return $this->db()->table('radar_exceptions')
->where('active', 1)
->orderBy('kind', 'ASC')
->orderBy('value', 'ASC')
->get()
->getResultArray();
}
public function listThreads(): array
{
$rows = $this->db()->table('radar_threads')
->orderBy('received_at', 'DESC')
->orderBy('id', 'DESC')
->get()
->getResultArray();
if ($rows === []) {
return [];
}
$ids = array_column($rows, 'id');
$skillRows = $this->db()->table('radar_thread_skills')
->whereIn('thread_id', $ids)
->get()
->getResultArray();
$byThread = [];
foreach ($skillRows as $s) {
$byThread[(int) $s['thread_id']][] = $s['skill'];
}
foreach ($rows as &$row) {
$id = (int) $row['id'];
$row['skills'] = $byThread[$id] ?? [];
$row['client_url'] = ! empty($row['client_id'])
? '/index.php/clients/form/' . (int) $row['client_id']
: '';
$row['projet_url'] = ! empty($row['projet_id'])
? '/index.php/projets/form/' . (int) $row['projet_id']
: '';
}
unset($row);
return $rows;
}
/**
* Historique dossards pour le même expéditeur (hors fil courant), du plus ancien au plus récent.
*
* @return list<array<string, mixed>>
*/
public function listPriorDossardThreads(string $fromEmail, string $excludeGmailThreadId, int $limit = 5): array
{
$fromEmail = \strtolower(\trim($fromEmail));
if ($fromEmail === '') {
return [];
}
$sql = 'SELECT t.subject, t.summary, t.received_at'
. ($this->hasAnalysisColumns() ? ', t.analysis' : ', NULL AS analysis')
. ' FROM radar_threads t
INNER JOIN radar_thread_skills s ON s.thread_id = t.id AND s.skill = ?
WHERE t.from_email = ?
AND t.gmail_thread_id <> ?
AND t.is_example = 0
ORDER BY t.received_at ASC, t.id ASC
LIMIT ' . (int) $limit;
return $this->db()->query($sql, ['dossards', $fromEmail, $excludeGmailThreadId])->getResultArray();
}
/**
* @param array<string, mixed> $data
* @param list<string> $skills
* @return array{op:string,id:int}
*/
public function upsertThread(array $data, array $skills = []): array
{
$db = $this->db();
$existing = $db->table('radar_threads')
->where('gmail_thread_id', $data['gmail_thread_id'])
->get()
->getRowArray();
if ($existing) {
$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,
];
// Ne pas écraser une analyse IA déjà présente avec le seul snippet.
if ($this->hasAnalysisColumns() && ! empty($existing['analysis'])) {
unset($update['confidence']);
}
$db->table('radar_threads')->where('id', $existing['id'])->update($update);
$threadId = (int) $existing['id'];
$op = 'update';
} else {
$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'] ?? '',
];
$db->table('radar_threads')->insert($insert);
$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' => $op, 'id' => $threadId];
}
public function saveAnalysis(int $threadId, string $analysis, string $delta, int $confidence): void
{
if (! $this->hasAnalysisColumns()) {
// Fallback : ranger lanalyse dans summary si colonnes absentes.
$this->db()->table('radar_threads')->where('id', $threadId)->update([
'summary' => \mb_substr($analysis, 0, 1000),
'confidence' => $confidence,
]);
return;
}
$this->db()->table('radar_threads')->where('id', $threadId)->update([
'analysis' => $analysis,
'analysis_delta' => $delta,
'confidence' => $confidence,
]);
}
public function needsAnalysis(int $threadId): bool
{
if (! $this->hasAnalysisColumns()) {
// Sans sql/MSOP-3-radar-analysis.sql on ne lance pas lIA en boucle.
return false;
}
$row = $this->db()->table('radar_threads')->select('analysis')->where('id', $threadId)->get()->getRowArray();
return empty($row['analysis']);
}
}

View File

@ -168,6 +168,11 @@ class DynamicForms extends Controller
} }
// ================================================= // =================================================
// ================================================= // =================================================
// LISTES ENFANTS (quand on est sur le parent) // LISTES ENFANTS (quand on est sur le parent)
// ================================================= // =================================================

View File

@ -64,7 +64,7 @@
<script src="<?= base_url('v4/assets/raven/vendor/js/quill.js') ?>"></script> <script src="<?= base_url('v4/assets/raven/vendor/js/quill.js') ?>"></script>
<script src="<?= base_url('v4/assets/raven/js/theme.js') ?>"></script> <script src="<?= base_url('v4/assets/raven/js/theme.js') ?>"></script>
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<script>lucide.createIcons();</script> <script>if (window.lucide && typeof lucide.createIcons === 'function') { lucide.createIcons(); }</script>

View File

@ -20,6 +20,12 @@
<div class="flex flex-col h-full overflow-y-auto" data-simplebar> <div class="flex flex-col h-full overflow-y-auto" data-simplebar>
<div class="flex flex-col py-3 px-3"> <div class="flex flex-col py-3 px-3">
<nav class="flex flex-col space-y-0.5" id="SidebarCollapseGroup"> <nav class="flex flex-col space-y-0.5" id="SidebarCollapseGroup">
<a href="/v4/radar" class="sidebar-link">
<span class="sidebar-icon">
<span class="icon-[lucide--mail]"></span>
</span>
<span class="sidebar-text">Radar mail</span>
</a>
<a href="/v4/dynamic/list/inventaire" class="sidebar-link "> <a href="/v4/dynamic/list/inventaire" class="sidebar-link ">
<span class="sidebar-icon"> <span class="sidebar-icon">
<span class="icon-[lucide--file-chart-column]"></span> <span class="icon-[lucide--file-chart-column]"></span>

View File

@ -0,0 +1,362 @@
<?php
$title = 'Radar mail';
$menu = 'menus/men_list';
$header = 'headers/hea_default';
$this->setVar('menu', $menu);
$this->setVar('header', $header);
$this->setVar('title', $title);
$ready = $ready ?? false;
$exceptions = $exceptions ?? [];
$threads = $threads ?? [];
$feedUrl = $feedUrl ?? site_url('v4/radar/feed');
$syncUrl = $syncUrl ?? site_url('v4/radar/sync');
$analyzeUrl = $analyzeUrl ?? site_url('v4/radar/analyze');
$pendingAi = (int) ($pendingAi ?? 0);
$allowlist = $allowlist ?? [];
$syncMsg = $syncMsg ?? session()->getFlashdata('radar_sync_msg');
/** Texte Gmail / BD : décoder entités puis échapper pour HTML. */
$plain = static function (?string $s): string {
if ($s === null || $s === '') {
return '';
}
return \html_entity_decode($s, \ENT_QUOTES | \ENT_HTML5, 'UTF-8');
};
$statusLabel = static function (string $status): string {
return match ($status) {
'classe' => 'Classé',
'a_classer' => 'À classer',
'a_mettre_dans_crm' => 'À mettre dans le CRM',
'ignore' => 'Ignoré',
default => $status,
};
};
$statusClass = static function (string $status): string {
// Classes présentes dans assets/raven/css/style.css (pas indigo-* purgé).
return match ($status) {
'classe' => 'bg-primary/10 text-primary',
'a_mettre_dans_crm' => 'bg-amber-500/20 text-zinc-900 dark:text-zinc-100',
'ignore' => 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
default => 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',
};
};
?>
<?= $this->extend('layouts/default') ?>
<?= $this->section('content') ?>
<div class="space-y-6">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<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">
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="flex flex-col items-end gap-2">
<?php if ($ready): ?>
<div class="flex flex-wrap justify-end gap-2">
<a href="<?= esc($syncUrl) ?>" class="btn bg-primary text-white" id="radar-sync-btn">
Sync Gmail
</a>
<button type="button" class="btn btn-default" id="radar-analyze-btn"
data-url="<?= esc($analyzeUrl) ?>?json=1"
<?= $pendingAi < 1 ? 'disabled' : '' ?>>
Analyser dossards<?= $pendingAi > 0 ? ' (' . $pendingAi . ')' : '' ?>
</button>
</div>
<?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 id="radar-status" class="rounded-xl border border-zinc-300 bg-amber-500/20 px-4 py-3 text-sm font-medium text-zinc-900 dark:border-zinc-600 dark:text-zinc-100">
<?= esc($syncMsg) ?>
</div>
<?php else: ?>
<div id="radar-status" class="hidden rounded-xl border border-zinc-300 bg-amber-500/20 px-4 py-3 text-sm font-medium text-zinc-900 dark:border-zinc-600 dark:text-zinc-100"></div>
<?php endif; ?>
<?php if (!$ready): ?>
<div class="rounded-xl border border-zinc-200 bg-zinc-100 px-4 py-3 text-sm text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-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-4 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
<p class="m-0">
<strong>Sync Gmail</strong> = lecture rapide.
<strong>Analyser dossards</strong> = IA (séparé, ne bloque pas le Sync).
Mailbox : <span class="font-mono text-xs"><?= esc(implode(', ', $allowlist)) ?></span>.
</p>
</div>
<?php endif; ?>
<div class="grid gap-4 lg:grid-cols-3">
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900 lg:col-span-1">
<h2 class="text-sm font-semibold text-zinc-900 dark:text-white">Exceptions</h2>
<p class="mt-1 text-xs text-zinc-500">On exclut, on ne tient pas de liste blanche.</p>
<ul class="mt-3 space-y-2 text-sm" id="radar-exceptions">
<?php if ($exceptions === []): ?>
<li class="text-zinc-400">Aucune</li>
<?php else: ?>
<?php foreach ($exceptions as $ex): ?>
<li class="flex flex-col rounded-lg bg-zinc-50 px-3 py-2 dark:bg-zinc-800/60">
<span class="font-mono text-xs text-zinc-500"><?= esc($ex['kind']) ?></span>
<span class="font-medium"><?= esc($ex['value']) ?></span>
<?php if ($ex['note'] !== ''): ?>
<span class="text-xs text-zinc-500"><?= esc($ex['note']) ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
<?php endif; ?>
</ul>
</div>
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900 lg:col-span-2">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-semibold text-zinc-900 dark:text-white">Signaux</h2>
<span class="text-xs text-zinc-400" id="radar-count"><?= count($threads) ?> fil(s)</span>
</div>
<div id="radar-feed" class="space-y-3">
<?php if ($threads === []): ?>
<p class="text-sm text-zinc-400">Aucun signal pour linstant.</p>
<?php else: ?>
<?php foreach ($threads as $t): ?>
<article class="rounded-lg border border-zinc-100 p-3 dark:border-zinc-800">
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<p class="font-medium text-zinc-900 dark:text-white"><?= esc($plain($t['subject'] ?: '(sans objet)')) ?></p>
<p class="text-xs text-zinc-500">
<?= esc($t['from_name'] !== '' ? $plain($t['from_name']) . ' · ' : '') ?><?= esc($plain($t['from_email'])) ?>
· <?= esc($plain($t['mailbox'])) ?>
</p>
</div>
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium <?= $statusClass($t['status']) ?>">
<?= esc($statusLabel($t['status'])) ?>
</span>
</div>
<div class="mt-2 flex flex-wrap gap-1">
<?php foreach ($t['skills'] as $sk): ?>
<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"><?= esc($sk) ?></span>
<?php endforeach; ?>
<?php if (!empty($t['proposed_client_no'])): ?>
<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-mono text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">#<?= esc($t['proposed_client_no']) ?></span>
<?php endif; ?>
<?php if (!empty($t['is_example'])): ?>
<span class="rounded-md border border-dashed border-zinc-300 px-2 py-0.5 text-xs text-zinc-500">exemple proto</span>
<?php endif; ?>
</div>
<?php if (!empty($t['analysis'])): ?>
<div class="mt-2 rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-800 dark:bg-zinc-800/60 dark:text-zinc-200">
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Analyse IA</p>
<p class="m-0 whitespace-pre-wrap"><?= esc($plain($t['analysis'])) ?></p>
</div>
<?php if (!empty($t['analysis_delta'])): ?>
<div class="mt-2 rounded-lg border border-zinc-200 bg-amber-500/10 px-3 py-2 text-sm text-zinc-800 dark:border-zinc-700 dark:text-zinc-200">
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Delta vs mails dossards précédents</p>
<p class="m-0 whitespace-pre-wrap"><?= esc($plain($t['analysis_delta'])) ?></p>
</div>
<?php endif; ?>
<?php elseif ($t['summary'] !== ''): ?>
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400"><?= esc($plain($t['summary'])) ?></p>
<?php endif; ?>
<div class="mt-2 flex flex-wrap gap-3 text-xs">
<?php if (!empty($t['projet_url'])): ?>
<a class="font-medium text-primary hover:underline" href="<?= esc($t['projet_url']) ?>">Ouvrir le projet CRM</a>
<?php elseif ($t['status'] === 'a_mettre_dans_crm'): ?>
<a class="font-medium text-amber-700 hover:underline dark:text-amber-300" href="/index.php/clients">Créer dans le CRM, puis relancer</a>
<?php endif; ?>
<?php if (!empty($t['client_url'])): ?>
<a class="text-zinc-500 hover:underline" href="<?= esc($t['client_url']) ?>">Fiche client</a>
<?php endif; ?>
<?php if (!empty($t['gmail_url'])): ?>
<a class="text-zinc-500 hover:underline" href="<?= esc($t['gmail_url']) ?>" target="_blank" rel="noopener">Gmail</a>
<?php endif; ?>
</div>
</article>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
(function () {
const feedUrl = <?= json_encode($feedUrl) ?>;
const root = document.getElementById('radar-feed');
const clock = document.getElementById('radar-clock');
const countEl = document.getElementById('radar-count');
if (!root || !feedUrl) return;
const statusLabel = {
classe: 'Classé',
a_classer: 'À classer',
a_mettre_dans_crm: 'À mettre dans le CRM',
ignore: 'Ignoré'
};
const statusClass = {
classe: 'bg-primary/10 text-primary',
a_mettre_dans_crm: 'bg-amber-500/20 text-zinc-900 dark:text-zinc-100',
ignore: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
a_classer: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200'
};
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function plain(s) {
if (s == null || s === '') return '';
const t = document.createElement('textarea');
t.innerHTML = String(s);
return t.value;
}
function render(threads) {
if (countEl) countEl.textContent = (threads.length || 0) + ' fil(s)';
if (!threads.length) {
root.innerHTML = '<p class="text-sm text-zinc-400">Aucun signal pour linstant.</p>';
return;
}
root.innerHTML = threads.map(function (t) {
const st = t.status || 'a_classer';
const skills = (t.skills || []).map(function (sk) {
return '<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">' + esc(sk) + '</span>';
}).join('');
const num = t.proposed_client_no
? '<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-mono text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">#' + esc(t.proposed_client_no) + '</span>'
: '';
const ex = t.is_example && t.is_example !== '0'
? '<span class="rounded-md border border-dashed border-zinc-300 px-2 py-0.5 text-xs text-zinc-500">exemple proto</span>'
: '';
let links = '';
if (t.projet_url) {
links += '<a class="font-medium text-primary hover:underline" href="' + esc(t.projet_url) + '">Ouvrir le projet CRM</a>';
} else if (st === 'a_mettre_dans_crm') {
links += '<a class="font-medium text-amber-700 hover:underline dark:text-amber-300" href="/index.php/clients">Créer dans le CRM, puis relancer</a>';
}
if (t.client_url) {
links += '<a class="text-zinc-500 hover:underline" href="' + esc(t.client_url) + '">Fiche client</a>';
}
if (t.gmail_url) {
links += '<a class="text-zinc-500 hover:underline" href="' + esc(t.gmail_url) + '" target="_blank" rel="noopener">Gmail</a>';
}
let body = '';
if (t.analysis) {
body += '<div class="mt-2 rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-800 dark:bg-zinc-800/60 dark:text-zinc-200">'
+ '<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Analyse IA</p>'
+ '<p class="m-0 whitespace-pre-wrap">' + esc(plain(t.analysis)) + '</p></div>';
if (t.analysis_delta) {
body += '<div class="mt-2 rounded-lg border border-zinc-200 bg-amber-500/10 px-3 py-2 text-sm text-zinc-800 dark:border-zinc-700 dark:text-zinc-200">'
+ '<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Delta vs mails dossards précédents</p>'
+ '<p class="m-0 whitespace-pre-wrap">' + esc(plain(t.analysis_delta)) + '</p></div>';
}
} else if (t.summary) {
body += '<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">' + esc(plain(t.summary)) + '</p>';
}
return '<article class="rounded-lg border border-zinc-100 p-3 dark:border-zinc-800">'
+ '<div class="flex flex-wrap items-start justify-between gap-2"><div>'
+ '<p class="font-medium text-zinc-900 dark:text-white">' + esc(plain(t.subject) || '(sans objet)') + '</p>'
+ '<p class="text-xs text-zinc-500">' + esc((t.from_name ? plain(t.from_name) + ' · ' : '') + plain(t.from_email)) + ' · ' + esc(plain(t.mailbox)) + '</p>'
+ '</div><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium ' + (statusClass[st] || statusClass.a_classer) + '">'
+ esc(statusLabel[st] || st) + '</span></div>'
+ '<div class="mt-2 flex flex-wrap gap-1">' + skills + num + ex + '</div>'
+ body
+ '<div class="mt-2 flex flex-wrap gap-3 text-xs">' + links + '</div></article>';
}).join('');
}
let busy = false;
async function tick() {
if (busy) return;
busy = true;
try {
const ctrl = new AbortController();
const timer = setTimeout(function () { ctrl.abort(); }, 8000);
const res = await fetch(feedUrl, { credentials: 'same-origin', signal: ctrl.signal });
clearTimeout(timer);
const ct = (res.headers.get('content-type') || '');
if (!res.ok || ct.indexOf('application/json') === -1) {
if (clock) clock.textContent = 'hors ligne';
return;
}
const data = await res.json();
if (clock) clock.textContent = new Date().toLocaleTimeString('fr-CA');
if (data && data.ok) render(data.threads || []);
} catch (e) {
if (clock) clock.textContent = 'hors ligne';
} finally {
busy = false;
}
}
function setStatus(text, show) {
const el = document.getElementById('radar-status');
if (!el) return;
el.textContent = text;
if (show) el.classList.remove('hidden');
else el.classList.add('hidden');
}
const analyzeBtn = document.getElementById('radar-analyze-btn');
if (analyzeBtn) {
analyzeBtn.addEventListener('click', async function () {
const url = analyzeBtn.getAttribute('data-url');
if (!url || analyzeBtn.disabled) return;
analyzeBtn.disabled = true;
setStatus('Analyse IA en cours (1 mail, ~3060 s) — ne ferme pas la page…', true);
try {
const ctrl = new AbortController();
const timer = setTimeout(function () { ctrl.abort(); }, 120000);
const res = await fetch(url, { credentials: 'same-origin', signal: ctrl.signal });
clearTimeout(timer);
const ct = (res.headers.get('content-type') || '');
if (ct.indexOf('application/json') === -1) {
setStatus('Analyse interrompue (session / login). Recharge et reconnecte-toi.', true);
return;
}
const data = await res.json();
if (data.error) {
setStatus('Analyse IA en erreur — ' + data.error, true);
} else {
let msg = 'Analyse IA : ' + (data.ran || 0) + ' fait(s), '
+ (data.pending || 0) + ' encore en attente';
if (data.model) msg += ' [' + data.model + ']';
if (data.errors) msg += ', ' + data.errors + ' erreur(s)';
if (data.note && data.errors) msg += ' (' + String(data.note).slice(0, 120) + ')';
if ((data.pending || 0) > 0) msg += ' — recliquer Analyser pour le suivant';
setStatus(msg, true);
if (data.pending > 0) analyzeBtn.disabled = false;
analyzeBtn.textContent = 'Analyser dossards' + (data.pending > 0 ? ' (' + data.pending + ')' : '');
}
await tick();
} catch (e) {
setStatus('Analyse IA : délai dépassé ou réseau. Reessaie une fois.', true);
analyzeBtn.disabled = false;
}
});
}
tick();
setInterval(tick, 15000);
})();
</script>
<?= $this->endSection() ?>

30
v4_ci4/spark Normal file
View File

@ -0,0 +1,30 @@
#!/usr/bin/env php
<?php
/**
* CodeIgniter CLI — CRM MS1 (layout: public = ../v4/, app = ./app/)
*
* Usage (sur le serveur) :
* cd ~/public_html/v4_ci4 && php spark radar:ai-ping
*/
use CodeIgniter\Boot;
use Config\Paths;
$minPhpVersion = '8.2';
if (version_compare(PHP_VERSION, $minPhpVersion, '<')) {
fwrite(STDERR, "PHP {$minPhpVersion}+ requis. Actuel: " . PHP_VERSION . PHP_EOL);
exit(1);
}
// Front controller web = dossier v4/
define('FCPATH', realpath(__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'v4') . DIRECTORY_SEPARATOR);
chdir(FCPATH);
require __DIR__ . '/app/Config/Paths.php';
$paths = new Paths();
require $paths->systemDirectory . '/Boot.php';
exit(Boot::bootSpark($paths));