ההכשרה הבסיסית מורכבת משני שלבים. שלב 1 מתאים לטראומה נקודתית, ושלב 2 נותן כלים לאוכלוסיות מורכבות ולפרוטוקולים מתקדמים. ההכשרה מוכרת במסגרת החובות התיאורטיות להסמכה כמטפל/ת EMDR על ידי עמותת EMDR ישראל ואירופה.
בדוק/י זכאות – וודא/י שאת/ה עומד/ת בתנאי הקבלה
בחר/י העדפות שיבוץ – בחר/י מועדים לקבוצת הזום
מלא/י טופס רישום – פרטים אישיים, מקצוע ותיעוד
שלם/י ועגן/י מקום – דמי רישום 355 ₪ (אין החזר)
מכון פשוט לפסיכותרפיה EMDR – ד"ר קרן מינץ מלחי. ההכשרה מוכרת על ידי עמותת EMDR ישראל ו-EMDR Europe. עמותת EMDR ישראל ←
ההכשרה הבסיסית מורכבת משני השלבים יחד. שלב 1 מתאים לטראומה נקודתית יותר, ושלב 2 נותן כלים לאוכלוסיות מורכבות ולפרוטוקולים מתקדמים.
על פי תנאי קבלה של עמותת EMDR ישראל ←
מלא/י את כל השדות. לאחר הגשה נבדוק את הבקשה ונחזור אליך במייל.
| # | שם | אימייל | טלפון | מקצוע | דמי רישום | תשלום מלא | העדפות | קבוצה | סטטוס תשלום | הערות | תאריך |
|---|
{שם} {קורס} {תאריכים} {מיקום}
{מדריך} {יום} {שעה} {תאריכי_הדרכה}
{סכום_רישום} {יתרה} {לינק_רישום} {לינק_יתרה} —
הם מוחלפים בנתונים של כל נרשם/ת בנפרד.
גש/י ל-script.google.com
New Project → מחק/י הטקסט → הדבק/י הסקריפט למטה
Project Settings → Script Properties → הוספת מאפיין בשם ANTHROPIC_API_KEY עם המפתח מ-console.anthropic.com. המפתח נשמר ב-Google בלבד ואינו נחשף לגולשים.
Deploy → New Deployment → Web App · Execute as: Me · Access: Anyone
העתק/י את ה-URL והדבק/י למטה
Triggers ⏰ (בסרגל הצד השמאלי) → Add Trigger → פונקציה: deleteExpiredDocuments · Event source: Time-driven · Day timer (פעם ביום) — בלי שלב זה, המסמכים שפג תוקף שמירתם לא נמחקים אוטומטית, בניגוד למה שמובטח בהצהרת הפרטיות בטופס.
__appdata), וכך אותה רשימה זמינה בכל מחשב.
כל שינוי נשמר אוטומטית תוך שניות. בפתיחת המערכת נטענת הגרסה העדכנית.ADMIN_PASSWORD, ערך: הסיסמה שתבחרי → Save./*** EMDR – Google Apps Script · v11 ***
* v11: הוספה ידנית במערכת נרשמת גם בגיליון · כל הכשרה נשארת באותה לשונית גם אם שמה משתנה ·
* חסימת שמירה מגרסאות ישנות של האתר (כדי שלשונית פתוחה ישנה לא תמחק נרשמים)
* v10: נרשמים חדשים מהאתר נכנסים אוטומטית לרשימת מערכת הניהול
* מבוסס על הסקריפט הקיים, בתוספת: בדיקת מסמכים אוטומטית, מייל אישור
* מהמערכת, השלמת מסמכים בדיעבד, ובדיקת סיסמת הכניסה בצד Google.
*
* הגדרות חד-פעמיות ב-Project Settings → Script Properties:
* ADMIN_PASSWORD – סיסמת הכניסה למערכת הניהול (חובה לאבטחת הכניסה)
* ANTHROPIC_API_KEY – מפתח לבדיקת המסמכים (אופציונלי)
* הערכים נשמרים בתוך Google ואינם נחשפים לגולשים באתר.
*
* נשמרו מהגרסה הקודמת: תיקיית Drive הקיימת, מבנה תת-התיקיות לכל נרשם/ת,
* מבנה העמודות בגיליון, מחיקת מסמכים אוטומטית אחרי 3 חודשים, והתראת שגיאה במייל.
*/
const FOLDER_ID = '1snkW53aZLI3zvKP3ZPL2DuW_tq842n-K';
const ADMIN_EMAIL = 'kmmemdr@gmail.com';
const AI_MODEL = 'claude-sonnet-5-5';
const MAX_FILES = 6;
const RETENTION_MONTHS = 3;
const HEADERS = ['תאריך בקשה','קוד','שם עברית','שם אנגלית','אימייל',
'טלפון','עיר','ת.ז.','מקצוע','העדפות קבוצה','הערות',
'סטטוס','תאריך החלטה','קישור למסמכים','תאריך מחיקה','בדיקת AI','הערת AI'];
const COL_LINK = 14, COL_DEL = 15, COL_AIV = 16, COL_AIR = 17;
/* ═══════════ ניתוב ═══════════ */
function doGet(e) {
return ContentService.createTextOutput('ok');
}
function doPost(e) {
var out = { ok: true };
try {
var raw;
if (e.parameter && e.parameter.payload) raw = e.parameter.payload; // form fallback
else raw = e.postData.contents; // fetch
var d = JSON.parse(raw);
var action = d.action || 'register';
try { ensureCleanupTrigger_(); } catch (e) {}
if (ADMIN_ONLY[action] && !tokenOk_(d)) return json_({ ok: false, err: 'unauthorized' });
if (!sizeOk_(d)) return json_({ ok: false, err: 'too-large' });
if (action === 'login') out = handleLogin(d);
else if (action === 'register') out = handleRegister(d);
else if (action === 'addDocs') out = handleAddDocs(d);
else if (action === 'sendMail') out = handleSendMail(d);
else if (action === 'recheck') out = handleRecheck(d);
else if (action === 'saveState') out = handleSaveState(d);
else if (action === 'loadState') out = handleLoadState(d);
else if (action === 'listTabs') out = handleListTabs(d);
else if (action === 'readSheet') out = handleReadSheet(d);
else if (action === 'getPublicConfig') out = handleGetPublicConfig(d);
else out = { ok: false, err: 'unknown-action' };
} catch (err) {
mail_({ to: ADMIN_EMAIL, subject: 'שגיאה במערכת ההרשמה',
body: 'שגיאה: ' + err.toString() });
out = { ok: false, err: String(err) };
}
return json_(out);
}
/* מחיקת מסמכים אוטומטית — הטיימר היומי מוגדר מעצמו, אין צורך להגדיר ידנית */
function ensureCleanupTrigger_() {
var cache = CacheService.getScriptCache();
if (cache.get('cleanup_trigger_ok')) return;
var has = ScriptApp.getProjectTriggers().some(function (t) {
return t.getHandlerFunction() === 'deleteExpiredDocuments';
});
if (!has) ScriptApp.newTrigger('deleteExpiredDocuments').timeBased().everyDays(1).atHour(3).create();
cache.put('cleanup_trigger_ok', '1', 21600);
}
/* שליחת מייל בטוחה — כתובת שגויה של נרשם/ת לא תפיל את שאר התהליך */
function mail_(opts, label) {
try { MailApp.sendEmail(opts); return true; }
catch (err) {
try {
MailApp.sendEmail({ to: ADMIN_EMAIL, subject: 'שליחת מייל נכשלה: ' + (label || ''),
body: 'לא נשלח מייל אל: ' + opts.to + '\n\nשגיאה: ' + String(err) +
'\n\nנושא: ' + opts.subject + '\n\n' + opts.body });
} catch (e2) {}
return false;
}
}
function json_(o) {
return ContentService.createTextOutput(JSON.stringify(o))
.setMimeType(ContentService.MimeType.JSON);
}
/* ═══════════ הרשאות ═══════════ */
const ADMIN_ONLY = { sendMail: true, recheck: true, saveState: true, loadState: true,
listTabs: true, readSheet: true };
const DATA_TAB = '__appdata';
const MIN_CLIENT = 11; /* גרסאות אתר ישנות מזה לא יכולות לשמור את הרשימה */
const CHUNK = 40000;
function tokenOk_(d) {
var want = PropertiesService.getScriptProperties().getProperty('ADMIN_TOKEN');
if (!want) return true; /* טרם הוגדרה — המערכת ממשיכה לעבוד */
return String(d.token || '') === want;
}
function sizeOk_(d) {
var docs = d.documents || [];
if (docs.length > MAX_FILES) return false;
var total = 0;
docs.forEach(function (doc) { total += String(doc.data || '').length; });
return total < 40000000;
}
function handleLogin(d) {
var props = PropertiesService.getScriptProperties();
var want = props.getProperty('ADMIN_PASSWORD');
if (!want) return { ok: false, err: 'not-configured' };
var cache = CacheService.getScriptCache();
var fails = Number(cache.get('login_fails') || 0);
if (fails > 8) return { ok: false, err: 'locked' };
if (String(d.pw || '') !== want) {
cache.put('login_fails', String(fails + 1), 900);
Utilities.sleep(1500);
return { ok: false, err: 'bad-password' };
}
cache.remove('login_fails');
var t = props.getProperty('ADMIN_TOKEN');
if (!t) { t = Utilities.getUuid().replace(/-/g, ''); props.setProperty('ADMIN_TOKEN', t); }
return { ok: true, token: t };
}
/* להרצה חד-פעמית מתוך העורך: מאשרת לסקריפט לפנות לשירות חיצוני (נדרש לבדיקת המסמכים).
בוחרים את הפונקציה למעלה, לוחצים Run, ומאשרים את ההרשאה שגוגל מבקשת. */
function authorizeExternalRequests() {
var res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
method: 'post', contentType: 'application/json',
headers: { 'x-api-key': 'authorization-probe', 'anthropic-version': '2023-06-01' },
payload: '{}', muteHttpExceptions: true
});
Logger.log('ההרשאה אושרה. קוד תגובה: ' + res.getResponseCode() + ' (401 הוא תקין כאן)');
return 'ok';
}
function createAdminToken() {
var t = Utilities.getUuid().replace(/-/g, '');
PropertiesService.getScriptProperties().setProperty('ADMIN_TOKEN', t);
Logger.log('ADMIN_TOKEN: ' + t);
return t;
}
/* ═══════════ גיליון ותיקיות ═══════════ */
function sheetFor_(tabName) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sh = ss.getSheetByName(tabName) || ss.insertSheet(tabName);
if (sh.getLastRow() === 0) {
sh.appendRow(['#'].concat(HEADERS)); /* עמודת מספור בצד — כל נרשם/ת מקבל/ת מספר */
sh.setFrozenRows(1);
} else if (sh.getLastColumn() < HEADERS.length) {
/* גיליון קיים מהגרסה הקודמת — משלימים את שתי הכותרות החדשות בלבד */
sh.getRange(1, COL_AIV, 1, 2).setValues([['בדיקת AI', 'הערת AI']]);
}
return sh;
}
function getOrCreateFolder(parent, name) {
var ex = parent.getFoldersByName(name);
return ex.hasNext() ? ex.next() : parent.createFolder(name);
}
/* אותו מבנה כמו קודם: תיקיית שורש → תיקיית הכשרה → תיקייה לכל נרשם/ת */
function personFolder_(tabName, hebrewName, regId) {
var root = DriveApp.getFolderById(FOLDER_ID);
var tf = getOrCreateFolder(root, tabName || 'הרשמות');
return getOrCreateFolder(tf, (hebrewName || '') + ' - ' + regId);
}
function saveDocs_(pf, docs) {
(docs || []).forEach(function (doc) {
var blob = Utilities.newBlob(Utilities.base64Decode(doc.data), doc.mimeType, doc.name);
pf.createFile(blob);
});
return pf.getUrl();
}
/* היסט עמודות: אם מישהו הוסיף עמודה משמאל (למשל מספור), כל העמודות זזות — מזהים לפי הכותרת */
function off_(sheet) {
try {
var lastC = sheet.getLastColumn();
if (!lastC) return 0;
var h = sheet.getRange(1, 1, 1, lastC).getValues()[0];
for (var c = 0; c < h.length; c++) if (String(h[c]).trim() === 'קוד') return Math.max(0, c - 1);
} catch (e) {}
return 0;
}
/* כתיבת נרשם/ת לגיליון. בלשונית עם עמודת מספור: ממלאים את השורה הממוספרת הפנויה הבאה
(או שורה חדשה), ונותנים מספר רץ. בלשונית ישנה בלי מספור — מוסיפים בסוף כרגיל. */
function writeRow_(sheet, values) {
var o = off_(sheet);
if (o < 1) { sheet.appendRow(values); return; }
var last = sheet.getLastRow();
var width = o + values.length;
var data = last >= 2 ? sheet.getRange(2, 1, last - 1, width).getValues() : [];
var maxNum = 0, target = -1, targetNum = null;
for (var i = 0; i < data.length; i++) {
var a = data[i][0];
var n = Number(a);
if (a !== '' && !isNaN(n)) maxNum = Math.max(maxNum, n);
if (target < 0) {
var rest = data[i].slice(1).join('').trim();
var aOk = (a === '' || !isNaN(n));
if (!rest && aOk) { target = i + 2; targetNum = (a !== '' ? n : null); }
}
}
if (target < 0) target = last + 1;
var num = targetNum !== null ? targetNum : maxNum + 1;
var row = [num];
for (var p = 1; p < o; p++) row.push('');
sheet.getRange(target, 1, 1, width).setValues([row.concat(values)]);
}
/* איתור שורה לפי קוד הבקשה, בכל לשוניות הגיליון */
function findRow_(regId) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheets = ss.getSheets();
for (var i = 0; i < sheets.length; i++) {
if (sheets[i].getName() === DATA_TAB) continue;
var vals = sheets[i].getDataRange().getValues();
var o = off_(sheets[i]);
for (var r = 1; r < vals.length; r++) {
if (String(vals[r][1 + o]) === String(regId)) {
/* values מוחזר מיושר, כך שהאינדקסים הקבועים ממשיכים לעבוד */
return { sheet: sheets[i], row: r + 1, values: vals[r].slice(o), tab: sheets[i].getName(), off: o };
}
}
}
return null;
}
/* ═══════════ בדיקת מסמכים ═══════════ */
function blocksFromDocs_(docs) {
var blocks = [];
(docs || []).slice(0, 5).forEach(function (doc) {
if (doc.mimeType === 'application/pdf') {
blocks.push({ type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: doc.data } });
} else if (String(doc.mimeType).indexOf('image/') === 0) {
blocks.push({ type: 'image', source: { type: 'base64', media_type: doc.mimeType, data: doc.data } });
}
});
return blocks;
}
function blocksFromFolderUrl_(url) {
var m = String(url).match(/folders\/([^\/\?]+)/);
if (!m) return [];
var docs = [];
try {
var files = DriveApp.getFolderById(m[1]).getFiles();
while (files.hasNext() && docs.length < 5) {
var f = files.next();
docs.push({ name: f.getName(), mimeType: f.getMimeType(),
data: Utilities.base64Encode(f.getBlob().getBytes()) });
}
} catch (e) { return []; }
return blocksFromDocs_(docs);
}
/* עטיפה: כישלון בבדיקה האוטומטית לעולם לא יפיל את ההרשמה */
function checkDocsSafe_(d, blocks) {
try { return checkDocs_(d, blocks); }
catch (err) {
return { verdict: 'pending', reason: 'הבדיקה האוטומטית לא בוצעה: ' + String(err).slice(0, 150) };
}
}
function checkDocs_(d, blocks) {
/* המתג במסך ההגדרות גובר: אם הבדיקה כבויה, שום מסמך אינו יוצא מ-Google */
if (d.aiEnabled === false) return { verdict: 'none', reason: 'הבדיקה האוטומטית כבויה — המסמכים נבדקים ידנית.' };
var key = PropertiesService.getScriptProperties().getProperty('ANTHROPIC_API_KEY');
if (!key) return { verdict: 'pending', reason: 'לא הוגדר מפתח API — הבדיקה האוטומטית לא בוצעה.' };
if (!blocks || !blocks.length) return { verdict: 'review', reason: 'לא נמצא קובץ שניתן לבדוק אוטומטית.' };
var prompt =
'המבקש/ת הצהיר/ה על המקצוע: ' + (d.professionLabel || d.profession || 'לא צוין') + '.\n' +
'המסמכים הנדרשים לפי תנאי הקבלה: ' + (d.docRequirement || 'לא הוגדרו') + '\n\n' +
'בדוק/י אם המסמכים המצורפים אכן מהווים את התיעוד הנדרש. ' +
'החזר/י JSON בלבד, ללא טקסט נוסף, במבנה: ' +
'{"verdict":"ok|review|problem","reason":"משפט או שניים בעברית"} ' +
'ok = המסמכים תואמים במלואם. review = חסר פרט או שיש ספק שדורש עין אנושית. ' +
'problem = המסמכים אינם תואמים את הנדרש.';
var content = blocks.slice(0);
content.push({ type: 'text', text: prompt });
var res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
method: 'post',
contentType: 'application/json',
headers: { 'x-api-key': key, 'anthropic-version': '2023-06-01' },
payload: JSON.stringify({
model: AI_MODEL,
max_tokens: 600,
system: 'את/ה עוזר/ת למזכירות הכשרת EMDR. תפקידך לתת חוות דעת מייעצת בלבד על התאמת מסמכי הסמכה. ' +
'ההחלטה הסופית תמיד אנושית. אין לדחות בקשה, רק לסמן. ענה/י בעברית וב-JSON בלבד.',
messages: [{ role: 'user', content: content }]
}),
muteHttpExceptions: true
});
var body = res.getContentText();
if (res.getResponseCode() !== 200) {
return { verdict: 'pending', reason: 'שגיאה בבדיקה: ' + body.slice(0, 200) };
}
try {
var j = JSON.parse(body), txt = '';
(j.content || []).forEach(function (b) { if (b.type === 'text') txt += b.text; });
txt = txt.replace(/```json/g, '').replace(/```/g, '').trim();
var parsed = JSON.parse(txt);
return { verdict: parsed.verdict || 'review', reason: parsed.reason || '' };
} catch (err) {
return { verdict: 'review', reason: 'לא ניתן היה לפענח את תשובת הבדיקה.' };
}
}
/* כל הכשרה נשמרת תמיד באותה לשונית — גם אם שם ההכשרה שונה מאוחר יותר */
function tabFor_(d) {
var label = d.training || 'הרשמות';
if (!d.trainingId) return label;
var props = PropertiesService.getScriptProperties();
var key = 'TAB_' + d.trainingId;
var saved = props.getProperty(key);
if (saved && SpreadsheetApp.getActiveSpreadsheet().getSheetByName(saved)) return saved;
props.setProperty(key, label);
return label;
}
/* ═══════════ רישום חדש ═══════════ */
function handleRegister(d) {
/* הוספה ידנית מהמערכת — רק למנהל/ת מחובר/ת, בלי מיילים ובלי בדיקה אוטומטית */
var manual = !!d.manual;
if (manual && !tokenOk_(d)) return { ok: false, err: 'unauthorized' };
var tabName = tabFor_(d);
var sheet = sheetFor_(tabName);
var docLink = '';
if (d.documents && d.documents.length) {
try { docLink = saveDocs_(personFolder_(tabName, d.hebrewName, d.regId), d.documents); }
catch (err) { docLink = 'שגיאה בשמירת המסמכים: ' + String(err).slice(0, 120); }
}
var ai = manual
? { verdict: 'none', reason: 'נוסף/ה ידנית על ידי המזכירות.' }
: (d.documents && d.documents.length)
? checkDocsSafe_(d, blocksFromDocs_(d.documents))
: { verdict: 'none', reason: 'טרם הוגש תיעוד.' };
var del = new Date();
del.setMonth(del.getMonth() + RETENTION_MONTHS);
/* בקשה שעוד לא הוחלט עליה לא תימחק לפני שהקורס התחיל (+חודש) — כדי שלא יימחקו מסמכים לפני ההחלטה */
if (d.courseStart) {
var cs = new Date(d.courseStart);
if (!isNaN(cs.getTime())) { cs.setMonth(cs.getMonth() + 1); if (cs > del) del = cs; }
}
writeRow_(sheet, [new Date(), d.regId, d.hebrewName, d.englishName, d.email,
d.phone, d.city, d.idNum, d.professionLabel || d.profession,
(d.preferredGroupNames || []).join(', '),
(d.supNotes || '') + (d.enrollBoth ? ' [נרשם/ה לשלב 1+2 — שלב 2 במחזור עתידי]' : ''),
manual ? (d.status || 'ממתין לבדיקה') : (d.docsLater ? 'ממתין לתיעוד' : 'ממתין לבדיקה'), '', docLink, del, ai.verdict, ai.reason]);
if (manual) {
try { addRegToState_(d, ai, docLink); } catch (err) {}
return { ok: true, ai: ai, files: [docLink], tab: tabName };
}
mail_({
to: d.email,
subject: 'בקשתך להכשרת EMDR התקבלה',
body: 'שלום ' + d.hebrewName + ',\n\n'
+ 'בקשתך להכשרת EMDR התקבלה ונמצאת כעת בבדיקה.\n'
+ 'קוד הבקשה שלך: ' + d.regId + '\n\n'
+ (d.docsLater
? 'מקומך שמור בסטטוס "ממתין לתיעוד". להשלמת המסמכים נא להיכנס לקישור האישי:\n' + (d.returnLink || '') + '\n\n'
: 'קיבלנו את מסמכיך, אנחנו בודקים ונחזור אלייך בקרוב.\n')
+ 'לאחר בדיקת המסמכים תישלח אלייך הודעה עם קישור לתשלום דמי הרישום.\n'
+ 'מקומך בקורס יישמר רק לאחר אישור הבקשה והשלמת התשלום.\n\n'
+ 'בברכה,\nד"ר קרן מינץ מלחי\nמכון פשוט לפסיכותרפיה EMDR'
}, 'אישור קבלת בקשה לנרשם/ת');
mail_({
to: ADMIN_EMAIL,
subject: 'בקשה חדשה: ' + d.hebrewName + ' · בדיקה: ' + ai.verdict,
body: 'שם: ' + d.hebrewName + '\n'
+ 'קוד: ' + d.regId + '\n'
+ 'מקצוע: ' + (d.professionLabel || d.profession) + '\n'
+ 'אימייל: ' + d.email + '\n'
+ 'טלפון: ' + d.phone + '\n'
+ 'הכשרה: ' + tabName + (d.enrollBoth ? ' (שלב 1+2)' : '') + '\n'
+ 'העדפות: ' + (d.preferredGroupNames || []).join(', ') + '\n'
+ 'הערות: ' + (d.supNotes || '-') + '\n\n'
+ 'בדיקת מסמכים: ' + ai.verdict + '\n' + ai.reason + '\n\n'
+ 'מסמכים: ' + (docLink || 'לא הועלו') + '\n\n'
+ 'אין לשלוח אישור עד לבדיקה ידנית.'
}, 'התראה למשרד');
/* הוספת הנרשם/ת גם לרשימה המשותפת של מערכת הניהול — כך מופיע/ה שם אוטומטית */
try { addRegToState_(d, ai, docLink); } catch (err) {
mail_({ to: ADMIN_EMAIL, subject: 'נרשם/ת לא נוסף/ה לרשימת המערכת: ' + (d.hebrewName || d.regId),
body: 'הבקשה נשמרה בגיליון (לשונית ' + tabName + ') אך לא נוספה אוטומטית למערכת הניהול.\nקוד: ' + d.regId + '\nשגיאה: ' + String(err) }, 'שגיאת סנכרון');
}
return { ok: true, ai: ai, files: [docLink] };
}
/* קריאה/כתיבה של מצב המערכת המשותף (__appdata) מצד השרת */
function readStateJson_(sh) {
var last = sh.getLastRow();
if (last < 2) return null;
var vals = sh.getRange(2, 1, last - 1, 1).getValues();
var json = '';
vals.forEach(function (r) { json += String(r[0] || ''); });
return json ? JSON.parse(json) : null;
}
function addRegToState_(d, ai, docLink) {
var tid = d.trainingId;
if (!tid || !d.regId) return;
var lock = LockService.getScriptLock();
lock.waitLock(20000);
try {
var sh = dataSheet_();
var meta = readMeta_(sh);
var st = readStateJson_(sh);
if (!st || !st.trainings) return; /* אין עדיין מצב שמור — הייבוא הידני יטפל */
st.registrations = st.registrations || {};
var list = st.registrations[tid] = st.registrations[tid] || [];
for (var i = 0; i < list.length; i++) if (String(list[i].id) === String(d.regId)) return;
var skip = { action: 1, token: 1, documents: 1, regId: 1, training: 1,
preferredGroupNames: 1, professionLabel: 1, returnLink: 1, aiEnabled: 1 };
var rec = {};
Object.keys(d).forEach(function (k) { if (!skip[k]) rec[k] = d[k]; });
rec.id = d.regId;
rec.trainingId = tid;
rec.aiVerdict = ai ? ai.verdict : (rec.aiVerdict || 'pending');
rec.aiReason = ai ? ai.reason : (rec.aiReason || '');
if (docLink && String(docLink).indexOf('http') === 0) rec.driveLink = docLink;
rec.source = d.manual ? 'manual' : 'web';
if (!d.manual) {
/* הטופס הציבורי לא יכול לקבוע סטטוס, תשלום או שיבוץ — רק המזכירות */
rec.status = d.docsLater ? 'ממתין לתיעוד' : 'ממתין לבדיקה';
rec.regPaid = false; rec.fullPaid = false;
rec.assignedGroup = null; rec.assignedRank = null; rec.waitlist = false;
delete rec.manual; delete rec.driveLink;
if (docLink && String(docLink).indexOf('http') === 0) rec.driveLink = docLink;
Object.keys(rec).forEach(function (k) { /* מגבלת אורך לשדות טקסט */
if (typeof rec[k] === 'string' && rec[k].length > 2000) rec[k] = rec[k].slice(0, 2000);
});
}
list.push(rec);
var json = JSON.stringify(st);
var chunks = [];
for (var j = 0; j < json.length; j += CHUNK) chunks.push([json.substr(j, CHUNK)]);
sh.clear();
var newMeta = { version: Number(meta.version || 0) + 1, savedAt: new Date().toISOString(),
savedBy: 'טופס ההרשמה באתר', rows: chunks.length };
sh.getRange(1, 1).setValue(JSON.stringify(newMeta));
if (chunks.length) sh.getRange(2, 1, chunks.length, 1).setValues(chunks);
} finally {
lock.releaseLock();
}
}
/* ═══════════ השלמת מסמכים בדיעבד ═══════════ */
function handleAddDocs(d) {
var found = findRow_(d.regId);
/* פרטי הנרשם/ת נלקחים מהרשימה המשותפת — הקישור האישי נפתח לרוב במכשיר אחר, בלי נתונים מקומיים */
var sheetEmail = found ? String(found.values[4] || '') : '';
var info = findStateRec_(d.regId, d.email || sheetEmail) || {};
var rec = info.rec || {};
var name = d.hebrewName || rec.hebrewName || (found ? String(found.values[2] || '') : '');
var email = d.email || rec.email || sheetEmail;
var tabName = found ? found.tab : (d.training || 'הרשמות');
var docLink = saveDocs_(personFolder_(tabName, name, d.regId), d.documents);
/* בדיקה אוטומטית רק אם המתג פעיל וגם הנרשם/ת לא ביקש/ה בדיקה ידנית בלבד */
var check = {
aiEnabled: d.aiEnabled !== false && rec.aiConsent !== false,
professionLabel: d.professionLabel || rec.profession || '',
docRequirement: d.docRequirement || rec.docRequirement ||
(info.level === 'level2' && !rec.enrollBoth ? 'תעודת סיום שלב 1 בהכשרת EMDR.' : '')
};
var ai = checkDocsSafe_(check, blocksFromDocs_(d.documents));
if (found) {
var o = found.off || 0;
found.sheet.getRange(found.row, COL_LINK + o).setValue(docLink);
found.sheet.getRange(found.row, COL_AIV + o).setValue(ai.verdict);
found.sheet.getRange(found.row, COL_AIR + o).setValue(ai.reason);
found.sheet.getRange(found.row, 12 + o).setValue('ממתין לבדיקה');
if (!found.values[COL_DEL - 1]) {
var del = new Date(); del.setMonth(del.getMonth() + RETENTION_MONTHS);
found.sheet.getRange(found.row, COL_DEL + o).setValue(del);
}
}
/* עדכון הרשומה ברשימת המערכת — כדי שהמזכירות תראה מיד שהמסמכים הוגשו */
try {
updateStateRec_(d.regId, email, function (r) {
r.docsLater = false;
r.status = 'ממתין לבדיקה';
r.aiVerdict = ai.verdict; r.aiReason = ai.reason;
if (String(docLink).indexOf('http') === 0) r.driveLink = docLink;
});
} catch (err) {}
mail_({
to: ADMIN_EMAIL,
subject: 'הושלמו מסמכים: ' + (name || d.regId) + ' · בדיקה: ' + ai.verdict,
body: 'קוד: ' + d.regId + '\n' + ai.reason + '\n\nמסמכים: ' + docLink
}, 'התראת השלמת מסמכים');
if (email) {
mail_({ to: email, subject: 'קיבלנו את מסמכיך – הכשרת EMDR',
body: 'שלום ' + (name || '') + ',\n\nהמסמכים התקבלו ונמצאים בבדיקה. נחזור אלייך בקרוב.\n\nבברכה,\nמזכירות ההכשרה' }, 'אישור קבלת מסמכים');
}
return { ok: true, ai: ai, files: [docLink] };
}
/* איתור נרשם/ת ברשימה המשותפת לפי קוד, ואם לא נמצא — לפי אימייל */
function findStateRec_(regId, email) {
var st = readStateJson_(dataSheet_());
if (!st || !st.registrations) return null;
var em = String(email || '').toLowerCase().trim();
var hit = null;
Object.keys(st.registrations).forEach(function (tid) {
(st.registrations[tid] || []).forEach(function (r) {
if (hit) return;
if (String(r.id) === String(regId) || (em && String(r.email || '').toLowerCase().trim() === em)) {
var t = (st.trainings || []).filter(function (x) { return x.id === tid; })[0];
hit = { rec: r, tid: tid, level: t ? t.level : '' };
}
});
});
return hit;
}
function updateStateRec_(regId, email, fn) {
var lock = LockService.getScriptLock();
lock.waitLock(20000);
try {
var sh = dataSheet_();
var meta = readMeta_(sh);
var st = readStateJson_(sh);
if (!st || !st.registrations) return false;
var em = String(email || '').toLowerCase().trim(), done = false;
Object.keys(st.registrations).forEach(function (tid) {
(st.registrations[tid] || []).forEach(function (r) {
if (done) return;
if (String(r.id) === String(regId) || (em && String(r.email || '').toLowerCase().trim() === em)) { fn(r); done = true; }
});
});
if (done) writeState_(sh, meta, st, 'טופס ההרשמה באתר');
return done;
} finally { lock.releaseLock(); }
}
function writeState_(sh, meta, st, by) {
var json = JSON.stringify(st);
var chunks = [];
for (var j = 0; j < json.length; j += CHUNK) chunks.push([json.substr(j, CHUNK)]);
sh.clear();
var newMeta = { version: Number(meta.version || 0) + 1, savedAt: new Date().toISOString(),
savedBy: by, rows: chunks.length };
sh.getRange(1, 1).setValue(JSON.stringify(newMeta));
if (chunks.length) sh.getRange(2, 1, chunks.length, 1).setValues(chunks);
return newMeta;
}
/* ═══════════ בדיקה חוזרת ═══════════ */
function handleRecheck(d) {
var found = findRow_(d.regId);
if (!found) return { ok: false, err: 'no-files', ai: { verdict: 'review', reason: 'הבקשה לא נמצאה בגיליון.' } };
var link = found.values[COL_LINK - 1];
if (!link) return { ok: false, err: 'no-files', ai: { verdict: 'none', reason: 'לא הוגשו מסמכים לבקשה זו.' } };
if (d.aiEnabled === false) return { ok: true, files: [link], ai: { verdict: 'none', reason: '' } };
var ai = checkDocsSafe_(d, blocksFromFolderUrl_(link));
found.sheet.getRange(found.row, COL_AIV + (found.off || 0)).setValue(ai.verdict);
found.sheet.getRange(found.row, COL_AIR + (found.off || 0)).setValue(ai.reason);
return { ok: true, ai: ai, files: [link] };
}
/* ═══════════ שליחת מייל מהמערכת ═══════════ */
function handleSendMail(d) {
/* עותק למשרד רק בהחלטות (אישור/דחייה) — כדי לא לשרוף את מכסת המיילים היומית של Gmail */
var opts = { to: d.to, subject: d.subject, body: d.body };
if (d.outcome) opts.bcc = ADMIN_EMAIL;
var sent = mail_(opts, 'מייל מהמערכת');
if (!sent) return { ok: false, err: 'mail-failed' };
var found = (d.regId && d.outcome) ? findRow_(d.regId) : null;
if (found) {
var o = found.off || 0;
found.sheet.getRange(found.row, 13 + o).setValue(new Date()); /* תאריך החלטה */
if (d.outcome === 'accept') {
found.sheet.getRange(found.row, 12 + o).setValue('מאושר – ממתין לתשלום');
/* התקבל/ה — נשמר עד שנה מתום הקורס, בהתאם להצהרת הפרטיות */
var keep = d.retainUntil ? new Date(d.retainUntil) : null;
if (keep && !isNaN(keep.getTime())) found.sheet.getRange(found.row, COL_DEL + o).setValue(keep);
else found.sheet.getRange(found.row, COL_DEL + o).clearContent();
} else if (d.outcome === 'reject') {
found.sheet.getRange(found.row, 12 + o).setValue('נדחה');
/* נדחה/בוטל — 3 חודשים מרגע ההחלטה */
var del2 = new Date(); del2.setMonth(del2.getMonth() + RETENTION_MONTHS);
found.sheet.getRange(found.row, COL_DEL + o).setValue(del2);
}
}
return { ok: true };
}
/* ═══════════ קריאה ישירה מהגיליון ═══════════
מאפשר למערכת לקרוא את לשונית התשובות בלי להוריד קובץ CSV. */
function handleListTabs(d) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var names = ss.getSheets().map(function (sh) {
return { name: sh.getName(), rows: Math.max(0, sh.getLastRow() - 1) };
}).filter(function (x) { return x.name !== DATA_TAB; });
return { ok: true, tabs: names };
}
function handleReadSheet(d) {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sh = ss.getSheetByName(d.tab);
if (!sh) return { ok: false, err: 'no-tab' };
var last = sh.getLastRow(), lastC = sh.getLastColumn();
if (last < 1) return { ok: true, rows: [] };
var vals = sh.getRange(1, 1, last, lastC).getValues();
/* המרה למחרוזות, כדי שהעיבוד יהיה זהה לקובץ CSV */
var out = vals.map(function (row) {
return row.map(function (v) {
if (v instanceof Date) return Utilities.formatDate(v, Session.getScriptTimeZone(), 'M/d/yyyy HH:mm:ss');
return String(v === null || v === undefined ? '' : v);
});
});
return { ok: true, rows: out };
}
/* ═══════════ סנכרון בין מחשבים ═══════════
* רשימת הנרשמים נשמרת בלשונית מוסתרת בגיליון, כך שכל מחשב עובד על אותו עותק.
* גרסה עולה בכל שמירה; ניסיון לשמור מעל גרסה חדשה יותר נדחה ומדווח כהתנגשות.
*/
function dataSheet_() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sh = ss.getSheetByName(DATA_TAB);
if (!sh) {
sh = ss.insertSheet(DATA_TAB);
sh.getRange(1, 1).setValue('{"version":0}');
try { sh.hideSheet(); } catch (e) {}
}
return sh;
}
function readMeta_(sh) {
try { return JSON.parse(sh.getRange(1, 1).getValue() || '{"version":0}'); }
catch (e) { return { version: 0 }; }
}
function handleSaveState(d) {
if (Number(d.clientVer || 0) < MIN_CLIENT) {
return { ok: false, err: 'old-client', msg: 'גרסת האתר בדפדפן הזה ישנה. יש לסגור את הלשונית, לפתוח מחדש וללחוץ Ctrl+Shift+R.' };
}
var lock = LockService.getScriptLock();
try { lock.waitLock(15000); } catch (e) { return { ok: false, err: 'busy' }; }
try {
var sh = dataSheet_();
var meta = readMeta_(sh);
var have = Number(meta.version || 0);
var expect = Number(d.expectedVersion || 0);
if (!d.force && have > expect) {
return { ok: false, err: 'conflict', meta: meta };
}
var json = String(d.json || '');
var chunks = [];
for (var i = 0; i < json.length; i += CHUNK) chunks.push([json.substr(i, CHUNK)]);
sh.clear();
var newMeta = { version: have + 1, savedAt: new Date().toISOString(),
savedBy: d.savedBy || 'לא ידוע', rows: chunks.length };
sh.getRange(1, 1).setValue(JSON.stringify(newMeta));
if (chunks.length) sh.getRange(2, 1, chunks.length, 1).setValues(chunks);
return { ok: true, meta: newMeta };
} finally {
lock.releaseLock();
}
}
/* ערוץ ציבורי, ללא סיסמה — חושף רק את מה שמוצג ממילא באתר: דגל בדיקת ה-AI,
רשימת ההכשרות הגלויות (תאריכים, מיקום, קבוצות הדרכה) והטקסטים שבאתר.
נתוני נרשמים לעולם אינם נשלחים מכאן. */
function handleGetPublicConfig(d) {
try {
var sh = dataSheet_();
var last = sh.getLastRow();
if (last < 2) return { ok: true, aiEnabled: false };
var vals = sh.getRange(2, 1, last - 1, 1).getValues();
var json = '';
vals.forEach(function (r) { json += String(r[0] || ''); });
var st = JSON.parse(json);
var out = { ok: true, aiEnabled: !!st.aiEnabled };
if (Array.isArray(st.trainings)) {
out.trainings = st.trainings.filter(function (t) { return t && !t.hidden; });
}
if (st.texts && typeof st.texts === 'object') out.texts = st.texts;
return out;
} catch (e) {
return { ok: true, aiEnabled: false };
}
}
function handleLoadState(d) {
var sh = dataSheet_();
var meta = readMeta_(sh);
if (d.metaOnly) return { ok: true, meta: meta };
var last = sh.getLastRow();
if (last < 2) return { ok: true, meta: meta, json: '' };
var vals = sh.getRange(2, 1, last - 1, 1).getValues();
var json = '';
vals.forEach(function (r) { json += String(r[0] || ''); });
return { ok: true, meta: meta, json: json };
}
/* ═══════════ מחיקת מסמכים אחרי 3 חודשים (ללא שינוי) ═══════════ */
function deleteExpiredDocuments() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const today = new Date();
ss.getSheets().forEach(function (sheet) {
if (sheet.getName() === DATA_TAB) return;
const o = off_(sheet);
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
const dd = data[i][14 + o], link = data[i][13 + o];
if (dd && link && String(link).indexOf('http') === 0 && new Date(dd) <= today) {
try {
const id = String(link).match(/folders\/([^\/\?]+)/)[1];
DriveApp.getFolderById(id).setTrashed(true);
sheet.getRange(i + 1, 14 + o).setValue('נמחק');
sheet.getRange(i + 1, 15 + o).setValue('');
} catch (err) {}
}
}
});
}
ADMIN_PASSWORD.