Files

1636 lines
79 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/../src/bootstrap.php';
demarrer_session();
$page = (string) ($_GET['p'] ?? 'kanban');
$post = $_SERVER['REQUEST_METHOD'] === 'POST';
if ($post) {
verifier_csrf();
}
$aucunCompte = static fn (): bool => (int) db()->query('SELECT COUNT(*) FROM utilisateurs')->fetchColumn() === 0;
function reponse_json(array $donnees, int $code = 200): never
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($donnees, JSON_UNESCAPED_UNICODE);
exit;
}
function vider_import_en_attente(): void
{
$imp = $_SESSION['import'] ?? null;
unset($_SESSION['import']);
// Un CV du lot garde ses fichiers : seul le lot les retire
if ($imp && empty($imp['lot'])) {
vider_dossier_import((string) $imp['dossier']);
}
}
/** Termine une connexion : session ouverte, échecs oubliés, connexion notée, alerte si nouveau réseau pour un gestionnaire. */
function finaliser_connexion(array $u): void
{
ouvrir_session($u);
effacer_echecs($u['identifiant']);
db()->prepare('UPDATE utilisateurs SET derniere_connexion = NOW() WHERE id = ?')->execute([$u['id']]);
$nouveauReseau = noter_connexion((int) $u['id'], $u['identifiant'], 'reussie');
if ($u['role'] === 'admin') {
if ($nouveauReseau) {
alerter_nouveau_reseau($u);
}
// Ménage fait à la connexion d'un gestionnaire, sans attendre la tâche planifiée
try {
purger_corbeille();
purger_connexions();
} catch (Throwable $ex) {
consigner_erreur(get_class($ex), $ex->getMessage(), $ex->getFile(), $ex->getLine());
}
}
}
/** Après la connexion : la page demandée avant, si elle est de ce site, sinon le Kanban. */
function rediriger_apres_connexion(): never
{
$retour = (string) ($_SESSION['retour'] ?? '');
unset($_SESSION['retour']);
header('Location: ' . (preg_match('/^index\.php\?[\w=&%.+\-]*$/', $retour) ? $retour : url('kanban')));
exit;
}
/** Filtres de la recherche lus dans l'adresse. */
function filtres_recherche(): array
{
return [
'statut' => (string) ($_GET['statut'] ?? ''),
'seniorite' => (string) ($_GET['seniorite'] ?? ''),
'langue' => (string) ($_GET['langue'] ?? ''),
'nationalite' => in_array($_GET['nationalite'] ?? '', ['ue', 'hors_ue', 'inconnue'], true) ? (string) $_GET['nationalite'] : '',
'permis' => ($_GET['permis'] ?? '') === 'expire' ? 'expire' : '',
'archives' => in_array($_GET['archives'] ?? '', ['inclus', 'seuls'], true) ? (string) $_GET['archives'] : '',
];
}
/** Après un changement qui ferme les sessions du compte : la session en cours suit la nouvelle version. */
function garder_session_courante(int $utilisateurId): void
{
$req = db()->prepare('SELECT session_version FROM utilisateurs WHERE id = ?');
$req->execute([$utilisateurId]);
$_SESSION['sv'] = (int) $req->fetchColumn();
session_regenerate_id(true);
}
/** Redirige vers une page, à une section donnée. */
function rediriger_ancre(string $page, string $ancre, array $params = []): never
{
header('Location: ' . url($page, $params) . '#' . $ancre);
exit;
}
/** Remet à plat un champ de fichiers multiples : une entrée par fichier, comme pour un champ simple. */
function fichiers_multiples(?array $champ): array
{
$fichiers = [];
foreach ((array) ($champ['name'] ?? []) as $i => $nom) {
if (($champ['error'][$i] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) {
continue;
}
$fichiers[] = ['name' => $nom, 'type' => $champ['type'][$i] ?? '', 'tmp_name' => $champ['tmp_name'][$i] ?? '',
'error' => $champ['error'][$i] ?? UPLOAD_ERR_NO_FILE, 'size' => $champ['size'][$i] ?? 0];
}
return $fichiers;
}
switch ($page) {
// -----------------------------------------------------------------------
// Premier lancement : création du compte administrateur
// -----------------------------------------------------------------------
case 'installation':
if (!$aucunCompte()) {
rediriger('connexion');
}
$erreur = null;
if ($post) {
try {
if (($_POST['mot_de_passe'] ?? '') !== ($_POST['confirmation'] ?? '')) {
throw new InvalidArgumentException(t('Les deux mots de passe diffèrent.'));
}
$id = creer_utilisateur((string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? ''), (string) ($_POST['mot_de_passe'] ?? ''), 'admin');
// Le premier compte active aussi la double authentification avant d'entrer
commencer_etape($id);
rediriger('double_authentification');
} catch (InvalidArgumentException $ex) {
$erreur = $ex->getMessage();
}
}
vue('installation', ['titre' => t('Installation'), 'erreur' => $erreur]);
break;
// -----------------------------------------------------------------------
// Connexion, étape 1 : identifiant et mot de passe
// -----------------------------------------------------------------------
case 'connexion':
if ($aucunCompte()) {
rediriger('installation');
}
if (utilisateur()) {
rediriger('kanban');
}
$erreur = null;
$info = null;
if (!empty($_SESSION['session_expiree']) && !$post) {
unset($_SESSION['session_expiree']);
$info = t('Votre session est terminée. Reconnectez-vous.');
}
if ($post) {
unset($_SESSION['etape']);
$identifiant = mb_strtolower(trim((string) ($_POST['identifiant'] ?? '')));
$mdp = (string) ($_POST['mot_de_passe'] ?? '');
$attente = minutes_blocage($identifiant);
if ($attente > 0) {
$req = db()->prepare('SELECT id FROM utilisateurs WHERE identifiant = ?');
$req->execute([$identifiant]);
noter_connexion(($id = $req->fetchColumn()) ? (int) $id : null, $identifiant, 'bloquee');
$erreur = t('Trop d\'essais infructueux. Réessayez dans %d minutes.', $attente);
} else {
$req = db()->prepare('SELECT * FROM utilisateurs WHERE identifiant = ?');
$req->execute([$identifiant]);
$u = $req->fetch() ?: null;
// Vérification à durée constante, y compris si le compte n'existe pas
$empreinte = $u['mot_de_passe'] ?? password_hash(random_bytes(12), algorithme_mot_de_passe());
$bon = password_verify(mb_substr($mdp, 0, LONGUEUR_MAX_MOT_DE_PASSE), $empreinte) && $u;
if ($bon && $u['actif']) {
// Ancien hachage (bcrypt) : il passe en Argon2id sans rien demander
if (password_needs_rehash($u['mot_de_passe'], algorithme_mot_de_passe())) {
db()->prepare('UPDATE utilisateurs SET mot_de_passe = ? WHERE id = ?')->execute([hacher_mot_de_passe($mdp), $u['id']]);
}
commencer_etape((int) $u['id']);
rediriger(suite_connexion($u));
}
if ($bon) {
// Le mot de passe est le bon : la personne peut savoir où en est son compte
noter_connexion((int) $u['id'], $identifiant, $u['en_attente'] ? 'attente' : 'desactive');
$erreur = $u['en_attente']
? t('Votre demande de compte attend l\'accord du gestionnaire. Vous pourrez vous connecter ensuite.')
: t('Ce compte est désactivé. Adressez-vous au gestionnaire de Vivier.');
} else {
noter_echec($identifiant);
noter_connexion($u ? (int) $u['id'] : null, $identifiant, 'mot_de_passe');
usleep(random_int(300000, 700000));
$erreur = t('Identifiant ou mot de passe incorrect.');
}
}
}
vue('connexion', ['titre' => t('Connexion'), 'erreur' => $erreur, 'info' => $info]);
break;
// -----------------------------------------------------------------------
// Connexion, étape 2 : code de l'application, ou code de secours
// -----------------------------------------------------------------------
case 'verification':
$e = etape_connexion();
$u = $e ? compte_par_id((int) $e['uid']) : null;
if (!$u || !$u['actif'] || suite_connexion($u) !== 'verification') {
rediriger('connexion');
}
$erreur = null;
if ($post) {
$attente = minutes_blocage($u['identifiant']);
if ($attente > 0) {
noter_connexion((int) $u['id'], $u['identifiant'], 'bloquee');
unset($_SESSION['etape']);
flash(t('Trop d\'essais infructueux. Réessayez dans %d minutes.', $attente), 'erreur');
rediriger('connexion');
}
$type = verifier_second_facteur($u, (string) ($_POST['code'] ?? ''));
if ($type !== null) {
finaliser_connexion($u);
if ($type === 'secours') {
$reste = codes_secours_restants(compte_par_id((int) $u['id']) ?? []);
flash(tn($reste, 'Code de secours accepté. Il vous en reste %d.', 'Code de secours accepté. Il vous en reste %d.')
. ($reste <= 3 ? ' ' . t('Générez-en de nouveaux dans Mon compte.') : ''), $reste <= 3 ? 'erreur' : 'info');
}
rediriger_apres_connexion();
}
noter_echec($u['identifiant']);
noter_connexion((int) $u['id'], $u['identifiant'], 'code');
$erreur = t('Code incorrect. Vérifiez l\'heure de votre téléphone, ou utilisez un code de secours.');
}
vue('verification', ['titre' => t('Vérification'), 'erreur' => $erreur]);
break;
// -----------------------------------------------------------------------
// Double authentification : première activation, ou nouvelle application pour un compte connecté
// -----------------------------------------------------------------------
case 'double_authentification':
$moi = utilisateur();
$e = $moi ? null : etape_connexion();
$u = $moi ? compte_par_id((int) $moi['id']) : ($e ? compte_par_id((int) $e['uid']) : null);
if (!$u || !$u['actif'] || (!$moi && suite_connexion($u) !== 'double_authentification')) {
rediriger('connexion');
}
// La clé proposée reste la même tant que la page n'est pas confirmée
if (empty($_SESSION['totp_nouvelle']) || (int) ($_SESSION['totp_nouvelle']['uid'] ?? 0) !== (int) $u['id']) {
$_SESSION['totp_nouvelle'] = ['uid' => (int) $u['id'], 'cle' => Totp::nouvelleCle()];
}
$cle = $_SESSION['totp_nouvelle']['cle'];
$erreur = null;
if ($post) {
try {
if ($moi) {
confirmer_mot_de_passe((string) ($_POST['mot_de_passe'] ?? ''));
}
$pas = Totp::verifier($cle, (string) ($_POST['code'] ?? ''), null);
if ($pas === null) {
throw new InvalidArgumentException(t('Code incorrect. Vérifiez que l\'heure de votre téléphone est juste, puis saisissez le code affiché maintenant.'));
}
activer_double_authentification((int) $u['id'], $cle, $pas);
unset($_SESSION['totp_nouvelle']);
$codes = generer_codes_secours((int) $u['id']);
if ($moi) {
// Nouvelle application : les autres sessions se ferment, celle-ci reste ouverte
fermer_sessions((int) $u['id'], true);
journaliser(null, 'securite', 'nouvelle_application', null, $u['identifiant']);
} else {
finaliser_connexion(compte_par_id((int) $u['id']));
}
$_SESSION['codes_a_montrer'] = $codes;
rediriger('codes_secours');
} catch (InvalidArgumentException $ex) {
$erreur = $ex->getMessage();
}
}
vue('double_authentification', [
'titre' => t('Double authentification'),
'cle' => $cle,
'adresse' => Totp::adresse($cle, $u['identifiant'], (string) (config('nom_app') ?: 'Vivier')),
'erreur' => $erreur,
'connecte' => $moi !== null,
]);
break;
// Codes de secours : montrés une seule fois, juste après leur création
case 'codes_secours':
$moi = exiger_connexion();
$codes = $_SESSION['codes_a_montrer'] ?? null;
unset($_SESSION['codes_a_montrer']);
if (!$codes) {
rediriger('compte');
}
vue('codes_secours', ['titre' => t('Codes de secours'), 'codes' => $codes, 'moi' => $moi]);
break;
// -----------------------------------------------------------------------
// Mot de passe donné par le gestionnaire : la personne le remplace avant d'entrer
// -----------------------------------------------------------------------
case 'nouveau_mot_de_passe':
$e = etape_connexion();
$u = $e ? compte_par_id((int) $e['uid']) : null;
if (!$u || !$u['actif'] || suite_connexion($u) !== 'nouveau_mot_de_passe') {
rediriger('connexion');
}
$erreur = null;
if ($post) {
try {
$nouveau = (string) ($_POST['nouveau'] ?? '');
if ($nouveau !== ($_POST['confirmation'] ?? '')) {
throw new InvalidArgumentException(t('Les deux nouveaux mots de passe diffèrent.'));
}
if (password_verify($nouveau, $u['mot_de_passe'])) {
throw new InvalidArgumentException(t('Choisissez un mot de passe différent de celui que vous avez reçu.'));
}
changer_mot_de_passe((int) $u['id'], $nouveau);
$u = compte_par_id((int) $u['id']);
commencer_etape((int) $u['id']);
rediriger(suite_connexion($u));
} catch (InvalidArgumentException $ex) {
$erreur = $ex->getMessage();
}
}
vue('nouveau_mot_de_passe', ['titre' => t('Nouveau mot de passe'), 'erreur' => $erreur]);
break;
// -----------------------------------------------------------------------
// Invitation : la personne crée son compte avec le lien reçu du gestionnaire
// -----------------------------------------------------------------------
case 'invitation':
if ($aucunCompte()) {
rediriger('installation');
}
$jeton = (string) ($_GET['jeton'] ?? $_POST['jeton'] ?? '');
$inv = invitation($jeton);
$erreur = null;
if ($inv && $post) {
try {
if (($_POST['mot_de_passe'] ?? '') !== ($_POST['confirmation'] ?? '')) {
throw new InvalidArgumentException(t('Les deux mots de passe diffèrent.'));
}
$id = accepter_invitation($inv, (string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? ''), (string) ($_POST['mot_de_passe'] ?? ''));
unset($_SESSION['uid']);
commencer_etape($id);
rediriger('double_authentification');
} catch (InvalidArgumentException $ex) {
$erreur = $ex->getMessage();
}
}
vue('invitation', ['titre' => t('Rejoindre Vivier'), 'inv' => $inv, 'jeton' => $jeton, 'erreur' => $erreur]);
break;
// L'ancienne page de demande de compte mène désormais à une explication
case 'inscription':
vue('inscription', ['titre' => t('Obtenir un accès')]);
break;
// -----------------------------------------------------------------------
// Guide Sécurité : lisible sans être connecté, depuis la page de connexion
// -----------------------------------------------------------------------
case 'securite':
$langue = (string) ($_GET['langue'] ?? '');
$aide = aide('securite', isset(LANGUES_INTERFACE[$langue]) ? $langue : langue_courante());
vue('aide', ['titre' => t(DOCUMENTS_AIDE['securite']), 'guide' => 'securite', 'aide' => $aide]);
break;
case 'deconnexion':
if ($post) {
vider_import_en_attente();
$_SESSION = [];
session_destroy();
}
rediriger('connexion');
// -----------------------------------------------------------------------
// Kanban
// -----------------------------------------------------------------------
case 'kanban':
exiger_connexion();
vue('kanban', ['titre' => t('Kanban'), 'colonnes' => candidats_kanban()]);
break;
case 'statut':
exiger_ecriture();
$ajax = ($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'fetch';
$id = (int) ($_POST['id'] ?? 0);
try {
if (!$post) {
throw new InvalidArgumentException(t('Requête invalide.'));
}
$statut = (string) ($_POST['statut'] ?? '');
db()->beginTransaction();
changer_statut($id, $statut, $_POST['motif'] ?? null);
if (isset($_POST['ordre']) && $_POST['ordre'] !== '') {
ordonner_colonne($statut, array_filter(array_map('intval', explode(',', (string) $_POST['ordre']))));
}
db()->commit();
if ($ajax) {
reponse_json(['ok' => true]);
}
flash(t('Statut mis à jour.'), 'succes');
} catch (InvalidArgumentException $ex) {
if (db()->inTransaction()) {
db()->rollBack();
}
if ($ajax) {
reponse_json(['ok' => false, 'erreur' => $ex->getMessage()], 422);
}
flash($ex->getMessage(), 'erreur');
}
rediriger('profil', ['id' => $id]);
// -----------------------------------------------------------------------
// Import d'un profil
// -----------------------------------------------------------------------
case 'import':
exiger_ecriture();
$erreurs = [];
if ($post && ($_POST['mode'] ?? '') === 'auto') {
vider_import_en_attente();
try {
if (!Claude::configure()) {
throw new RuntimeException(t('L\'analyse automatique n\'est pas configurée.'));
}
$cv = controler_fichier($_FILES['cv'] ?? [], ['pdf', 'docx', 'odt', 'md', 'txt']);
$analyse = Claude::analyser($cv);
$erreurs = Import::valider($analyse['profil']);
if ($erreurs) {
array_unshift($erreurs, t('L\'analyse automatique a produit un profil non conforme. Relancez-la, ou passez par les fichiers déjà analysés.'));
} else {
$jeton = bin2hex(random_bytes(16));
$dossier = dossier_stockage('tmp/' . $jeton);
file_put_contents($dossier . '/profil.json', json_encode($analyse['profil'], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
$cv['chemin_tmp'] = $dossier . '/cv.' . $cv['ext'];
move_uploaded_file($cv['tmp'], $cv['chemin_tmp']);
$rapport = null;
if ($analyse['rapport'] !== '') {
$base = preg_replace('/[^a-z0-9]+/', '-', Recherche::normaliser($analyse['profil']['identite']['nom'] . ' ' . $analyse['profil']['identite']['prenom']));
$rapport = ['nom' => 'rapport-' . trim((string) $base, '-') . '.md', 'ext' => 'md', 'mime' => 'text/markdown',
'taille' => strlen($analyse['rapport']), 'chemin_tmp' => $dossier . '/rapport.md'];
file_put_contents($rapport['chemin_tmp'], $analyse['rapport']);
}
$photo = Photo::extraire($cv['chemin_tmp'], $cv['ext']);
if ($photo !== null) {
file_put_contents($dossier . '/photo.jpg', $photo);
}
$_SESSION['import'] = ['dossier' => $jeton, 'cv' => $cv, 'rapport' => $rapport, 'auto' => $analyse['modele'], 'photo' => $photo !== null];
rediriger('import_relecture');
}
} catch (RuntimeException $ex) {
$erreurs = [$ex->getMessage()];
}
} elseif ($post) {
vider_import_en_attente();
try {
$json = controler_fichier($_FILES['profil'] ?? [], ['json']);
$cv = controler_fichier($_FILES['cv'] ?? [], ['pdf', 'docx', 'doc', 'odt', 'rtf', 'md', 'txt']);
$rapport = fichier_fourni($_FILES['rapport'] ?? null)
? controler_fichier($_FILES['rapport'], ['md', 'txt', 'pdf', 'docx'])
: null;
$profil = json_decode((string) file_get_contents($json['tmp']), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException(t('Le fichier de profil n\'est pas un JSON valide : %s.', json_last_error_msg()));
}
$erreurs = Import::valider($profil);
if (!$erreurs) {
$jeton = bin2hex(random_bytes(16));
$dossier = dossier_stockage('tmp/' . $jeton);
move_uploaded_file($json['tmp'], $dossier . '/profil.json');
$ranger = static function (?array $f, string $cle) use ($dossier): ?array {
if ($f !== null) {
$f['chemin_tmp'] = $dossier . '/' . $cle . '.' . $f['ext'];
move_uploaded_file($f['tmp'], $f['chemin_tmp']);
}
return $f;
};
$cv = $ranger($cv, 'cv');
$rapport = $ranger($rapport, 'rapport');
$photo = Photo::extraire($cv['chemin_tmp'], $cv['ext']);
if ($photo !== null) {
file_put_contents($dossier . '/photo.jpg', $photo);
}
$_SESSION['import'] = ['dossier' => $jeton, 'cv' => $cv, 'rapport' => $rapport, 'photo' => $photo !== null];
rediriger('import_relecture');
}
} catch (RuntimeException $ex) {
$erreurs = [$ex->getMessage()];
}
}
vue('import', ['titre' => t('Importer un profil'), 'erreurs' => $erreurs, 'auto' => Claude::configure(), 'lot' => Lot::nombre()]);
break;
case 'import_relecture':
exiger_ecriture();
$imp = $_SESSION['import'] ?? null;
$fichier = $imp ? dossier_stockage('tmp') . '/' . $imp['dossier'] . '/profil.json' : null;
if (!$imp || !is_file($fichier)) {
rediriger('import');
}
$profil = json_decode((string) file_get_contents($fichier), true);
$doublons = Import::doublons(db(), $profil);
// Même adresse email : c'est la même personne, l'enregistrement est refusé. Téléphone ou nom : à confirmer.
$bloquant = (bool) array_filter($doublons, static fn ($d) => in_array('email', $d['motifs'], true));
$enLot = !empty($imp['lot']);
if ($post && ($_POST['action'] ?? '') === 'annuler') {
vider_import_en_attente();
if ($enLot) {
rediriger('import_lot');
}
flash(t('Import annulé.'));
rediriger('import');
}
if ($post && !$bloquant) {
if ($doublons && empty($_POST['confirmer_doublon'])) {
flash(t('Un profil ressemblant existe déjà. Cochez la case pour confirmer qu\'il s\'agit d\'une autre personne.'), 'erreur');
rediriger('import_relecture');
}
$id = enregistrer_import($profil, $imp, !empty($_POST['garder_photo']));
if ($enLot) {
Lot::retirer((int) $imp['lot']);
}
vider_import_en_attente();
flash(t('Profil enregistré dans la colonne Reçu.'), 'succes');
if ($enLot && Lot::nombre() > 0) {
rediriger('import_lot');
}
rediriger('profil', ['id' => $id]);
}
vue('import_relecture', [
'titre' => t('Relecture avant enregistrement'),
'profil' => $profil,
'seniorite' => Import::seniorite($profil),
'doublons' => $doublons,
'bloquant' => $bloquant,
'imp' => $imp,
]);
break;
// -----------------------------------------------------------------------
// Import en lot
// -----------------------------------------------------------------------
case 'import_lot':
exiger_ecriture();
$action = (string) ($_POST['action'] ?? '');
if ($post && $action === 'deposer') {
// CV à analyser automatiquement
$fichiers = fichiers_multiples($_FILES['cv'] ?? null);
$places = LOT_MAX - Lot::nombre();
$deposes = 0;
$refus = [];
foreach ($fichiers as $f) {
if ($deposes >= $places) {
$refus[] = t('%s : le lot est plein (%d CV au plus).', basename((string) $f['name']), LOT_MAX);
continue;
}
try {
Lot::deposer(controler_fichier($f, ['pdf', 'docx', 'odt', 'md', 'txt']));
$deposes++;
} catch (RuntimeException $ex) {
$refus[] = basename((string) $f['name']) . ' : ' . $ex->getMessage();
}
}
if ($deposes) {
flash(tn($deposes, '%d CV déposé.', '%d CV déposés.'), 'succes');
} elseif (!$refus) {
flash(t('Aucun fichier reçu.'), 'erreur');
}
foreach ($refus as $m) {
flash($m, 'erreur');
}
rediriger('import_lot');
}
if ($post && $action === 'deposer_analyses') {
// Profils déjà analysés : JSON, CV et rapports déposés ensemble, associés par leurs noms
$controles = [];
$refus = [];
foreach (fichiers_multiples($_FILES['fichiers'] ?? null) as $f) {
try {
$controles[] = controler_fichier($f, ['json', 'pdf', 'docx', 'doc', 'odt', 'rtf', 'md', 'txt']);
} catch (RuntimeException $ex) {
$refus[] = basename((string) $f['name']) . ' : ' . $ex->getMessage();
}
}
$r = Lot::apparier($controles);
$places = LOT_MAX - Lot::nombre();
$deposes = 0;
foreach ($r['paires'] as $paire) {
if ($deposes >= $places) {
$refus[] = t('%s : le lot est plein (%d CV au plus).', $paire['cv']['nom'], LOT_MAX);
continue;
}
Lot::deposerAnalyse($paire['json'], $paire['cv'], $paire['rapport']);
$deposes++;
}
if ($deposes) {
flash(tn($deposes, '%d profil déposé.', '%d profils déposés.'), 'succes');
} elseif (!$refus && !$r['erreurs']) {
flash(t('Aucun fichier reçu.'), 'erreur');
}
foreach ([...$refus, ...$r['erreurs']] as $m) {
flash($m, 'erreur');
}
rediriger('import_lot');
}
if ($post && $action === 'analyser') {
$l = Lot::analyser((int) ($_POST['id'] ?? 0));
flash($l['etat'] === 'pret' ? t('%s : analyse terminée.', $l['nom_fichier']) : $l['nom_fichier'] . ' : ' . $l['erreur'], $l['etat'] === 'pret' ? 'succes' : 'erreur');
rediriger('import_lot');
}
if ($post && $action === 'relire') {
$l = Lot::ligne((int) ($_POST['id'] ?? 0));
if ($l && $l['etat'] === 'pret') {
vider_import_en_attente();
$_SESSION['import'] = Lot::enAttente($l);
rediriger('import_relecture');
}
rediriger('import_lot');
}
if ($post && $action === 'retirer') {
// La relecture en cours porte peut-être sur ce CV
if ((int) ($_SESSION['import']['lot'] ?? 0) === (int) ($_POST['id'] ?? 0)) {
unset($_SESSION['import']);
}
Lot::retirer((int) ($_POST['id'] ?? 0));
flash(t('CV retiré du lot.'), 'succes');
rediriger('import_lot');
}
if ($post && $action === 'tout') {
unset($_SESSION['import']);
$r = Lot::toutEnregistrer();
if ($r['enregistres']) {
flash(tn($r['enregistres'], '%d profil enregistré dans la colonne Reçu.', '%d profils enregistrés dans la colonne Reçu.'), 'succes');
}
if ($r['a_relire']) {
flash(tn($r['a_relire'], '%d profil ressemble à un profil existant : relisez-le avant de l\'enregistrer.', '%d profils ressemblent à des profils existants : relisez-les avant de les enregistrer.'), 'info');
}
if (!$r['enregistres'] && !$r['a_relire']) {
flash(t('Aucun profil prêt à enregistrer.'), 'info');
}
rediriger(Lot::nombre() > 0 ? 'import_lot' : 'kanban');
}
vue('import_lot', ['titre' => t('Import en lot'), 'lignes' => Lot::liste(), 'auto' => Claude::configure()]);
break;
case 'import_lot_analyse':
// Appelé par le JavaScript de la page du lot : un CV par requête
exiger_ecriture();
if (!$post) {
reponse_json(['ok' => false, 'erreur' => t('Requête invalide.')], 405);
}
// L'analyse dure une à deux minutes : la session est rendue tout de suite, pour ne pas bloquer les autres pages
session_write_close();
try {
$l = Lot::analyser((int) ($_POST['id'] ?? 0));
} catch (RuntimeException $ex) {
reponse_json(['ok' => false, 'erreur' => $ex->getMessage()], 404);
}
reponse_json(['ok' => $l['etat'] === 'pret', 'etat' => $l['etat'], 'candidat' => $l['candidat'], 'erreur' => $l['erreur']]);
// -----------------------------------------------------------------------
// Fiche profil, commentaires, documents
// -----------------------------------------------------------------------
case 'profil':
exiger_connexion();
$c = candidat((int) ($_GET['id'] ?? 0));
if (!$c) {
http_response_code(404);
vue('introuvable', ['titre' => t('Profil introuvable')]);
break;
}
// Le contenu s'affiche dans la langue de l'interface quand une traduction à jour existe ; « original=1 » montre le texte d'origine
$original = ($_GET['original'] ?? '') === '1';
$affiche = $original ? $c + ['traduction' => traduction_profil($c, langue_courante())] : profil_affiche($c, langue_courante());
vue('profil', [
'titre' => $c['prenom'] . ' ' . $c['nom'],
'c' => $affiche,
'original' => $original,
'enFile' => profil_en_file((int) $c['id'], langue_courante()),
'besoinsOuverts' => besoins_ouverts(),
]);
break;
// -----------------------------------------------------------------------
// Suivi : archivage, entretiens, relances, consentement, export des données
// -----------------------------------------------------------------------
case 'archiver':
exiger_ecriture();
$c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null;
if (!$c) {
rediriger('kanban');
}
$archiver = ($_POST['action'] ?? '') !== 'desarchiver';
archiver_candidat($c, $archiver);
flash($archiver ? t('Profil archivé. Il reste dans Profils, filtre « Archivés ».') : t('Profil désarchivé. Il est de retour dans le Kanban.'), 'succes');
rediriger('profil', ['id' => $c['id']]);
case 'entretien':
exiger_ecriture();
$action = (string) ($_POST['action'] ?? '');
$ancien = in_array($action, ['modifier', 'supprimer'], true) ? entretien((int) ($_POST['entretien_id'] ?? 0)) : null;
$c = $post ? candidat((int) ($ancien['candidat_id'] ?? $_POST['id'] ?? 0)) : null;
if (!$c) {
rediriger('kanban');
}
try {
if ($action === 'supprimer' && $ancien) {
supprimer_entretien($ancien);
flash(t('Entretien supprimé.'), 'succes');
} elseif ($action === 'modifier' && $ancien) {
modifier_entretien($ancien, $_POST);
flash(t('Entretien mis à jour.'), 'succes');
} elseif ($action === 'ajouter') {
db()->beginTransaction();
ajouter_entretien($c, $_POST);
// Premier entretien : le profil avance dans le Kanban si la case est cochée
if (!empty($_POST['avancer']) && in_array($c['statut'], ['recu', 'en_cours'], true) && $c['archive_le'] === null) {
changer_statut((int) $c['id'], 'entretiens', null);
}
db()->commit();
flash(t('Entretien enregistré.'), 'succes');
}
} catch (InvalidArgumentException $ex) {
if (db()->inTransaction()) {
db()->rollBack();
}
flash($ex->getMessage(), 'erreur');
}
header('Location: ' . url('profil', ['id' => $c['id']]) . '#entretiens');
exit;
case 'relance':
exiger_ecriture();
$action = (string) ($_POST['action'] ?? '');
$r = in_array($action, ['faite', 'supprimer'], true) ? relance((int) ($_POST['relance_id'] ?? 0)) : null;
$c = $post ? candidat((int) ($r['candidat_id'] ?? $_POST['id'] ?? 0)) : null;
if (!$c) {
rediriger('suivi');
}
try {
if ($action === 'faite' && $r) {
terminer_relance($r);
flash(t('Relance faite.'), 'succes');
} elseif ($action === 'supprimer' && $r) {
supprimer_relance($r);
flash(t('Relance supprimée.'), 'succes');
} elseif ($action === 'ajouter') {
ajouter_relance($c, $_POST);
flash(t('Relance enregistrée.'), 'succes');
}
} catch (InvalidArgumentException $ex) {
flash($ex->getMessage(), 'erreur');
}
if (($_POST['retour'] ?? '') === 'suivi') {
rediriger('suivi');
}
header('Location: ' . url('profil', ['id' => $c['id']]) . '#relances');
exit;
case 'consentement':
exiger_ecriture();
$c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null;
if (!$c) {
rediriger('kanban');
}
try {
$retirer = ($_POST['action'] ?? '') === 'retirer';
enregistrer_consentement($c, $retirer ? '' : (string) ($_POST['date'] ?? ''), (string) ($_POST['note'] ?? ''));
flash($retirer ? t('Accord retiré.') : t('Accord enregistré.'), 'succes');
} catch (InvalidArgumentException $ex) {
flash($ex->getMessage(), 'erreur');
}
header('Location: ' . url('profil', ['id' => $c['id']]) . '#donnees');
exit;
case 'profil_export':
exiger_ecriture();
$c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null;
if (!$c) {
rediriger('kanban');
}
envoyer_fichier_temporaire(exporter_donnees_candidat($c), 'donnees-' . strtolower(reference_profil($c)) . '-' . date('Y-m-d') . '.zip', 'application/zip');
case 'suivi':
exiger_connexion();
vue('suivi', [
'titre' => t('Suivi'),
'relances' => relances_ouvertes(),
'entretiens' => entretiens_a_venir(),
'sansAvis' => entretiens_sans_avis(),
]);
break;
// -----------------------------------------------------------------------
// Besoins clients
// -----------------------------------------------------------------------
case 'besoins':
exiger_connexion();
$erreur = null;
if ($post) {
exiger_ecriture();
try {
$id = enregistrer_besoin(null, $_POST);
flash(t('Besoin enregistré.'), 'succes');
rediriger('besoin', ['id' => $id]);
} catch (InvalidArgumentException $ex) {
$erreur = $ex->getMessage();
}
}
$statut = (string) ($_GET['statut'] ?? '');
vue('besoins', ['titre' => t('Besoins clients'), 'liste' => besoins($statut), 'statut' => $statut, 'erreur' => $erreur, 'saisie' => $post ? $_POST : []]);
break;
case 'besoin':
exiger_connexion();
$b = besoin((int) ($_GET['id'] ?? $_POST['besoin_id'] ?? 0));
if (!$b) {
http_response_code(404);
vue('introuvable', ['titre' => t('Besoin introuvable')]);
break;
}
$erreur = null;
if ($post) {
exiger_ecriture();
$action = (string) ($_POST['action'] ?? '');
$candidatId = (int) ($_POST['candidat_id'] ?? 0);
try {
switch ($action) {
case 'modifier':
enregistrer_besoin($b, $_POST);
flash(t('Besoin mis à jour.'), 'succes');
break;
case 'statut':
changer_statut_besoin($b, (string) ($_POST['statut'] ?? ''));
flash(t('Statut mis à jour.'), 'succes');
break;
case 'supprimer':
supprimer_besoin($b);
flash(t('Besoin supprimé.'), 'succes');
rediriger('besoins');
case 'proposer':
if (candidat($candidatId)) {
proposer_candidat($b, $candidatId);
flash(t('Profil ajouté à ce besoin.'), 'succes');
}
break;
case 'etat':
changer_proposition($b, $candidatId, (string) ($_POST['etat'] ?? ''), (string) ($_POST['note'] ?? ''));
flash(t('Proposition mise à jour.'), 'succes');
break;
case 'retirer':
retirer_proposition($b, $candidatId);
flash(t('Profil retiré de ce besoin.'), 'succes');
break;
}
if (($_POST['retour'] ?? '') === 'profil' && $candidatId) {
header('Location: ' . url('profil', ['id' => $candidatId]) . '#besoins');
exit;
}
rediriger('besoin', ['id' => $b['id']]);
} catch (InvalidArgumentException $ex) {
$erreur = $ex->getMessage();
}
}
vue('besoin', [
'titre' => $b['client'] . ' : ' . $b['intitule'],
'b' => $b,
'suggestions' => $b['statut'] === 'ouvert' ? rapprochement($b) : [],
'erreur' => $erreur,
'saisie' => $erreur !== null ? $_POST : $b,
]);
break;
// -----------------------------------------------------------------------
// Sauvegarde (gestionnaire des comptes)
// -----------------------------------------------------------------------
case 'sauvegarde':
$moi = exiger_admin();
$action = (string) ($_POST['action'] ?? '');
if (!$post && isset($_GET['telecharger'])) {
$s = Sauvegarde::ligne((int) $_GET['telecharger']);
$chemin = $s ? Sauvegarde::chemin($s) : null;
if ($chemin === null) {
http_response_code(404);
vue('introuvable', ['titre' => t('Sauvegarde introuvable')]);
break;
}
journaliser(null, 'sauvegarde', 'telechargement', null, $s['fichier']);
header('Content-Type: application/zip');
header('Content-Length: ' . filesize($chemin));
header('Content-Disposition: attachment; filename="' . $s['fichier'] . '"');
header('Cache-Control: private, no-store');
readfile($chemin);
exit;
}
if ($post && $action === 'lancer') {
$r = Sauvegarde::executer('manuelle', !empty($_POST['envoyer']), (int) $moi['id']);
if (!$r['reussie']) {
flash(t('La sauvegarde a échoué : %s', $r['detail']), 'erreur');
} else {
journaliser(null, 'sauvegarde', 'manuelle', null, $r['fichier']);
flash(t('Sauvegarde faite : %s (%s).', $r['fichier'], Sauvegarde::taille((int) $r['taille'])), 'succes');
if ($r['envoi'] === 'envoye') {
flash(t('Email envoyé avec la sauvegarde en pièce jointe.'), 'succes');
} elseif ($r['envoi'] === 'sans_piece') {
flash(t('Email envoyé sans pièce jointe : le fichier dépasse la taille admise.'), 'info');
} elseif ($r['envoi'] === 'echec') {
flash(t('L\'email n\'est pas parti. %s', $r['detail']), 'erreur');
}
}
rediriger('sauvegarde');
}
if ($post && $action === 'essai') {
if (Sauvegarde::tester()) {
flash(t('Email d\'essai remis au serveur. Vérifiez les boîtes de réception, courriers indésirables compris.'), 'succes');
} else {
flash(t('L\'email d\'essai n\'est pas parti. Vérifiez les adresses dans config/config.php.'), 'erreur');
}
rediriger('sauvegarde');
}
if ($post && $action === 'supprimer') {
$s = Sauvegarde::ligne((int) ($_POST['id'] ?? 0));
if ($s) {
Sauvegarde::supprimerFichier(db(), $s);
journaliser(null, 'sauvegarde', 'suppression', $s['fichier'], null);
flash(t('Copie supprimée du serveur.'), 'succes');
}
rediriger('sauvegarde');
}
if ($post && $action === 'fichiers') {
try {
confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? ''));
} catch (InvalidArgumentException $ex) {
flash($ex->getMessage(), 'erreur');
rediriger('sauvegarde');
}
journaliser(null, 'sauvegarde', 'fichiers', null, 'Archive des fichiers');
envoyer_fichier_temporaire(Sauvegarde::archiverFichiers(), 'vivier-fichiers-' . date('Y-m-d-Hi') . '.zip', 'application/zip');
}
vue('sauvegarde', [
'titre' => t('Sauvegarde'),
'liste' => Sauvegarde::liste(),
'etat' => Sauvegarde::etat(),
'reglages' => Sauvegarde::reglages(),
'fichiers' => db()->query('SELECT COUNT(*) AS n, COALESCE(SUM(taille_octets), 0) AS octets FROM documents')->fetch(),
]);
break;
case 'cv':
case 'cv_word':
case 'cv_paquet':
case 'cv_traduire':
case 'cv_traduction':
case 'europass':
case 'europass_word':
exiger_connexion();
$c = candidat((int) ($_GET['id'] ?? $_POST['id'] ?? 0));
if (!$c) {
http_response_code(404);
vue('introuvable', ['titre' => t('Profil introuvable')]);
break;
}
$anonyme = ($_GET['anonyme'] ?? $_POST['anonyme'] ?? '') === '1';
$source = langue_du_profil($c);
// Sans choix : la langue de l'interface quand elle n'est pas le français, sinon celle du CV d'origine
$langue = (string) ($_GET['langue'] ?? $_POST['langue'] ?? '');
$parDefaut = langue_courante() !== 'fr' && isset(LANGUES_CV[langue_courante()]) ? langue_courante() : $source;
$langue = isset(LANGUES_CV[$langue]) ? $langue : (isset(LANGUES_CV[$parDefaut]) ? $parDefaut : 'fr');
$retour = ['id' => $c['id'], 'langue' => $langue] + ($anonyme ? ['anonyme' => 1] : []);
$pageRetour = ($_POST['format'] ?? '') === 'europass' ? 'europass' : 'cv';
// Traduction automatique du contenu
if ($page === 'cv_traduire') {
exiger_ecriture();
try {
if (!$post || !Claude::configure() || !traduction_necessaire($c, $langue)) {
throw new RuntimeException(t('La traduction automatique n\'est pas disponible.'));
}
traduire_profil($c, $langue);
flash(t('Contenu traduit. Relisez-le avant d\'envoyer le CV.'), 'succes');
} catch (RuntimeException $ex) {
flash($ex->getMessage(), 'erreur');
}
rediriger($pageRetour, $retour);
}
// Traduction par fichier : on télécharge le paquet, on le fait traduire, on le recharge
if ($page === 'cv_paquet') {
$nom = 'traduction-' . strtolower(reference_profil($c)) . '-' . $langue . '.json';
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $nom . '"');
header('Cache-Control: private, no-store');
echo json_encode([
'type' => 'traduction-vivier', 'profil' => reference_profil($c), 'langue_source' => $source, 'langue_cible' => $langue,
'contenu' => paquet_traduction($c),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
exit;
}
if ($page === 'cv_traduction') {
exiger_ecriture();
try {
$f = controler_fichier($_FILES['traduction'] ?? [], ['json']);
$recu = json_decode((string) file_get_contents($f['tmp']), true);
if (!is_array($recu) || ($recu['type'] ?? '') !== 'traduction-vivier' || !isset($recu['contenu'])) {
throw new RuntimeException(t('Ce fichier n\'est pas une traduction produite pour Vivier.'));
}
if (($recu['profil'] ?? '') !== reference_profil($c)) {
throw new RuntimeException(t('Cette traduction concerne un autre profil (%s).', (string) ($recu['profil'] ?? '?')));
}
$cible = (string) ($recu['langue_cible'] ?? $langue);
if (!isset(LANGUES_CV[$cible]) || !traduction_necessaire($c, $cible)) {
throw new RuntimeException(t('La langue de cette traduction n\'est pas gérée.'));
}
enregistrer_traduction($c, $cible, $recu['contenu'], 'fichier');
$retour['langue'] = $cible;
flash(t('Traduction enregistrée.'), 'succes');
} catch (RuntimeException $ex) {
flash($ex->getMessage(), 'erreur');
}
rediriger('cv', $retour);
}
$traduction = traduction_profil($c, $langue);
// CV Europass : coordonnées affichées sauf demande contraire, photo seulement si on la demande
if ($page === 'europass' || $page === 'europass_word') {
$options = [
'anonyme' => $anonyme,
'photo' => !$anonyme && ($_GET['photo'] ?? '') === '1' && !empty($c['photo']),
'coordonnees' => !$anonyme && ($_GET['coordonnees'] ?? '1') !== '0',
];
$d = donnees_europass($c, $options, $langue, $traduction['contenu']);
$version = ($anonyme ? 'CV Europass anonymisé' : 'CV Europass') . ' ' . strtoupper($langue);
if ($page === 'europass_word') {
$photo = $d['photo'] !== null ? dossier_stockage() . '/' . $d['photo'] : null;
$docx = ExportEuropass::generer($d, __DIR__ . '/assets/europass/logo-europass-blanc.png', $photo);
journaliser((int) $c['id'], 'export', 'cv_europass', null, $version . ' (Word)');
$nom = preg_replace('/[^\w.\- ()]+/u', '_', $d['fichier']) . '.docx';
header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document');
header('Content-Length: ' . strlen($docx));
header('Content-Disposition: attachment; filename="' . $nom . '"; filename*=UTF-8\'\'' . rawurlencode($nom));
header('Cache-Control: private, no-store');
echo $docx;
exit;
}
journaliser((int) $c['id'], 'export', 'cv_europass', null, $version . ' (PDF)');
require VIVIER_RACINE . '/vues/europass.php';
break;
}
$d = donnees_cv($c, $anonyme, (array) config('societe'), $langue, $traduction['contenu']);
$version = ($anonyme ? 'CV anonymisé' : 'CV IT Club') . ' ' . strtoupper($langue);
if ($page === 'cv_word') {
$docx = ExportWord::generer($d, __DIR__ . '/assets/logo-itclub-or.png');
journaliser((int) $c['id'], 'export', $anonyme ? 'cv_anonyme' : 'cv_itclub', null, $version . ' (Word)');
$nom = preg_replace('/[^\w.\- ()]+/u', '_', $d['fichier']) . '.docx';
header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document');
header('Content-Length: ' . strlen($docx));
header('Content-Disposition: attachment; filename="' . $nom . '"; filename*=UTF-8\'\'' . rawurlencode($nom));
header('Cache-Control: private, no-store');
echo $docx;
exit;
}
journaliser((int) $c['id'], 'export', $anonyme ? 'cv_anonyme' : 'cv_itclub', null, $version . ' (PDF)');
require VIVIER_RACINE . '/vues/cv.php';
break;
case 'commentaire':
exiger_ecriture();
$id = (int) ($_POST['id'] ?? 0);
$texte = trim((string) ($_POST['texte'] ?? ''));
if ($post && $texte !== '' && candidat($id)) {
db()->prepare('INSERT INTO commentaires (candidat_id, auteur_id, texte) VALUES (?, ?, ?)')
->execute([$id, utilisateur()['id'], $texte]);
journaliser($id, 'commentaire');
} elseif ($post) {
flash(t('Le commentaire est vide.'), 'erreur');
}
rediriger_fiche($id, 'commentaires');
case 'document_ajout':
exiger_ecriture();
$id = (int) ($_POST['id'] ?? 0);
$type = (string) ($_POST['type'] ?? 'autre');
try {
if (!$post || !candidat($id) || !isset(TYPES_DOCUMENT[$type])) {
throw new RuntimeException(t('Requête invalide.'));
}
attacher_document($id, $type, controler_fichier($_FILES['fichier'] ?? []));
flash(t('Document ajouté.'), 'succes');
} catch (RuntimeException $ex) {
flash($ex->getMessage(), 'erreur');
}
rediriger_fiche($id, 'documents');
case 'document':
exiger_connexion();
$d = document((int) ($_GET['id'] ?? 0));
$chemin = $d ? dossier_stockage() . '/' . $d['chemin'] : null;
if (!$d || !is_file($chemin)) {
http_response_code(404);
exit(t('Document introuvable.'));
}
$nom = preg_replace('/[^\w.\- ]+/u', '_', $d['nom_fichier']);
header('Content-Type: ' . $d['type_mime']);
header('Content-Length: ' . filesize($chemin));
header('Content-Disposition: attachment; filename="' . $nom . '"');
header('Cache-Control: private, no-store');
readfile($chemin);
exit;
// -----------------------------------------------------------------------
// Comptes (administrateur)
// -----------------------------------------------------------------------
case 'utilisateurs':
$moi = exiger_admin();
$cible = (int) ($_POST['id'] ?? 0);
$action = $post ? (string) ($_POST['action'] ?? '') : '';
try {
switch ($action) {
case 'inviter':
$jeton = creer_invitation((string) ($_POST['nom'] ?? ''));
// Le lien n'est montré qu'une fois : seule son empreinte est enregistrée
$_SESSION['invitation_lien'] = ['lien' => adresse_complete('invitation', ['jeton' => $jeton]), 'nom' => trim((string) ($_POST['nom'] ?? ''))];
rediriger_ancre('utilisateurs', 'invitations');
case 'annuler_invitation':
flash(annuler_invitation((int) ($_POST['invitation'] ?? 0)) ? t('Invitation annulée : le lien ne fonctionne plus.') : t('Cette invitation n\'existe plus.'), 'succes');
rediriger_ancre('utilisateurs', 'invitations');
case 'creer':
$id = creer_utilisateur((string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? ''), (string) ($_POST['mot_de_passe'] ?? ''), 'recruteur', true);
journaliser(null, 'compte', 'creation', null, mb_strtolower(trim((string) $_POST['identifiant'])));
flash(t('Compte créé. Transmettez l\'identifiant et le mot de passe à la personne : elle le changera à sa première connexion, puis activera la double authentification.'), 'succes');
break;
case 'accepter':
case 'refuser':
$d = $action === 'accepter' ? accepter_demande($cible) : refuser_demande($cible);
if ($d === null) {
flash(t('Cette demande n\'existe plus.'), 'erreur');
} else {
flash($action === 'accepter'
? t('Compte %s accepté. Prévenez la personne : elle peut se connecter.', $d['identifiant'])
: t('Demande de %s refusée et effacée.', $d['identifiant']), 'succes');
}
break;
case 'debloquer':
$cle = (string) ($_POST['cle'] ?? '');
if (debloquer($cle)) {
journaliser(null, 'compte', 'deblocage', null, preg_replace('/^(compte|ip):/', '', $cle));
flash(t('Blocage levé. La connexion est de nouveau possible.'), 'succes');
}
rediriger_ancre('utilisateurs', 'blocages');
case 'reindexer':
flash(tn(Recherche::toutIndexer(db()), '%d profil réindexé.', '%d profils réindexés.'), 'succes');
rediriger_ancre('utilisateurs', 'maintenance');
case 'traduire_tout':
relancer_traductions_en_echec();
planifier_toutes_traductions();
$n = nombre_traductions_a_faire();
flash($n ? tn($n, '%d traduction à faire. Elles se font en arrière-plan, tant qu\'une page de Vivier reste ouverte.', '%d traductions à faire. Elles se font en arrière-plan, tant qu\'une page de Vivier reste ouverte.')
: t('Tous les profils sont déjà traduits.'), 'succes');
rediriger_ancre('utilisateurs', 'maintenance');
}
// Les actions suivantes portent sur un compte déjà accepté
if (in_array($action, ['modifier', 'gestionnaire', 'basculer', 'double_auth'], true)) {
$compte = compte_par_id($cible);
if (!$compte || $compte['en_attente']) {
throw new InvalidArgumentException(t('Compte introuvable.'));
}
$soi = $cible === (int) $moi['id'];
if ($action === 'modifier') {
modifier_utilisateur($cible, (string) ($_POST['identifiant'] ?? ''), (string) ($_POST['nom'] ?? ''));
if (($_POST['mot_de_passe'] ?? '') !== '') {
confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? ''));
// Mot de passe donné par le gestionnaire : la personne le change à sa prochaine connexion
changer_mot_de_passe($cible, (string) $_POST['mot_de_passe'], !$soi);
if ($soi) {
garder_session_courante($cible);
}
journaliser(null, 'compte', 'mot_de_passe', null, $compte['identifiant']);
}
flash(t('Compte modifié.'), 'succes');
} elseif ($action === 'gestionnaire') {
if ($soi) {
throw new InvalidArgumentException(t('Vous ne pouvez pas retirer votre propre gestion des comptes.'));
}
confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? ''));
db()->prepare("UPDATE utilisateurs SET role = IF(role = 'admin', 'recruteur', 'admin') WHERE id = ?")->execute([$cible]);
journaliser(null, 'compte', $compte['role'] === 'admin' ? 'gestionnaire_retire' : 'gestionnaire', null, $compte['identifiant']);
flash($compte['role'] === 'admin' ? t('Gestion des comptes retirée.') : t('Gestion des comptes confiée.'), 'succes');
} elseif ($action === 'double_auth') {
confirmer_mot_de_passe((string) ($_POST['mon_mot_de_passe'] ?? ''));
reinitialiser_double_authentification($cible);
if ($soi) {
$_SESSION = [];
rediriger('connexion');
}
journaliser(null, 'compte', 'double_auth', null, $compte['identifiant']);
flash(t('Double authentification remise à zéro. La personne en active une nouvelle à sa prochaine connexion. Ses sessions ouvertes sont fermées.'), 'succes');
} elseif ($action === 'basculer') {
if ($soi) {
throw new InvalidArgumentException(t('Vous ne pouvez pas désactiver votre propre compte.'));
}
db()->prepare('UPDATE utilisateurs SET actif = 1 - actif, session_version = session_version + 1 WHERE id = ?')->execute([$cible]);
journaliser(null, 'compte', $compte['actif'] ? 'desactivation' : 'reactivation', null, $compte['identifiant']);
flash($compte['actif'] ? t('Compte désactivé : il ne peut plus se connecter et ses sessions sont fermées.') : t('Compte réactivé.'), 'succes');
}
}
} catch (InvalidArgumentException $ex) {
flash($ex->getMessage(), 'erreur');
}
if ($post) {
rediriger('utilisateurs');
}
$lien = $_SESSION['invitation_lien'] ?? null;
unset($_SESSION['invitation_lien']);
vue('utilisateurs', [
'titre' => t('Comptes'),
'liste' => utilisateurs(),
'demandes' => demandes_compte(),
'moi' => $moi,
'invitations' => invitations_ouvertes(),
'lien' => $lien,
'blocages' => blocages_en_cours(),
'aTraduire' => nombre_traductions_a_faire(),
'echecsTraduction' => traductions_en_echec(),
]);
break;
// -----------------------------------------------------------------------
// Journal des connexions et erreurs (gestionnaire)
// -----------------------------------------------------------------------
case 'connexions':
exiger_admin();
$qui = (int) ($_GET['utilisateur'] ?? 0);
$resultat = (string) ($_GET['resultat'] ?? '');
vue('connexions', ['titre' => t('Connexions'), 'lignes' => connexions($qui, $resultat), 'comptes' => utilisateurs(), 'qui' => $qui, 'resultat' => $resultat]);
break;
case 'erreurs':
exiger_admin();
if ($post && ($_POST['action'] ?? '') === 'vider') {
vider_erreurs();
flash(t('Journal des erreurs vidé.'), 'succes');
rediriger('erreurs');
}
$noms = db()->query('SELECT id, nom_affiche FROM utilisateurs')->fetchAll(PDO::FETCH_KEY_PAIR);
vue('erreurs', ['titre' => t('Erreurs'), 'lignes' => erreurs_notees(), 'noms' => $noms]);
break;
// -----------------------------------------------------------------------
// Traduction des profils : la suivante de la file (appelée par le script, en arrière-plan),
// ou tout de suite, depuis la fiche
// -----------------------------------------------------------------------
case 'traduction_suivante':
$moi = exiger_ecriture();
if (!$post || !Claude::configure()) {
reponse_json(['ok' => false, 'reste' => 0]);
}
session_write_close();
$r = traduction_suivante((int) $moi['id']);
reponse_json(['ok' => $r['erreur'] === null, 'fait' => $r['fait'], 'reste' => $r['reste'], 'erreur' => $r['erreur']]);
case 'traduire_profil':
$moi = exiger_ecriture();
$c = $post ? candidat((int) ($_POST['id'] ?? 0)) : null;
if (!$c) {
rediriger('kanban');
}
$langue = isset(LANGUES_INTERFACE[$_POST['langue'] ?? '']) ? (string) $_POST['langue'] : langue_courante();
try {
traduire_profil($c, $langue);
flash(t('Profil traduit. Relisez la traduction : elle est automatique.'), 'succes');
} catch (RuntimeException $ex) {
flash($ex->getMessage(), 'erreur');
}
rediriger('profil', ['id' => $c['id']]);
// -----------------------------------------------------------------------
// Recherche
// -----------------------------------------------------------------------
case 'recherche':
exiger_connexion();
$q = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 200);
$filtres = filtres_recherche();
$r = Recherche::chercher(db(), $q, $filtres);
$r['resultats'] = traduire_titres($r['resultats']);
vue('recherche', [
'titre' => $q !== '' ? t('Recherche : %s', $q) : t('Tous les profils'),
'q' => $q,
'filtres' => $filtres,
'resultats' => $r['resultats'],
'analyse' => $r['analyse'],
'langues' => db()->query('SELECT DISTINCT langue FROM langues ORDER BY langue')->fetchAll(PDO::FETCH_COLUMN),
'cv' => cv_origine_par_candidat(array_column($r['resultats'], 'id')),
]);
break;
// -----------------------------------------------------------------------
// Modification, mise à jour et suppression d'un profil
// -----------------------------------------------------------------------
case 'profil_modifier':
exiger_ecriture();
$c = candidat((int) ($_GET['id'] ?? $_POST['id'] ?? 0));
if (!$c) {
http_response_code(404);
vue('introuvable', ['titre' => t('Profil introuvable')]);
break;
}
$erreur = null;
if ($post) {
try {
$n = modifier_candidat($c, $_POST);
flash($n ? tn($n, '%d champ modifié.', '%d champs modifiés.') : t('Aucun changement.'), $n ? 'succes' : 'info');
rediriger('profil', ['id' => $c['id']]);
} catch (InvalidArgumentException $ex) {
$erreur = $ex->getMessage();
}
}
vue('profil_modifier', ['titre' => t('Modifier %s', $c['prenom'] . ' ' . $c['nom']), 'c' => $c, 'erreur' => $erreur, 'saisie' => $post ? $_POST : $c]);
break;
case 'profil_maj':
$moi = exiger_ecriture();
$c = candidat((int) ($_POST['id'] ?? 0));
if (!$post || !$c) {
rediriger('kanban');
}
try {
$json = controler_fichier($_FILES['profil'] ?? [], ['json']);
$cv = fichier_fourni($_FILES['cv'] ?? null) ? controler_fichier($_FILES['cv'], ['pdf', 'docx', 'doc', 'odt', 'rtf', 'md', 'txt']) : null;
$rapport = fichier_fourni($_FILES['rapport'] ?? null) ? controler_fichier($_FILES['rapport'], ['md', 'txt', 'pdf', 'docx']) : null;
$profil = json_decode((string) file_get_contents($json['tmp']), true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException(t('Le fichier de profil n\'est pas un JSON valide.'));
}
$erreurs = Import::valider($profil);
if ($erreurs) {
throw new RuntimeException(t('Mise à jour refusée : %s', implode(' ', array_slice($erreurs, 0, 5)) . (count($erreurs) > 5 ? ' …' : '')));
}
$autre = Import::doublon(db(), $profil);
if ($autre !== null && $autre !== (int) $c['id']) {
throw new RuntimeException(t('L\'adresse email de ce fichier appartient à un autre profil.'));
}
$pdo = db();
$pdo->beginTransaction();
try {
Import::remplacer($pdo, (int) $c['id'], $profil, (int) $moi['id']);
journaliser((int) $c['id'], 'modification', 'Analyse', null, 'Mise à jour depuis ' . $profil['source']['fichier_origine']);
if ($cv) {
// Photo reprise du nouveau CV seulement si la fiche n'en a pas : un choix fait à la main n'est pas écrasé
$photo = empty($c['photo']) ? Photo::extraire($cv['tmp'], $cv['ext']) : null;
attacher_document((int) $c['id'], 'cv_origine', $cv);
if ($photo !== null) {
Photo::enregistrer($pdo, (int) $c['id'], $photo);
}
}
if ($rapport) {
attacher_document((int) $c['id'], 'rapport', $rapport);
}
$pdo->commit();
} catch (Throwable $ex) {
$pdo->rollBack();
throw $ex;
}
flash(t('Profil mis à jour. Le statut, les commentaires et les documents sont conservés.'), 'succes');
} catch (RuntimeException $ex) {
flash($ex->getMessage(), 'erreur');
}
rediriger('profil', ['id' => $c['id']]);
// Suppression : réservée au gestionnaire. Le profil part à la corbeille, d'où il est effacé après 30 jours.
case 'profil_suppr':
exiger_admin();
$c = candidat((int) ($_POST['id'] ?? 0));
if ($post && $c) {
mettre_a_la_corbeille($c);
flash(t('Profil %s mis à la corbeille. Il sera effacé pour de bon dans %d jours.', reference_profil($c), CORBEILLE_JOURS), 'succes');
}
rediriger(($_POST['retour'] ?? '') === 'conservation' ? 'conservation' : 'kanban');
// -----------------------------------------------------------------------
// Corbeille (gestionnaire) : restaurer un profil ou l'effacer sans attendre
// -----------------------------------------------------------------------
case 'corbeille':
exiger_admin();
if ($post) {
$c = candidat((int) ($_POST['id'] ?? 0), true);
$action = (string) ($_POST['action'] ?? '');
if (!$c || $c['supprime_le'] === null) {
flash(t('Ce profil n\'est plus dans la corbeille.'), 'erreur');
} elseif ($action === 'restaurer') {
restaurer_candidat($c);
flash(t('Profil %s restauré.', $c['prenom'] . ' ' . $c['nom']), 'succes');
} elseif ($action === 'effacer') {
supprimer_candidat($c);
flash(t('Profil %s effacé pour de bon, avec ses documents.', reference_profil($c)), 'succes');
}
rediriger('corbeille');
}
$effaces = purger_corbeille();
if ($effaces) {
flash(t($effaces > 1 ? '%d profils effacés : leur délai de %d jours était passé.' : '%d profil effacé : son délai de %d jours était passé.', $effaces, CORBEILLE_JOURS), 'info');
}
vue('corbeille', ['titre' => t('Corbeille'), 'liste' => traduire_titres(corbeille())]);
break;
case 'document_suppr':
exiger_ecriture();
$d = document((int) ($_POST['id'] ?? 0));
if ($post && $d) {
supprimer_document($d);
flash(t('Document supprimé.'), 'succes');
rediriger_fiche((int) $d['candidat_id'], 'documents');
}
rediriger('kanban');
case 'mot_cle':
exiger_ecriture();
$id = (int) ($_POST['id'] ?? 0);
if ($post && candidat($id)) {
$pdo = db();
if (($_POST['action'] ?? '') === 'supprimer') {
$pdo->prepare('DELETE FROM mots_cles WHERE id = ? AND candidat_id = ?')->execute([(int) ($_POST['mot_id'] ?? 0), $id]);
} else {
$type = (string) ($_POST['type'] ?? 'mot_cle');
$mot = mb_strtolower(mb_substr(trim((string) ($_POST['mot'] ?? '')), 0, 80));
if ($mot !== '' && isset(TYPES_MOT[$type])) {
$pdo->prepare('INSERT IGNORE INTO mots_cles (candidat_id, type, mot, manuel) VALUES (?, ?, ?, 1)')->execute([$id, $type, $mot]);
journaliser($id, 'modification', TYPES_MOT[$type], null, $mot);
}
}
Recherche::indexer($pdo, $id);
}
rediriger_fiche($id, 'mots-cles');
case 'rapport':
exiger_connexion();
$d = document((int) ($_GET['id'] ?? 0));
$chemin = $d ? dossier_stockage() . '/' . $d['chemin'] : null;
if (!$d || !is_file($chemin) || !in_array(strtolower(pathinfo($d['nom_fichier'], PATHINFO_EXTENSION)), ['md', 'txt'], true)) {
http_response_code(404);
vue('introuvable', ['titre' => t('Rapport introuvable')]);
break;
}
vue('rapport', [
'titre' => $d['nom_fichier'],
'd' => $d,
'c' => candidat((int) $d['candidat_id']),
'html' => markdown_vers_html((string) file_get_contents($chemin)),
]);
break;
// -----------------------------------------------------------------------
// Mon compte
// -----------------------------------------------------------------------
case 'compte':
$moi = exiger_connexion();
if ($post) {
$action = (string) ($_POST['action'] ?? 'mot_de_passe');
try {
if ($action === 'mot_de_passe') {
confirmer_mot_de_passe((string) ($_POST['actuel'] ?? ''));
if (($_POST['nouveau'] ?? '') !== ($_POST['confirmation'] ?? '')) {
throw new InvalidArgumentException(t('Les deux nouveaux mots de passe diffèrent.'));
}
changer_mot_de_passe((int) $moi['id'], (string) ($_POST['nouveau'] ?? ''));
garder_session_courante((int) $moi['id']);
journaliser(null, 'securite', 'mot_de_passe', null, $moi['identifiant']);
flash(t('Mot de passe modifié. Vos autres sessions sont fermées.'), 'succes');
} elseif ($action === 'codes') {
confirmer_mot_de_passe((string) ($_POST['mot_de_passe'] ?? ''));
$_SESSION['codes_a_montrer'] = generer_codes_secours((int) $moi['id']);
journaliser(null, 'securite', 'codes_secours', null, $moi['identifiant']);
rediriger('codes_secours');
} elseif ($action === 'sessions') {
fermer_sessions((int) $moi['id'], true);
journaliser(null, 'securite', 'sessions', null, $moi['identifiant']);
flash(t('Vos autres sessions sont fermées. Celle-ci reste ouverte.'), 'succes');
}
} catch (InvalidArgumentException $ex) {
flash($ex->getMessage(), 'erreur');
}
rediriger('compte');
}
vue('compte', [
'titre' => t('Mon compte'),
'moi' => $moi,
'compte' => compte_par_id((int) $moi['id']),
'derniere' => connexions((int) $moi['id'], 'reussie', 5),
]);
break;
// -----------------------------------------------------------------------
// Conservation des données et journal (administrateur)
// -----------------------------------------------------------------------
case 'conservation':
exiger_admin();
if ($post && ($_POST['action'] ?? '') === 'prolonger') {
$c = candidat((int) ($_POST['id'] ?? 0));
if ($c) {
prolonger_conservation($c, (int) config('conservation_mois'));
flash(t('Conservation prolongée de %d mois.', (int) config('conservation_mois')), 'succes');
}
rediriger('conservation');
}
vue('conservation', ['titre' => t('Conservation'), 'liste' => traduire_titres(candidats_a_echeance())]);
break;
case 'journal':
exiger_admin();
$qui = (int) ($_GET['utilisateur'] ?? 0);
vue('journal', ['titre' => t('Journal'), 'lignes' => journal($qui ?: null), 'comptes' => utilisateurs(), 'qui' => $qui]);
break;
// -----------------------------------------------------------------------
// Export CSV de la liste et tableau de bord
// -----------------------------------------------------------------------
case 'export_csv':
exiger_admin();
$r = Recherche::chercher(db(), mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 200), filtres_recherche());
$lignes = lignes_export(array_map('intval', array_column($r['resultats'], 'id')));
journaliser(null, 'export', 'csv', null, 'Liste CSV (' . count($lignes) . ' profils)');
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="vivier-profils-' . date('Y-m-d') . '.csv"');
header('Cache-Control: private, no-store');
$sortie = fopen('php://output', 'w');
fwrite($sortie, "\xEF\xBB\xBF"); // marque UTF-8 : Excel affiche les accents
// Une cellule qui commence par = + - @ serait lue comme une formule par un tableur
$neutre = static fn (string $v): string => preg_match('/^[=+\-@\t\r]/', $v) ? "'" . $v : $v;
if ($lignes) {
fputcsv($sortie, array_map('t', array_keys($lignes[0])), ';', '"', '');
}
foreach ($lignes as $l) {
fputcsv($sortie, array_map($neutre, array_values($l)), ';', '"', '');
}
exit;
case 'tableau':
exiger_connexion();
vue('tableau', ['titre' => t('Tableau de bord'), 's' => statistiques(), 'sauvegarde' => utilisateur()['role'] === 'admin' ? Sauvegarde::etat() : null]);
break;
// -----------------------------------------------------------------------
// Langue de l'interface
// -----------------------------------------------------------------------
case 'langue':
$langue = (string) ($_POST['langue'] ?? '');
if ($post && isset(LANGUES_INTERFACE[$langue])) {
setcookie('vivier_langue', $langue, ['expires' => time() + 365 * 86400, 'path' => '/', 'secure' => (bool) config('https'), 'httponly' => true, 'samesite' => 'Lax']);
if (utilisateur()) {
db()->prepare('UPDATE utilisateurs SET langue = ? WHERE id = ?')->execute([$langue, utilisateur()['id']]);
}
}
// Retour à la page d'où vient la demande, si elle est bien de ce site
$retour = (string) ($_POST['retour'] ?? '');
header('Location: ' . (preg_match('/^index\.php\?[\w=&%.+\-]*$/', $retour) ? $retour : url('kanban')));
exit;
// -----------------------------------------------------------------------
// Photo du candidat
// -----------------------------------------------------------------------
case 'photo':
exiger_connexion();
$req = db()->prepare('SELECT photo FROM candidats WHERE id = ? AND (supprime_le IS NULL OR ?)');
$req->execute([(int) ($_GET['id'] ?? 0), (int) est_admin()]);
$relatif = $req->fetchColumn();
$chemin = $relatif ? dossier_stockage() . '/' . $relatif : null;
if (!$chemin || !is_file($chemin)) {
http_response_code(404);
exit;
}
header('Content-Type: image/jpeg');
header('Content-Length: ' . filesize($chemin));
header('Cache-Control: private, max-age=86400'); // l'adresse change avec la photo
readfile($chemin);
exit;
case 'import_photo':
exiger_ecriture();
$imp = $_SESSION['import'] ?? null;
$chemin = $imp && preg_match('/^[a-f0-9]{32}$/', $imp['dossier']) ? dossier_stockage('tmp') . '/' . $imp['dossier'] . '/photo.jpg' : null;
if (!$chemin || !is_file($chemin)) {
http_response_code(404);
exit;
}
header('Content-Type: image/jpeg');
header('Cache-Control: private, no-store');
readfile($chemin);
exit;
case 'photo_maj':
exiger_ecriture();
$c = candidat((int) ($_POST['id'] ?? 0));
if (!$post || !$c) {
rediriger('kanban');
}
try {
if (($_POST['action'] ?? '') === 'retirer') {
Photo::supprimer(db(), (int) $c['id']);
journaliser((int) $c['id'], 'modification', 'Photo', null, 'Photo retirée');
flash(t('Photo retirée.'), 'succes');
} else {
$f = controler_fichier($_FILES['photo'] ?? [], ['jpg', 'jpeg', 'png', 'webp', 'gif']);
$jpeg = Photo::normaliser((string) file_get_contents($f['tmp']));
if ($jpeg === null) {
throw new RuntimeException(t('Cette image est illisible. Chargez un fichier JPEG ou PNG.'));
}
Photo::enregistrer(db(), (int) $c['id'], $jpeg);
journaliser((int) $c['id'], 'modification', 'Photo', null, 'Photo ajoutée');
flash(t('Photo enregistrée.'), 'succes');
}
} catch (RuntimeException $ex) {
flash($ex->getMessage(), 'erreur');
}
rediriger_fiche((int) $c['id'], 'photo');
// -----------------------------------------------------------------------
// Aide : les guides du dossier aide/
// -----------------------------------------------------------------------
case 'aide':
$moi = exiger_connexion();
$guide = (string) ($_GET['guide'] ?? 'guide');
if ($guide === 'administration' && $moi['role'] !== 'admin') {
http_response_code(403);
exit(t('Page réservée au gestionnaire des comptes.'));
}
$langue = (string) ($_GET['langue'] ?? '');
$aide = aide($guide, isset(LANGUES_INTERFACE[$langue]) ? $langue : langue_courante());
if ($aide === null) {
http_response_code(404);
vue('introuvable', ['titre' => t('Page introuvable')]);
break;
}
vue('aide', ['titre' => t(DOCUMENTS_AIDE[$guide]), 'guide' => $guide, 'aide' => $aide]);
break;
default:
exiger_connexion();
http_response_code(404);
vue('introuvable', ['titre' => t('Page introuvable')]);
}