| const CONFIG = Object.freeze({ |
| folderId: "1QBsYtQtUp5-uhqjDRugvxHZinozIu6YY", |
| spreadsheetId: "1_LiYMMphsJaRRJje0pxKrNSiC1ahBS3GnHM01vPrrCk", |
| sheetName: "الورقة1", |
| timezone: "Asia/Riyadh", |
| maxPhotos: 3, |
| maxPhotoBytes: 3 * 1024 * 1024, |
| }); |
|
|
| const HEADERS = [ |
| "رقم التوثيق", |
| "تاريخ ووقت التسجيل", |
| "اسم الباحث/ة", |
| "اسم المنشأة", |
| "السجل التجاري", |
| "رقم العقد", |
| "المدينة الصناعية", |
| "حالة التوثيق", |
| "الإفادة الميدانية", |
| "روابط الصور", |
| "رابط مجلد المنشأة", |
| "عدد الصور", |
| "مفتاح العينة", |
| "آخر تعديل", |
| ]; |
|
|
| function doGet(event) { |
| try { |
| const params = (event && event.parameter) || {}; |
| const action = String(params.action || "health"); |
| if (action === "health") { |
| return jsonResponse({ ok: true, service: "ICS2 Documentation", version: 4 }); |
| } |
|
|
| if (action === "status") { |
| validateResearcherAccess(params.accessCode); |
| return jsonResponse({ |
| ok: true, |
| generatedAt: new Date().toISOString(), |
| records: getDocumentationRecords(true), |
| }); |
| } |
|
|
| if (action === "admin") { |
| validateSupervisorAccess(params.accessCode); |
| return jsonResponse({ |
| ok: true, |
| generatedAt: new Date().toISOString(), |
| timezone: CONFIG.timezone, |
| records: getDocumentationRecords(true), |
| }); |
| } |
|
|
| throw new Error("الطلب غير معروف."); |
| } catch (error) { |
| return jsonResponse({ ok: false, error: String(error.message || error) }); |
| } |
| } |
|
|
| function authorizeService() { |
| DriveApp.getFolderById(CONFIG.folderId).getName(); |
| SpreadsheetApp.openById(CONFIG.spreadsheetId).getName(); |
| return "AUTHORIZED"; |
| } |
|
|
| function doPost(event) { |
| const lock = LockService.getScriptLock(); |
| try { |
| lock.waitLock(20000); |
| const request = JSON.parse(event.postData.contents || "{}"); |
| validateResearcherAccess(request.accessCode); |
| validateRequest(request); |
|
|
| const sheet = getSheet(); |
| ensureHeaders(sheet); |
| if (request.action === "updateEvidence") { |
| return jsonResponse(updateEvidence(sheet, request)); |
| } |
|
|
| return jsonResponse(createEvidence(sheet, request)); |
| } catch (error) { |
| return jsonResponse({ ok: false, error: String(error.message || error) }); |
| } finally { |
| try { |
| lock.releaseLock(); |
| } catch (_) {} |
| } |
| } |
|
|
| function createEvidence(sheet, request) { |
| const existingIds = sheet |
| .getRange(1, 1, Math.max(sheet.getLastRow(), 1), 1) |
| .getDisplayValues() |
| .flat(); |
| if (existingIds.includes(request.documentationId)) { |
| return { ok: true, duplicate: true, documentationId: request.documentationId }; |
| } |
|
|
| const now = new Date(); |
| const folder = getEvidenceFolder(request); |
| const photoUrls = request.photos.map((photo, index) => savePhoto(folder, photo, request, index)); |
| sheet.appendRow([ |
| request.documentationId, |
| now, |
| safeText(request.researcher), |
| safeText(request.establishmentName), |
| safeText(request.commercialRecord), |
| safeText(request.contractNumber) || "لا يوجد رقم عقد", |
| safeText(request.city), |
| safeText(request.fieldStatus), |
| safeText(request.statement), |
| photoUrls.join("\n"), |
| folder.getUrl(), |
| photoUrls.length, |
| safeText(request.sampleKey), |
| now, |
| ]); |
|
|
| return evidenceResponse(request, folder.getUrl(), photoUrls, now, now); |
| } |
|
|
| function updateEvidence(sheet, request) { |
| const rowNumber = findDocumentationRow(sheet, request.documentationId); |
| if (!rowNumber) throw new Error("تعذر العثور على التوثيق المطلوب تعديله."); |
|
|
| const row = sheet.getRange(rowNumber, 1, 1, HEADERS.length).getValues()[0]; |
| if ( |
| safeText(row[12]) && |
| safeText(row[12]) !== safeText(request.sampleKey) && |
| safeText(row[4]) !== safeText(request.commercialRecord) |
| ) { |
| throw new Error("لا يمكن تعديل توثيق عينة أخرى."); |
| } |
|
|
| const oldPhotoUrls = splitUrls(row[9]); |
| const keptPhotoUrls = Array.isArray(request.keptPhotoUrls) |
| ? request.keptPhotoUrls.map(safeText).filter((url) => oldPhotoUrls.includes(url)) |
| : oldPhotoUrls; |
| if (keptPhotoUrls.length + request.photos.length > CONFIG.maxPhotos) { |
| throw new Error("الحد الأعلى 3 صور."); |
| } |
|
|
| const folder = row[10] ? getFolderByUrl(row[10], request) : getEvidenceFolder(request); |
| const newPhotoUrls = request.photos.map((photo, index) => |
| savePhoto(folder, photo, request, keptPhotoUrls.length + index), |
| ); |
| const updatedAt = new Date(); |
| const photoUrls = [...keptPhotoUrls, ...newPhotoUrls]; |
| sheet.getRange(rowNumber, 8, 1, 7).setValues([[ |
| safeText(request.fieldStatus), |
| safeText(request.statement), |
| photoUrls.join("\n"), |
| folder.getUrl(), |
| photoUrls.length, |
| safeText(request.sampleKey), |
| updatedAt, |
| ]]); |
| oldPhotoUrls |
| .filter((url) => !keptPhotoUrls.includes(url)) |
| .forEach(trashDriveFileByUrl); |
|
|
| const documentedAt = row[1] instanceof Date ? row[1] : updatedAt; |
| return evidenceResponse(request, folder.getUrl(), photoUrls, documentedAt, updatedAt, true); |
| } |
|
|
| function evidenceResponse(request, folderUrl, photoUrls, documentedAt, updatedAt, updated) { |
| return { |
| ok: true, |
| updated: Boolean(updated), |
| documentationId: request.documentationId, |
| sampleKey: safeText(request.sampleKey), |
| researcher: safeText(request.researcher), |
| establishmentName: safeText(request.establishmentName), |
| commercialRecord: safeText(request.commercialRecord), |
| contractNumber: safeText(request.contractNumber), |
| city: safeText(request.city), |
| fieldStatus: safeText(request.fieldStatus), |
| statement: safeText(request.statement), |
| documentedAt: documentedAt.toISOString(), |
| documentedDate: Utilities.formatDate(documentedAt, CONFIG.timezone, "yyyy-MM-dd"), |
| updatedAt: updatedAt.toISOString(), |
| folderUrl, |
| photoUrls, |
| photoCount: photoUrls.length, |
| }; |
| } |
|
|
| function findDocumentationRow(sheet, documentationId) { |
| if (sheet.getLastRow() < 2) return 0; |
| const ids = sheet.getRange(2, 1, sheet.getLastRow() - 1, 1).getDisplayValues().flat(); |
| const index = ids.indexOf(String(documentationId)); |
| return index < 0 ? 0 : index + 2; |
| } |
|
|
| function getFolderByUrl(url, request) { |
| const id = driveIdFromUrl(url); |
| return id ? DriveApp.getFolderById(id) : getEvidenceFolder(request); |
| } |
|
|
| function trashDriveFileByUrl(url) { |
| const id = driveIdFromUrl(url); |
| if (!id) return; |
| try { |
| DriveApp.getFileById(id).setTrashed(true); |
| } catch (_) {} |
| } |
|
|
| function driveIdFromUrl(url) { |
| const value = String(url || ""); |
| const match = |
| value.match(/\/folders\/([a-zA-Z0-9_-]+)/) || |
| value.match(/\/d\/([a-zA-Z0-9_-]+)/) || |
| value.match(/[?&]id=([a-zA-Z0-9_-]+)/); |
| return match ? match[1] : ""; |
| } |
|
|
| function validateResearcherAccess(accessCode) { |
| const expected = PropertiesService.getScriptProperties().getProperty("ACCESS_CODE") || "20302030"; |
| if (String(accessCode || "") !== expected) throw new Error("غير مصرح بالوصول."); |
| } |
|
|
| function validateSupervisorAccess(accessCode) { |
| const expected = PropertiesService.getScriptProperties().getProperty("SUPERVISOR_CODE") || "1448"; |
| if (String(accessCode || "") !== expected) throw new Error("غير مصرح بالوصول."); |
| } |
|
|
| function validateRequest(request) { |
| if (!request.documentationId) throw new Error("رقم التوثيق مفقود."); |
| if (!safeText(request.sampleKey)) throw new Error("مفتاح العينة مفقود."); |
| if (!safeText(request.researcher)) throw new Error("اسم الباحث مفقود."); |
| if (!safeText(request.establishmentName)) throw new Error("اسم المنشأة مفقود."); |
| if (!safeText(request.fieldStatus)) throw new Error("حالة التوثيق مطلوبة."); |
| if (!safeText(request.statement)) throw new Error("الإفادة الميدانية مطلوبة."); |
| if (!Array.isArray(request.photos)) request.photos = []; |
| if (request.photos.length > CONFIG.maxPhotos) throw new Error("الحد الأعلى 3 صور."); |
| } |
|
|
| function getDocumentationRecords(includeDetails) { |
| const sheet = getSheet(); |
| ensureHeaders(sheet); |
| const lastRow = sheet.getLastRow(); |
| if (lastRow < 2) return []; |
|
|
| const values = sheet.getRange(2, 1, lastRow - 1, HEADERS.length).getValues(); |
| return values |
| .filter((row) => row.some((cell) => cell !== "")) |
| .map((row) => { |
| const date = row[1] instanceof Date ? row[1] : new Date(row[1]); |
| const validDate = !Number.isNaN(date.getTime()); |
| const base = { |
| documentationId: safeText(row[0]), |
| documentedAt: validDate ? date.toISOString() : "", |
| documentedDate: validDate ? Utilities.formatDate(date, CONFIG.timezone, "yyyy-MM-dd") : "", |
| researcher: safeText(row[2]), |
| establishmentName: safeText(row[3]), |
| commercialRecord: safeText(row[4]), |
| contractNumber: safeText(row[5]), |
| city: safeText(row[6]), |
| fieldStatus: safeText(row[7]), |
| photoCount: Number(row[11]) || splitUrls(row[9]).length, |
| sampleKey: safeText(row[12]), |
| updatedAt: row[13] instanceof Date ? row[13].toISOString() : "", |
| }; |
| if (!includeDetails) return base; |
| return Object.assign(base, { |
| statement: safeText(row[8]), |
| photoUrls: splitUrls(row[9]), |
| folderUrl: safeText(row[10]), |
| }); |
| }) |
| .sort((a, b) => |
| String(b.updatedAt || b.documentedAt).localeCompare(String(a.updatedAt || a.documentedAt)), |
| ); |
| } |
|
|
| function splitUrls(value) { |
| return safeText(value).split(/\s+/).filter((item) => /^https?:\/\ |
| } |
|
|
| function getSheet() { |
| const spreadsheet = SpreadsheetApp.openById(CONFIG.spreadsheetId); |
| return spreadsheet.getSheetByName(CONFIG.sheetName) || spreadsheet.insertSheet(CONFIG.sheetName); |
| } |
|
|
| function ensureHeaders(sheet) { |
| const current = sheet.getRange(1, 1, 1, HEADERS.length).getDisplayValues()[0]; |
| if (current.join("") === "") { |
| sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]); |
| sheet.setFrozenRows(1); |
| sheet.getRange(1, 1, 1, HEADERS.length) |
| .setFontWeight("bold") |
| .setBackground("#4137A8") |
| .setFontColor("#FFFFFF"); |
| sheet.autoResizeColumns(1, HEADERS.length); |
| return; |
| } |
| if (!current[12]) sheet.getRange(1, 13).setValue(HEADERS[12]); |
| if (!current[13]) sheet.getRange(1, 14).setValue(HEADERS[13]); |
| } |
|
|
| function getEvidenceFolder(request) { |
| const root = DriveApp.getFolderById(CONFIG.folderId); |
| const researcherFolder = getOrCreateFolder(root, safeFileName(request.researcher)); |
| const cityFolder = getOrCreateFolder(researcherFolder, safeFileName(request.city || "مدينة غير محددة")); |
| const entityName = `${safeText(request.commercialRecord) || "دون سجل"} - ${safeText(request.establishmentName)}`; |
| return getOrCreateFolder(cityFolder, safeFileName(entityName)); |
| } |
|
|
| function getOrCreateFolder(parent, name) { |
| const folders = parent.getFoldersByName(name); |
| return folders.hasNext() ? folders.next() : parent.createFolder(name); |
| } |
|
|
| function savePhoto(folder, photo, request, index) { |
| const mimeType = String(photo.mimeType || ""); |
| if (!/^image\/(jpeg|png|webp)$/.test(mimeType)) throw new Error("نوع الصورة غير مدعوم."); |
| const bytes = Utilities.base64Decode(String(photo.base64 || "")); |
| if (bytes.length > CONFIG.maxPhotoBytes) throw new Error("إحدى الصور أكبر من الحد المسموح."); |
| const extension = mimeType === "image/png" ? "png" : mimeType === "image/webp" ? "webp" : "jpg"; |
| const timestamp = Utilities.formatDate(new Date(), CONFIG.timezone, "yyyyMMdd-HHmmss"); |
| const name = safeFileName( |
| `${request.commercialRecord || "دون-سجل"}-${timestamp}-${index + 1}.${extension}`, |
| ); |
| return folder.createFile(Utilities.newBlob(bytes, mimeType, name)).getUrl(); |
| } |
|
|
| function safeText(value) { |
| return String(value == null ? "" : value).trim().slice(0, 2000); |
| } |
|
|
| function safeFileName(value) { |
| return safeText(value).replace(/[\\/:*?"<>|#%{}[\]]/g, "-").slice(0, 140) || "غير محدد"; |
| } |
| |
| function jsonResponse(payload) { |
| return ContentService.createTextOutput(JSON.stringify(payload)) |
| .setMimeType(ContentService.MimeType.JSON); |
| } |
| |