/******************************************************* * GAS API — PWA ÉDITEUR PAYSAHAEP * Sahelia Clouds SARLU — Niamey, Niger * Version 1.0 — Juin 2026 * * Architecture: API REST autonome sur Google Apps Script * 100% indépendant du chatbot (aucune dépendance Listes sheet) * * DÉPLOIEMENT: * → Apps Script > Déployer > Nouvelle mise à jour * → Exécuter en tant que: Moi * → Accès: Tout le monde *******************************************************/ const API_CONFIG = { // ── Classeurs Google Sheets ────────────────────────── SS_USERS_ID: "1aYB-qalDVXrF1Zqv90BAPGEkPAwV3BYhTqW9ekWHj38", SS_PEDU_ID: "1wKBddtRnCA-iTkfZm9KgiYUDd0C7cs34EEkT0nBMeao", SS_HISTORIQUE_ID: "13H4ypwwHag5NInsy0E1fjHjMnmFLbbOMy0zM3rlM-cs", SS_GESTION_HIST_ID: "1Idn0UMhRgzLdNseMsD38t1NqrrwNrQOdU_K44xj-We4", // ── Noms des feuilles ──────────────────────────────── SH_USERS: "Users", SH_PEDU: "Feuille1", SH_HIST: "Historique", SH_GESTION: "SHistorique", // ── Sécurité ───────────────────────────────────────── TOKEN_SECRET: "PaysahAEP_SC_2026_xK9#mN", // ← Changer en prod TOKEN_TTL_H: 12, // Durée session: 12h // ── Google Drive (dossier racine pour photos) ──────── // Créer un dossier "PaysahAEP_Photos" dans Drive et coller l'ID ici DRIVE_FOLDER_ID: "1YKPH19k7AnbX7jEoT-8iNjcSaLlhPuq8", // ── Règles métier ──────────────────────────────────── PHOTO_REQUIRED: false, // ← Passer à true quand Helvetas l'exige PEDU_COLS: 33 // Colonnes lues dans Feuille1 (A→AG) }; /* * ┌─────────────────────────────────────────────────────┐ * │ STRUCTURE FEUILLE1 (référence pour le code) │ * │ Col A (0) → ID-PEDU │ * │ Col D (3) → Type PEDU (FE/BF/BP/BS/AB) │ * │ Col E (4) → Région │ * │ Col F (5) → Département │ * │ Col G (6) → Commune │ * │ Col H (7) → Village │ * │ Col I (8) → Localité │ * │ Col J (9) → Secteur │ * │ Col K (10) → Zone │ * │ Col L (11) → Latitude │ * │ Col M (12) → Longitude │ * │ Col N (13) → Altitude │ * │ Col O (14) → Débit │ * │ Col P (15) → Profondeur │ * │ Col Q (16) → Diamètre │ * │ Col T (19) ★ DERNIER INDEX (seuil validation) │ * │ Col U (20) → Responsable │ * │ Col V (21) → Téléphone │ * │ Col W (22) ★ DERNIÈRE DATE OPÉRATION │ * │ Col X (23) → Impayés précédents FCFA │ * │ Col Y (24) → Fréquence (jours) │ * │ Col Z (25) → LastOperDate │ * │ Col AA (26) → LastOilCompteurIndex │ * │ Col AB (27) → TelPediaResp │ * │ Col AC (28) → NomsPediaResp │ * │ Col AD (29) → Actif │ * │ Col AE (30) → Etat │ * │ Col AF (31) → Arre assainte │ * │ Col AG (32) → Tuite │ * └─────────────────────────────────────────────────────┘ */ /* ===================================================== * ROUTER PRINCIPAL * ===================================================== */ function doGet(e) { try { const params = e.parameter || {}; const action = (params.action || "").toLowerCase(); Logger.log(`[GET] action=${action} phone=${params.phone || "?"}`); switch (action) { case "login": return respond(handleLogin(params)); case "pedu": return respond(handleGetPedu(params)); case "ping": return respond({ success: true, ts: new Date().toISOString(), version: "1.0" }); default: return respond({ success: false, code: "UNKNOWN_ACTION" }, 400); } } catch (err) { Logger.log("❌ doGet: " + err.toString() + "\n" + err.stack); return respond({ success: false, code: "INTERNAL_ERROR", message: err.toString() }, 500); } } function doPost(e) { try { const body = JSON.parse((e.postData && e.postData.contents) || "{}"); const action = (body.action || "").toLowerCase(); Logger.log(`[POST] action=${action} phone=${body.phone || "?"} idSysteme=${body.idSysteme || "?"}`); switch (action) { case "submit-releves": return respond(handleSubmitReleves(body)); case "submit-gestion": return respond(handleSubmitGestion(body)); case "archive": return respond(handleArchive(body)); case "upload-photo": return respond(handleUploadPhoto(body)); case "logout": return respond(handleLogout(body)); default: return respond({ success: false, code: "UNKNOWN_ACTION" }, 400); } } catch (err) { Logger.log("❌ doPost: " + err.toString() + "\n" + err.stack); return respond({ success: false, code: "INTERNAL_ERROR", message: err.toString() }, 500); } } function respond(data) { return ContentService .createTextOutput(JSON.stringify(data)) .setMimeType(ContentService.MimeType.JSON); } /* ===================================================== * 1. LOGIN * GET ?action=login&phone=22789166358&code=12345 * ===================================================== */ function handleLogin(params) { const phone = normalizePhone(params.phone || ""); const code = (params.code || "").toString().trim(); if (!phone || !code) { return { success: false, code: "MISSING_PARAMS", message: "Phone et code requis" }; } // ── Recherche dans UsersChat ───────────────────────── const ss = SpreadsheetApp.openById(API_CONFIG.SS_USERS_ID); const sh = ss.getSheetByName(API_CONFIG.SH_USERS); const data = sh.getDataRange().getValues(); let userRow = null; for (let r = 1; r < data.length; r++) { if (normalizePhone(data[r][0]) === phone) { userRow = data[r]; break; } } if (!userRow) { return { success: false, code: "USER_NOT_FOUND", message: "Numéro non enregistré" }; } const status = (userRow[4] || "").toString().trim().toUpperCase(); // Col E const codeAgent = (userRow[6] || "").toString().trim(); // Col G const nom = (userRow[1] || "").toString().trim(); // Col B const communeRaw= (userRow[2] || "").toString().trim(); // Col C if (status !== "ACTIF") { return { success: false, code: "ACCOUNT_INACTIVE", message: "Compte inactif. Contactez l'administration." }; } if (codeAgent !== code) { return { success: false, code: "INVALID_CODE", message: "Code Agent incorrect" }; } // ── Générer et stocker le token ────────────────────── const token = buildToken(phone); const expiresAt = new Date().getTime() + (API_CONFIG.TOKEN_TTL_H * 3600 * 1000); const props = PropertiesService.getScriptProperties(); const tokenKey = "TOK_" + phone; props.setProperty(tokenKey, JSON.stringify({ token, expiresAt })); // ── Récupérer les ID-Systèmes disponibles ──────────── const communes = communeRaw.split(",").map(c => c.trim()).filter(Boolean); const idSystemes = resolveIdSystemesForCommunes(communes); Logger.log(`✅ Login: ${nom} (${phone}) — ${idSystemes.length} système(s) trouvé(s)`); return { success: true, agent: { phone, nom, communes, idSystemes // [{id:"42040", commune:"Tamou", nbPedu:47}, ...] }, token, expiresAt }; } /** * Scanne Feuille1 et retourne les ID-Systèmes correspondant aux communes de l'agent */ function resolveIdSystemesForCommunes(communes) { try { const ss = SpreadsheetApp.openById(API_CONFIG.SS_PEDU_ID); const sh = ss.getSheetByName(API_CONFIG.SH_PEDU); const lastRow = sh.getLastRow(); if (lastRow < 2) return []; // Lire A et G uniquement (ID-PEDU + Commune) const data = sh.getRange(2, 1, lastRow - 1, 7).getValues(); // A→G const map = {}; // idSysteme → { commune, count } for (const row of data) { const idPedu = (row[0] || "").toString().trim(); const commune = (row[6] || "").toString().trim(); // Col G if (!idPedu) continue; // Matcher commune const matched = communes.some(c => commune.toLowerCase().includes(c.toLowerCase()) || c.toLowerCase().includes(commune.toLowerCase()) ); if (!matched) continue; // Extraire ID-Système depuis ID-PEDU: ex FE42040001 → 42040 const m = idPedu.match(/^[A-Z]{2}(\d{5})/); if (!m) continue; const idSys = m[1]; if (!map[idSys]) { map[idSys] = { id: idSys, commune: commune, nbPedu: 0 }; } map[idSys].nbPedu++; } return Object.values(map); } catch (err) { Logger.log("❌ resolveIdSystemesForCommunes: " + err.toString()); return []; } } /* ===================================================== * 2. CHARGEMENT PEDU * GET ?action=pedu&idSysteme=42040&phone=XXX&token=XXX * ===================================================== */ function handleGetPedu(params) { const auth = validateToken(params.token, params.phone); if (!auth.valid) return { success: false, code: "UNAUTHORIZED", message: auth.message }; const idSysteme = (params.idSysteme || "").toString().trim(); if (!idSysteme || idSysteme.length !== 5) { return { success: false, code: "INVALID_ID_SYSTEME", message: "ID-Système doit être 5 chiffres" }; } const ss = SpreadsheetApp.openById(API_CONFIG.SS_PEDU_ID); const sh = ss.getSheetByName(API_CONFIG.SH_PEDU); const lastRow = sh.getLastRow(); if (lastRow < 2) return { success: true, idSysteme, peduList: [], total: 0 }; const data = sh.getRange(2, 1, lastRow - 1, API_CONFIG.PEDU_COLS).getValues(); const peduList = []; let commune = ""; for (let i = 0; i < data.length; i++) { const row = data[i]; const idPedu = (row[0] || "").toString().trim(); if (!idPedu) continue; // Filtrer par ID-Système if (!idPedu.includes(idSysteme)) continue; // Extraire type et SID depuis l'ID-PEDU const m = idPedu.match(/^([A-Z]{2})(\d{5})(\d+)$/); const type = m ? m[1] : (row[3] || "").toString().trim(); const sid = m ? m[3] : ""; if (!commune) commune = (row[6] || "").toString().trim(); const lastIndex = parseFloat(row[19]) || 0; // Col T ★ const lastDate = formatDateVal(row[22]); // Col W ★ peduList.push({ id: idPedu, idSysteme, type, sid, hasFinancial: type !== "FE", // Localisation region: str(row[4]), departement: str(row[5]), commune: str(row[6]), village: str(row[7]), localite: str(row[8]), secteur: str(row[9]), zone: str(row[10]), lat: flt(row[11]), lng: flt(row[12]), altitude: flt(row[13]), // Technique debit: str(row[14]), profondeur: str(row[15]), diametre: str(row[16]), // Suivi opérationnel ★★★ lastIndex, lastDate, lastImpayes: flt(row[23]), frequence: int(row[24]) || 30, // Responsable responsable: str(row[20]), tel: str(row[21]), // Statut actif: str(row[29]).toUpperCase() !== "NON", etat: str(row[30]), // Données brutes pour reconstruction Historique (colonnes W→AG) raw: { lastValueReg: str(row[22]), // W impayesFCFA: str(row[23]), // X frequence: str(row[24]), // Y lastOperDate: str(row[25]), // Z lastIndexCompteur: str(row[26]), // AA telPediaResp: str(row[27]), // AB nomsPediaResp: str(row[28]), // AC actif: str(row[29]), // AD etat: str(row[30]), // AE arreAssainte: str(row[31]), // AF tuite: str(row[32]) // AG } }); } Logger.log(`✅ /pedu: ${peduList.length} PEDU retournés pour idSysteme=${idSysteme}`); return { success: true, idSysteme, commune, peduList, total: peduList.length, lastSync: new Date().toISOString(), photoRequired: API_CONFIG.PHOTO_REQUIRED }; } /* ===================================================== * 3. SOUMISSION RELEVÉS → HISTORIQUE + UPDATE FEUILLE1 * POST { action:"submit-releves", token, phone, idSysteme, releves:[...] } * * Chaque relevé: * { idPedu, type, indexNouvel, cash, impayes, photoUrl, * gpsLat, gpsLng, timestamp, peduRaw (données brutes) } * ===================================================== */ function handleSubmitReleves(body) { const auth = validateToken(body.token, body.phone); if (!auth.valid) return { success: false, code: "UNAUTHORIZED", message: auth.message }; const idSysteme = (body.idSysteme || "").toString().trim(); const phone = normalizePhone(body.phone || ""); const releves = body.releves || []; if (!releves.length) return { success: false, code: "NO_RELEVES" }; // ── Charger Feuille1 pour validation et mise à jour ── const ssPedu = SpreadsheetApp.openById(API_CONFIG.SS_PEDU_ID); const shPedu = ssPedu.getSheetByName(API_CONFIG.SH_PEDU); const lastRowP = shPedu.getLastRow(); const dataPedu = shPedu.getRange(2, 1, lastRowP - 1, API_CONFIG.PEDU_COLS).getValues(); // Map ID-PEDU → { rowIndex (1-based dans la sheet), row } const peduMap = {}; dataPedu.forEach((row, i) => { const id = (row[0] || "").toString().trim(); if (id) peduMap[id] = { rowIndex: i + 2, row }; }); // ── Accès Historique ───────────────────────────────── const ssHist = SpreadsheetApp.openById(API_CONFIG.SS_HISTORIQUE_ID); const shHist = ssHist.getSheetByName(API_CONFIG.SH_HIST); const lastRowH = shHist.getLastRow(); // Références existantes pour déduplication const existingRefs = new Set(); if (lastRowH > 1) { shHist.getRange(2, 2, lastRowH - 1, 1).getValues().flat() .forEach(r => existingRefs.add(r.toString())); } const maintenant = new Date(); const dateExport = formatDateFull(maintenant); // dd/MM/yyyy HH:mm:ss const dateOnly = formatDateSimp(maintenant); // dd/MM/yyyy const resultats = []; const lignesHistorique = []; const feuille1Updates = []; // {rowIndex, newIndex, newDate} for (const rel of releves) { const { idPedu, type, indexNouvel, cash, impayes, photoUrl, gpsLat, gpsLng, peduRaw } = rel; // ── 1. PEDU existe ? ───────────────────────────── const entry = peduMap[idPedu]; if (!entry) { resultats.push({ idPedu, status: "ERROR", code: "PEDU_NOT_FOUND" }); Logger.log(`⚠️ PEDU non trouvé: ${idPedu}`); continue; } const row = entry.row; const lastIndex = parseFloat(row[19]) || 0; // Col T ★ // ── 2. VALIDATION HYDRAULIQUE ★★★ ───────────────── const newIdx = parseFloat(indexNouvel) || 0; if (newIdx < lastIndex) { resultats.push({ idPedu, status: "REJECTED", code: "INDEX_TOO_LOW", message: `Index ${newIdx} inférieur au dernier index ${lastIndex}`, lastIndex }); Logger.log(`❌ Validation échouée ${idPedu}: ${newIdx} < ${lastIndex}`); continue; } // ── 3. Référence unique ─────────────────────────── let ref; do { ref = String(Math.floor(Math.random() * 9e9) + 1e9); } while (existingRefs.has(ref)); existingRefs.add(ref); // ── 4. Construire ligne Historique (96 colonnes) ── // 95 colonnes existantes + col 96 (PhotoURL) const L = new Array(96).fill(""); // ─ Colonnes racines ─ L[0] = dateExport; // A: Date opération L[1] = ref; // B: Référence unique L[2] = dateOnly; // C: Date simple L[7] = idPedu; // H: ID-PEDU L[11] = cash || 0; // L: Cash L[13] = impayes || 0; // N: Impayés L[15] = (impayes > 0) ? "YES" : "NO"; // P: Tag IMPAYE // ─ Doublons nécessaires ─ L[27] = idPedu; // AB: ID-PEDU L[28] = idSysteme; // AC: ID-Système L[29] = str(row[2]); // AD: SID-PEDU (col C) L[30] = str(row[3]); // AE: Nature-PEDU (col D = type) // ─ Géo depuis Feuille1 ─ L[31] = str(row[4]); // AF: Région L[32] = str(row[5]); // AG: Département L[33] = str(row[6]); // AH: Commune L[34] = str(row[7]); // AI: Village L[35] = str(row[8]); // AJ: Localité L[36] = str(row[9]); // AK: Secteur L[37] = str(row[10]); // AL: Zone L[38] = flt(row[11]); // AM: Latitude L[39] = flt(row[12]); // AN: Longitude L[40] = flt(row[13]); // AO: Altitude L[41] = str(row[14]); // AP: Débit L[42] = str(row[15]); // AQ: Profondeur L[43] = str(row[16]); // AR: Diamètre // ─ Colonnes calculées / fixes ─ L[44] = "m3/" + type; // AS: m3/TypePEDU (calculé) L[45] = 0; // AT: VQtE-min (fixe = 0) L[46] = 1000000; // AU: VQtE-maxi (fixe) // ─ Données opérationnelles depuis Feuille1 ─ L[47] = str(row[22]); // AV: VLastValueReg (W) L[48] = str(row[23]); // AW: VImpayés FCFA (X) L[49] = str(row[24]); // AX: VFrequence (Y) L[50] = str(row[25]); // AY: VLastOperDate (Z) L[51] = str(row[26]); // AZ: VLastIndexCompteur (AA) L[52] = str(row[27]); // BA: VTelPediaResp (AB) L[53] = str(row[28]); // BB: VNomsPediaResp (AC) L[54] = str(row[29]); // BC: VActif (AD) L[55] = str(row[30]); // BD: VEtat (AE) L[56] = str(row[31]); // BE: VArre assainte (AF) L[57] = str(row[32]); // BF: VTuite (AG) // ─ Col 96: URL Photo (hors structure 95 cols) ─ L[95] = photoUrl || ""; // Col 96: PhotoURL lignesHistorique.push(L); // ── 5. Préparer mise à jour Feuille1 ───────────── feuille1Updates.push({ rowIndex: entry.rowIndex, newIndex: newIdx, newDate: dateOnly }); resultats.push({ idPedu, status: "OK", ref }); Logger.log(`✅ Relevé validé: ${idPedu} → index ${newIdx} (was ${lastIndex})`); } // ── 6. ÉCRITURE BATCH Historique ───────────────────── if (lignesHistorique.length > 0) { const startRow = shHist.getLastRow() + 1; shHist.getRange(startRow, 1, lignesHistorique.length, 96) .setValues(lignesHistorique); Logger.log(`📤 ${lignesHistorique.length} lignes écrites dans Historique`); } // ── 7. MISE À JOUR BATCH Feuille1 (col T + W) ★★★ ── feuille1Updates.forEach(upd => { shPedu.getRange(upd.rowIndex, 20).setValue(upd.newIndex); // Col T (index 20) shPedu.getRange(upd.rowIndex, 23).setValue(upd.newDate); // Col W (index 23) }); SpreadsheetApp.flush(); const okCount = resultats.filter(r => r.status === "OK").length; const errCount = resultats.filter(r => r.status !== "OK").length; Logger.log(`✅ submit-releves: ${okCount} OK, ${errCount} erreurs`); return { success: okCount > 0, resultats, exported: okCount, errors: errCount }; } /* ===================================================== * 4. SOUMISSION PARAMÈTRES GESTION → SHISTORIQUE * POST { action:"submit-gestion", token, phone, idSysteme, * nomAgent, params:{C3:..., C4:..., ...} } * ===================================================== */ function handleSubmitGestion(body) { const auth = validateToken(body.token, body.phone); if (!auth.valid) return { success: false, code: "UNAUTHORIZED", message: auth.message }; const idSysteme = (body.idSysteme || "").toString().trim(); const phone = normalizePhone(body.phone || ""); const nomAgent = (body.nomAgent || "").toString(); const params = body.params || {}; // ── Vérification cellules obligatoires ─────────────── const REQUIRED = [ "C3","C4","C5","C6", "C9","C10","C11","C12", "C15","C16", "C19","C20", "C26","C27","C28","C29","C30","C31","C32" ]; const manquantes = REQUIRED.filter(k => { const v = params[k]; return v === undefined || v === null || v.toString().trim() === ""; }); if (manquantes.length > 0) { return { success: false, code: "MISSING_REQUIRED_PARAMS", message: `Champs obligatoires non remplis: ${manquantes.join(", ")}`, manquantes }; } // ── Vérification doublon mois en cours ─────────────── // (L'agent peut sauvegarder et reprendre dans le mois, // mais ne peut soumettre définitivement qu'une fois par mois) const ss = SpreadsheetApp.openById(API_CONFIG.SS_GESTION_HIST_ID); const sh = ss.getSheetByName(API_CONFIG.SH_GESTION); const lastRow = sh.getLastRow(); const now = new Date(); if (lastRow > 1) { const existing = sh.getRange(2, 1, lastRow - 1, 3).getValues(); // A, B, C for (const ligne of existing) { const dateCell = ligne[0]; const idSys = (ligne[2] || "").toString(); if (idSys !== idSysteme) continue; const dateObj = toDate(dateCell); if (dateObj && dateObj.getMonth() === now.getMonth() && dateObj.getFullYear() === now.getFullYear()) { return { success: false, code: "ALREADY_SUBMITTED_THIS_MONTH", message: `Paramètres déjà soumis ce mois pour le système ${idSysteme}` }; } } } // ── Construire ligne SHistorique (32 colonnes) ─────── const ref = String(Math.floor(Math.random() * 9e9) + 1e9); const dateFull = formatDateFull(now); const ligne = [ dateFull, // Col 1: Date export ref, // Col 2: Référence idSysteme, // Col 3: ID-Système "", // Col 4: (non applicable en mode PWA) "", // Col 5 "", // Col 6 phone, // Col 7: Téléphone agent nomAgent, // Col 8: Nom agent p(params, "C3"), // Col 9: Prix bidon 25L bornes p(params, "C4"), // Col 10: Prix m3 BF p(params, "C5"), // Col 11: Prix m3 BP p(params, "C6"), // Col 12: Prix m3 BS p(params, "C7"), // Col 13: Prix m3 AB p(params, "C9"), // Col 14: Salaires p(params, "C10"), // Col 15: Entretien p(params, "C11"), // Col 16: Energie p(params, "C12"), // Col 17: Autres dépenses exploit. p(params, "C13"), // Col 18: Dépenses gestion admin p(params, "C15"), // Col 19: Remise SMEA p(params, "C16"), // Col 20: Remise FRIE p(params, "C17"), // Col 21: Dépôt Fond Garantie p(params, "C19"), // Col 22: Heures fonct. groupe p(params, "C20"), // Col 23: Jours interruption p(params, "C21"), // Col 24: Consommation groupe L/h p(params, "C23"), // Col 25: Autres renseignements (texte) p(params, "C26"), // Col 26: FRIE Solde p(params, "C27"), // Col 27: Fonds Garanties Recettes p(params, "C28"), // Col 28: FRIE Dépenses renouvellement p(params, "C29"), // Col 29: FRIE Dépenses extensions p(params, "C30"), // Col 30: SMEA Prestations SAC/SPE p(params, "C31"), // Col 31: SMEA Subventions AUSPE p(params, "C32") // Col 32: SMEA Autres dépenses ]; sh.appendRow(ligne); SpreadsheetApp.flush(); Logger.log(`✅ Gestion soumise: idSysteme=${idSysteme} ref=${ref}`); return { success: true, ref, idSysteme, message: "Paramètres de gestion enregistrés avec succès" }; } /* ===================================================== * 5. ARCHIVAGE FINAL * POST { action:"archive", token, phone, idSysteme } * ===================================================== */ function handleArchive(body) { const auth = validateToken(body.token, body.phone); if (!auth.valid) return { success: false, code: "UNAUTHORIZED", message: auth.message }; const idSysteme = (body.idSysteme || "").toString().trim(); // Vérifier paramètres gestion soumis ce mois if (!checkGestionSoumiseCeMois(idSysteme)) { return { success: false, code: "GESTION_NOT_SUBMITTED", message: "Paramètres de gestion non encore soumis pour ce mois" }; } // Vérifier qu'il y a des relevés ce mois const nbReleves = countRelevesCeMois(idSysteme); if (nbReleves === 0) { return { success: false, code: "NO_RELEVES", message: "Aucun relevé trouvé dans l'historique pour ce mois" }; } Logger.log(`✅ Archive confirmée: idSysteme=${idSysteme} — ${nbReleves} relevés + gestion`); return { success: true, idSysteme, nbReleves, message: `Archivage complet: ${nbReleves} relevés + paramètres de gestion` }; } function checkGestionSoumiseCeMois(idSysteme) { try { const ss = SpreadsheetApp.openById(API_CONFIG.SS_GESTION_HIST_ID); const sh = ss.getSheetByName(API_CONFIG.SH_GESTION); const last = sh.getLastRow(); if (last < 2) return false; const now = new Date(); const data = sh.getRange(2, 1, last - 1, 3).getValues(); for (const ligne of data) { if ((ligne[2] || "").toString() !== idSysteme) continue; const d = toDate(ligne[0]); if (d && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()) { return true; } } return false; } catch (e) { Logger.log("❌ checkGestionSoumiseCeMois: " + e.toString()); return false; } } function countRelevesCeMois(idSysteme) { try { const ss = SpreadsheetApp.openById(API_CONFIG.SS_HISTORIQUE_ID); const sh = ss.getSheetByName(API_CONFIG.SH_HIST); const last = sh.getLastRow(); if (last < 2) return 0; const now = new Date(); // Lire col A (date) et col AC=index29 (ID-Système) const data = sh.getRange(2, 1, last - 1, 30).getValues(); let count = 0; for (const ligne of data) { if ((ligne[28] || "").toString() !== idSysteme) continue; // Col AC = index 28 const d = toDate(ligne[0]); if (d && d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()) { count++; } } return count; } catch (e) { Logger.log("❌ countRelevesCeMois: " + e.toString()); return 0; } } /* ===================================================== * 6. UPLOAD PHOTO → GOOGLE DRIVE * POST { action:"upload-photo", token, phone, idPedu, * idSysteme, imageBase64, mimeType } * ===================================================== */ function handleUploadPhoto(body) { const auth = validateToken(body.token, body.phone); if (!auth.valid) return { success: false, code: "UNAUTHORIZED", message: auth.message }; const { idPedu, idSysteme, imageBase64, mimeType } = body; if (!imageBase64 || !idPedu) { return { success: false, code: "MISSING_DATA", message: "idPedu et imageBase64 requis" }; } try { // Décoder base64 const bytes = Utilities.base64Decode(imageBase64); const blob = Utilities.newBlob(bytes, mimeType || "image/jpeg"); const yymm = Utilities.formatDate(new Date(), "GMT+1", "yyyy-MM"); const ts = Utilities.formatDate(new Date(), "GMT+1", "yyyyMMdd_HHmm"); const fname = `${idPedu}_${ts}.jpg`; blob.setName(fname); // Trouver/créer dossier Drive: PaysahAEP_Photos/{idSysteme}/{yyyy-MM} const folder = getOrCreateFolder(idSysteme || "general", yymm); const file = folder.createFile(blob); file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW); const url = `https://drive.google.com/uc?id=${file.getId()}`; Logger.log(`📷 Photo uploadée: ${fname} → ${url}`); return { success: true, idPedu, filename: fname, photoUrl: url, fileId: file.getId() }; } catch (err) { Logger.log("❌ handleUploadPhoto: " + err.toString()); return { success: false, code: "UPLOAD_FAILED", message: err.toString() }; } } function getOrCreateFolder(idSysteme, yearMonth) { let root; try { root = DriveApp.getFolderById(API_CONFIG.DRIVE_FOLDER_ID); } catch (e) { // Dossier configuré introuvable → créer à la racine const existing = DriveApp.getFoldersByName("PaysahAEP_Photos"); root = existing.hasNext() ? existing.next() : DriveApp.createFolder("PaysahAEP_Photos"); } const getOrCreate = (parent, name) => { const it = parent.getFoldersByName(name); return it.hasNext() ? it.next() : parent.createFolder(name); }; const sysFolder = getOrCreate(root, idSysteme); const monthFolder = getOrCreate(sysFolder, yearMonth); return monthFolder; } /* ===================================================== * 7. LOGOUT * POST { action:"logout", token, phone } * ===================================================== */ function handleLogout(body) { const phone = normalizePhone(body.phone || ""); const tokenKey = "TOK_" + phone; PropertiesService.getScriptProperties().deleteProperty(tokenKey); Logger.log(`👋 Logout: ${phone}`); return { success: true }; } /* ===================================================== * SÉCURITÉ — TOKEN * ===================================================== */ function buildToken(phone) { const day = Utilities.formatDate(new Date(), "GMT+1", "yyyyMMdd"); const raw = `${phone}:${day}:${API_CONFIG.TOKEN_SECRET}`; const sig = Utilities.computeHmacSha256Signature(raw, API_CONFIG.TOKEN_SECRET); return bytesToHex(sig); } function validateToken(token, phone) { if (!token || !phone) return { valid: false, message: "Token ou phone manquant" }; const norm = normalizePhone(phone); const tokenKey = "TOK_" + norm; const stored = PropertiesService.getScriptProperties().getProperty(tokenKey); if (!stored) return { valid: false, message: "Session expirée. Reconnectez-vous." }; let data; try { data = JSON.parse(stored); } catch (e) { return { valid: false, message: "Token corrompu" }; } if (data.token !== token) return { valid: false, message: "Token invalide" }; if (new Date().getTime() > data.expiresAt) { PropertiesService.getScriptProperties().deleteProperty(tokenKey); return { valid: false, message: "Session expirée (12h). Reconnectez-vous." }; } return { valid: true }; } /* ===================================================== * UTILITAIRES * ===================================================== */ function normalizePhone(v) { let n = (v || "").toString().replace(/\D/g, ""); // Retirer indicatif Niger 227 si présent if (n.startsWith("227") && n.length === 11) n = n.slice(3); return n; } function str(v) { return (v === null || v === undefined) ? "" : v.toString(); } function flt(v) { const n = parseFloat(v); return isNaN(n) ? 0 : n; } function int(v) { const n = parseInt(v); return isNaN(n) ? 0 : n; } function p(params, key) { const v = params[key]; return (v === undefined || v === null) ? "" : v; } function formatDateFull(d) { return Utilities.formatDate(d || new Date(), "GMT+1", "dd/MM/yyyy HH:mm:ss"); } function formatDateSimp(d) { return Utilities.formatDate(d || new Date(), "GMT+1", "dd/MM/yyyy"); } function formatDateVal(val) { if (!val) return ""; if (val instanceof Date) return formatDateSimp(val); return val.toString(); } function toDate(val) { if (!val) return null; if (val instanceof Date) return val; if (typeof val === "string") { // Formats: "dd/MM/yyyy HH:mm:ss" ou "dd/MM/yyyy" const m = val.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/); if (m) return new Date(parseInt(m[3]), parseInt(m[2]) - 1, parseInt(m[1])); } return null; } function bytesToHex(bytes) { return Array.from(bytes).map(b => ('0' + (b & 0xFF).toString(16)).slice(-2)).join(''); } /* ===================================================== * FONCTIONS DE TEST (exécuter manuellement) * ===================================================== */ function testPing() { const result = handleRequest({ parameter: { action: "ping" } }, "GET"); Logger.log(result.getContent()); } function testLogin() { const result = handleLogin({ phone: "22789166358", code: "58291" }); Logger.log(JSON.stringify(result, null, 2)); } function testGetPedu() { // D'abord faire testLogin() pour avoir un token const props = PropertiesService.getScriptProperties(); const stored = props.getProperty("TOK_22789166358"); if (!stored) { Logger.log("❌ Pas de token. Faites testLogin() d'abord."); return; } const { token } = JSON.parse(stored); const result = handleGetPedu({ phone: "22789166358", token: token, idSysteme: "42040" }); Logger.log(`Total PEDU: ${result.total}`); if (result.peduList && result.peduList.length > 0) { Logger.log("Exemple PEDU: " + JSON.stringify(result.peduList[0], null, 2)); } } function testSubmitReleve() { const props = PropertiesService.getScriptProperties(); const stored = props.getProperty("TOK_22789166358"); if (!stored) { Logger.log("❌ Pas de token."); return; } const { token } = JSON.parse(stored); const result = handleSubmitReleves({ token: token, phone: "22789166358", idSysteme: "42040", releves: [{ idPedu: "FE42040001", type: "FE", indexNouvel: 9999, // ← Mettre un index > au dernier index réel cash: null, impayes: null, photoUrl: "" }] }); Logger.log(JSON.stringify(result, null, 2)); } function diagTokens() { const props = PropertiesService.getScriptProperties(); const allProps = props.getProperties(); const tokens = Object.keys(allProps).filter(k => k.startsWith("TOK_")); Logger.log(`Tokens actifs: ${tokens.length}`); tokens.forEach(k => { const d = JSON.parse(allProps[k]); const remainMs = d.expiresAt - Date.now(); Logger.log(` ${k}: expire dans ${Math.round(remainMs / 60000)} min`); }); } function purgeExpiredTokens() { const props = PropertiesService.getScriptProperties(); const allProps = props.getProperties(); let count = 0; Object.keys(allProps).filter(k => k.startsWith("TOK_")).forEach(k => { try { const d = JSON.parse(allProps[k]); if (Date.now() > d.expiresAt) { props.deleteProperty(k); count++; } } catch (e) { props.deleteProperty(k); count++; } }); Logger.log(`🧹 ${count} token(s) expirés supprimés`); } /** * SETUP — À exécuter UNE SEULE FOIS après déploiement * Vérifie l'accès à tous les classeurs et ajoute le header PhotoURL (col 96) */ function setupEditeurAPI() { Logger.log("=== SETUP PWA ÉDITEUR PAYSAHAEP ===\n"); const checks = [ { label: "Users", id: API_CONFIG.SS_USERS_ID, sheet: API_CONFIG.SH_USERS }, { label: "Feuille1", id: API_CONFIG.SS_PEDU_ID, sheet: API_CONFIG.SH_PEDU }, { label: "Historique", id: API_CONFIG.SS_HISTORIQUE_ID, sheet: API_CONFIG.SH_HIST }, { label: "SHistorique", id: API_CONFIG.SS_GESTION_HIST_ID, sheet: API_CONFIG.SH_GESTION } ]; let allOk = true; // 1. Vérifier l'accès à chaque classeur/onglet for (const c of checks) { try { const ss = SpreadsheetApp.openById(c.id); const sh = ss.getSheetByName(c.sheet); if (!sh) { Logger.log(`❌ Onglet "${c.sheet}" introuvable dans "${c.label}" (id: ${c.id})`); allOk = false; continue; } const rows = sh.getLastRow(); const cols = sh.getLastColumn(); Logger.log(`✅ ${c.label} → onglet "${c.sheet}" OK (${rows} lignes, ${cols} colonnes)`); } catch (err) { Logger.log(`❌ Impossible d'ouvrir "${c.label}": ${err.toString()}`); Logger.log(" → Vérifiez que le compte GAS a accès à ce classeur"); allOk = false; } } // 2. Ajouter header PhotoURL en colonne 96 de Historique (si absent) Logger.log("\n--- Colonne 96 (PhotoURL) dans Historique ---"); try { const ssHist = SpreadsheetApp.openById(API_CONFIG.SS_HISTORIQUE_ID); const shHist = ssHist.getSheetByName(API_CONFIG.SH_HIST); const lastCol = shHist.getLastColumn(); Logger.log(`Nombre de colonnes actuelles: ${lastCol}`); if (lastCol < 96) { // La col 96 n'existe pas encore → ajouter le header shHist.getRange(1, 96).setValue("PhotoURL"); SpreadsheetApp.flush(); Logger.log("✅ Header 'PhotoURL' ajouté en colonne 96"); } else { const existingHeader = shHist.getRange(1, 96).getValue(); if (existingHeader === "PhotoURL") { Logger.log("✅ Colonne 96 déjà configurée ('PhotoURL')"); } else { Logger.log(`⚠️ Colonne 96 existe mais contient: "${existingHeader}" — attendu: "PhotoURL"`); Logger.log(" → À corriger manuellement si nécessaire"); } } } catch (err) { Logger.log("❌ Erreur colonne PhotoURL: " + err.toString()); allOk = false; } // 3. Vérifier les ScriptProperties (tokens) Logger.log("\n--- ScriptProperties ---"); try { const props = PropertiesService.getScriptProperties(); props.setProperty("SETUP_TEST", "ok"); props.deleteProperty("SETUP_TEST"); Logger.log("✅ ScriptProperties accessibles (stockage tokens OK)"); } catch (err) { Logger.log("❌ ScriptProperties inaccessibles: " + err.toString()); allOk = false; } // 4. Vérifier Drive (dossier photos) Logger.log("\n--- Google Drive (photos) ---"); if (API_CONFIG.DRIVE_FOLDER_ID === "REMPLACER_PAR_ID_DOSSIER_DRIVE") { Logger.log("⚠️ DRIVE_FOLDER_ID non configuré — les photos iront dans un dossier auto à la racine"); } else { try { DriveApp.getFolderById(API_CONFIG.DRIVE_FOLDER_ID); Logger.log("✅ Dossier Drive accessible"); } catch (err) { Logger.log("❌ Dossier Drive inaccessible: " + err.toString()); Logger.log(" → Créez le dossier et collez son ID dans API_CONFIG.DRIVE_FOLDER_ID"); } } // 5. Résumé Logger.log("\n============================="); Logger.log(allOk ? "✅ SETUP RÉUSSI — Le GAS Éditeur est opérationnel" : "❌ SETUP INCOMPLET — Corrigez les erreurs ci-dessus avant de déployer" ); Logger.log("============================="); }