Sentrix · קליטת מפתח — המדריך המלא ראש המסמך

קליטת מפתח ב‑Sentrix — המדריך המלא (מסמך עצמאי)

נקודת הכניסה היחידה למפתח חדש. כל מה שצריך כדי לעבוד נמצא כאן — כולל התוכן המלא של מסמכי העומק, המשוכפל בסוף כנספחים א׳–י״ב. אין צורך לצאת מהמסמך כדי להבין את המערכת, להקים מחשב, להעלות שינוי לאוויר או לקלוט לקוח. היעד: פרודוקטיביות תוך יום עבודה אחד. הדיאגרמות שלאורך הפרקים הן חלק מהמסמך — לא קישוט.

איך לקרוא נתיב כמו docs/DECISIONS.md או services/mis-core/src/… במסמך הזה. נתיב הוא ציטוט מקור בתוך הריפו, ולא הפניה לקריאה חיצונית: הוא שם כדי שתדעו איפה העובדה חיה בקוד ואיפה לתקן אותה, ומרגע שיש לכם clone הוא לחיץ בעורך. אף נתיב כאן אינו תנאי להבנה — כל מה שצריך לעבוד כתוב במסמך במלואו, ומקום שבו התוכן שוכפל לכאן אומר זאת מפורשות בשורת "מקור:". קפיצות פנימיות מסומנות כקישור לפרק או לנספח, למשל פרק 28 — המגבלות.

ברוכים הבאים ל‑MIS26 Sentrix

אם קיבלתם את הקובץ הזה, אתם מצטרפים לפיתוח. הפרק הזה נכתב כדי שתדעו לאן נכנסתם לפני שאתם מתקינים משהו — הוא לא דורש גישה, לא לריפו ולא לענן. קראו אותו עד הסוף, ואז תדעו גם מה לבקש כדי להתחיל בפועל.

מה זו החברה ומה זו הפלטפורמה

MIS26 בונה תוכנת תפעול לרשויות מקומיות ולקבלנים שעובדים מולן. התחום המוביל הוא פסולת: פינוי אשפה, מכולות, תחנות שקילה, קבלנים, פיקוח וחיוב. סביבו יש עוד: ביטחון עירוני (מצלמות על עמודים), דלק, לוגיסטיקה, חקלאות, ובורסות למחזור.

Sentrix הוא שם הפלטפורמה והריפו — כל זה בנוי על Google Cloud, ומחליף שלושה אתרי Wix שהמערכת עבדה עליהם. עיקרון הברזל של ההחלפה: היא נעשית בלי לגעת בשטח. ‏300+ משאיות, מאות מצלמות, מאזני גשר וחיישני מכולות משדרים לאותן כתובות בדיוק ולא מרגישים שהוחלף להם המוח. הרבה מהאילוצים במסמך הזה נובעים מהמשפט הזה.

איך זה בנוי, בגדול

ארבע שכבות. פרק 1 מפרק כל אחת עם דיאגרמה; זו התמונה שמספיקה כדי להתמצא:

השכבה מה יש בה מה חשוב לדעת
השטח משאיות עם מצלמות ו‑Jetson, עמודי ביטחון, מאזני גשר, חיישני מכולות, מקליטי וידאו לא משתנה, ומשדר REST. הזיהוי (YOLO) קורה בקצה, ווידאו לא נשמר אצלנו
השער ‏Load Balancer + Cloud Armor + API Gateway הדלת היחידה פנימה. אין דרך אחרת להגיע לשירות
הליבה ‏11 שירותי Cloud Run: ‏7 שירותי backend פרטיים, ‏mcp-wrapper דרך השער, ו‑4 אפליקציות מאחורי ה‑LB mis-core הוא הגדול; לידו ביטחון, דוחות, מנוע התראות, שליחה, סוכני AI ו‑MCP. אפליקציית לקוחות אחת מגישה את כל סוגי הלקוחות
הנתונים ‏Cloud SQL פרטי, ‏Pub/Sub, ‏BigQuery, ‏Cloud Storage אין IP ציבורי לדאטהבייס. הכל מוגדר כקוד (Terraform) במונו‑ריפו אחד

וארבעה עקרונות שיחזרו בכל פרק, כי כל אחד מהם נשען על החלטה שנסגרה (DECISIONS.md):

מה נעשה במדריך הזה

הפרקים מה יוצא מזה
‏0–3 להבין את המערכת: מה רץ עכשיו, מפת הריפו, וחוק הזהות — לפני שנוגעים בכלום
4 היום הראשון — אשף גרפי מקים לכם את הסביבה, ובסוף המערכת רצה על המחשב שלכם
‏5–6 ענפים, ‏PR, ‏CI ופריסה — ואז השינוי הראשון שלכם עולה לאוויר, מקצה לקצה
‏7–16 דאטהבייס, גיבויים, סודות, השער, מולטי‑טננסי, פרונטאנד, טסטים, ניטור — ומה אסור
‏17–27 לקלוט לקוח חדש, המעבר מ‑Wix, ותפעול שוטף
28–30 המגבלות (מה לא עובד ומה לא מגלה לכם לבד), מי מאשר מה, ומשימות פתיחה מומלצות לספרינט הראשון
נספחים הארכיטקטורה המלאה, ‏IAM, ‏DR, תוכנית גדילה, אגם הנתונים, מודל העלות

היעד: בסוף יום עבודה אחד יש לכם סביבה עובדת ושינוי אחד שלכם חי ב‑dev. זה לא סיסמה — פרק 6 הוא תרגיל מודרך שעושה בדיוק את זה, שורה‑שורה.

שני כללים שכדאי שתכירו כבר עכשיו, כי הם משנים את קצב העבודה:

  1. ממזגים ל‑dev ביום שמסיימים — לא כשהפיצ'ר אושר ללקוחות. אם החשיפה היא החלטה עסקית, הפיצ'ר נכנס מאחורי דגל כבוי. שלב 11 מסביר.
  2. אנגלית בקוד ובטרמינל, עברית במסמכים — עברית בטרמינל מתהפכת. לכן מדריך ההרצה (HAND-IN-HAND-EN.md, פרק 26) כתוב באנגלית בכוונה.

מה צריך לבקש לפני שמתחילים

את שלושת אלה אתם לא יכולים להסדיר לבד. בקשו אותם לפני שאתם פותחים את פרק 4, אחרת ההתקנה תיראה כאילו עבדה ואז תיפול על "Not Found" מבלבל:

  1. חשבון @mis.org.il — הזהות שאיתה נכנסים לכל דבר (פרק 3).
  2. חברות ב‑org MIS-Make-It-Simple ב‑GitHub, עם גישה לריפו sentrix.
  3. הרשאת IAM על mis26-dev — שלושה תפקידים, וזה מה שמבקשים בשם: ‏Viewer ‏(roles/viewer) לראות את הפרויקט, IAP-secured Tunnel User ‏(roles/iap.tunnelResourceAccessor) ו‑Compute OS Login ‏(roles/compute.osLogin) כדי לפתוח את מנהרת ה‑DB דרך ה‑bastion (פרק 7). ‏prod — רק למי שבאמת צריך.

מי שמצרף אתכם מסדיר את שלושתם. עד שיש לכם אותם — הפרקים 0 עד 3 קריאים לגמרי, וכדאי לקרוא אותם.

0. המערכת החיה — כניסה מהירה

כל מה שרץ עכשיו, ואיך נכנסים. הכניסה לכל הכתובות זהה: לוחצים "המשך עם Google" ומזדהים עם המייל הארגוני שלכם — כל כתובת שמסתיימת ב‑@mis.org.il (אותה זהות ארגונית מפרק 3). אין משתמש וסיסמה נפרדים — הזהות הארגונית היא המפתח לכל הדשבורדים.

כתובת (dev) מי נכנס לשם מה רואים
https://demo.app-dev.mis-26.com קבלנים הקוקפיט התפעולי: צי, שקילות, התראות, משימות
https://mgroup.app-dev.mis-26.com רשויות דשבורד הרשות: פיקוח, אזורים, דוחות
https://netanya-demo.app-dev.mis-26.com חלון הראווה טננט רשות מקומית עם כל המודולים דלוקים — מארח ההדגמה לראש עיר ולמנכ"ל. אם מסך שם מציג "המודול לא כלול", מסך קבלן, או ריק — זה באג
https://security.app-dev.mis-26.com ביטחון מצלמות, זיהויים, חוקי התראה
https://admin.app-dev.mis-26.com אדמין קונסולת ניהול: לקוחות, מודולים, דגלי פיצ'רים (/feature-flags — מי רואה מה, שלב 11), הרשאות, נעילות
https://driver.app-dev.mis-26.com נהג אפליקציית הנהג: משימות, ניווט, אישורי איסוף
https://demo-shuttle.app-dev.mis-26.com הסעות קוקפיט מותאם‑סגמנט (shuttle) על אותו קוד
https://demo-tanker.app-dev.mis-26.com מיכליות קוקפיט מותאם‑סגמנט (tanker) על אותו קוד
demo-<segment>.app-dev.mis-26.com כל 11 הסגמנטים טננט דמו לכל סגמנט (‏demo-cargo, ‏demo-construction...) — אותו שלד, פרופיל מסכים שונה, אפס דליפה בין סגמנטים (טסט מטריצה)
https://kedumim.app-dev.mis-26.com קדומים (חלון ראווה) תוכנית פינוי אמיתית "קדומים כתום": ‏36 עצירות, תוואי OSRM, ניווט במסך הנהג
https://exchange-waste.app-dev.mis-26.com בורסת פסולת אפליקציית ה‑exchange (מרקטפלייס)
https://exchange-haulage.app-dev.mis-26.com בורסת הובלה אותה אפליקציה, ורטיקל הובלה

כל הכתובות חיות על אותה סביבת dev (‏app-dev.mis-26.com, מאחורי Load Balancer ו‑Cloud Armor). התת‑דומיין הוא זיהוי הלקוח (tenant): אותו קוד, מיתוג ונתונים שונים לכל כתובת. מסך כניסה = תקין, נכנסים עם Google. ‏403/401 בלי מסך = האבטחה עובדת; בדקו שאתם בחשבון הארגוני הנכון.

הערה כנה על ה‑DNS: הרשומה היא wildcard (‏*.app-dev.mis-26.com), אז כל תת‑דומיין פותר ל‑LB. תת‑דומיין שאין מאחוריו לקוח מקבל 404 בארבע שפות עוד לפני מסך הכניסה, ולכן כתובת שעונה 404 היא תשובה אמיתית. כתובת שמגישה מסך כניסה אומרת שיש שורת Tenant מאחוריה, אבל היא עדיין לא מבטיחה שהוגדר לה סגמנט; ראו פרק 20. ‏exchange-waste/haulage הם יוצאי דופן: הם לא טננטים אלא אפליקציה נפרדת, עם host rule מפורש ב‑url-map.

ואיפה ה‑DNS הזה חי בפועל: ב‑GoDaddy, ולא בטרהפורם. זה החלק היחיד בסטאק שאין לו קוד ואי אפשר לקרוא אותו מהרפו, ולכן מה שמוגדר שם מתועד ב‑docs/DNS-RECORDS.md — שש הרשומות, מה לא נוגעים בו (האתר השיווקי), ואיך מאמתים. שימו לב במיוחד לרשומות ה‑_acme-challenge: הן אימות בעלות לתעודה, הערך שלהן אינו קבוע, וכל פעם ש‑dns_authorization נוצר מחדש נטבע טוקן חדש שחייב להיכנס שם ביד. וכשעורכים אחת מהן, dig ימשיך להחזיר את הערך הקודם עד שה‑TTL פוגע — כלומר עריכה נכונה נראית כמו כישלון; המסמך מסביר איך לשאול את שרת השמות ישירות ולעקוף את המטמון.

אפליקציה אחת מאחורי כל הכתובות האלה. ארבעת סטי המסכים (‏municipal, ‏contractors, ‏security, ‏driver) מוגשים מ‑apps/client-app, והוא ה‑default_service של ה‑url map. אין כלל host פר לקוח, ואין terraform apply בפתיחת לקוח: הכתובת מגיעה לאפליקציה, האפליקציה שואלת את ה‑DB מי הלקוח, ובוחרת את סט המסכים לפי הסגמנט שלו. פתיחת לקוח = שורה ב‑DB. ההסבר המלא, כולל סדר איך זה בנוי ואיך נבחר סט המסכים: פרק צמוד ל‑2. תת‑דומיין שאין מאחוריו טננט — כולל תווית שמורה בלי דלת — מגיע ל‑client-app ומקבל 404 של מארח לא מזוהה, בדיוק כמו כל תת‑דומיין ריק אחר.

תוכן עניינים

בגרסת ה‑HTML (הקובץ בדרייב, או ‏docs/onboarding-he.html) יש תוכן עניינים צדי וקבוע שנבנה מהמסמך עצמו, עם תיבת סינון (מקש /), פרקים מתקפלים, סימון הפרק שאתה נמצא בו, ופס התקדמות קריאה. הרשימה למטה היא הקפיצות הראשיות; הצדי מכיל כל כותרת, כולל תתי‑סעיפים. ראו פרק 22.

👋 ברוכים הבאים ל‑MIS26 Sentrix — מה זה, איך זה בנוי, ומה צריך לבקש כדי להתחיל

פרק 0 · המערכת החיה — כל הכתובות והכניסה המהירה

פרקים

  1. מה המערכת ומה רץ היום
  2. מפת הריפו — מה יושב איפה
  3. חוק הזהות — לפני הכל
  4. היום הראשון — מסלול מודרך (אשף גרפי כברירת מחדל)
  5. עבודה על קוד: ענפים, PR, CI ופריסה
  6. השינוי הראשון שלך מקצה לקצה
  7. בסיס הנתונים: שלוש דרגות גישה, וחוק ה‑DDL
  8. גיבויים ו‑DR — כשמשהו נשבר באמת
  9. סודות — Secret Manager בלבד
  10. השער והרשת — הדלת היחידה פנימה
  11. מולטי‑טננסי: לקוח חדש = קונפיג, לא קוד
  12. פרונטאנד ומערכת העיצוב
  13. טסטים — מה יש, איך מריצים, ומה כל סוג שומר
  14. ג'ובים מתוזמנים (Cloud Scheduler)
  15. ניטור, לוגים ועלויות
  16. מה אסור — הרשימה השחורה
  17. מפת המסמכים החיים
  18. מושגי יסוד — למי שמגיע בלי רקע
  19. מעבר מ‑Wix ל‑Sentrix — עם דוגמאות קוד
  20. הקמת לקוח חדש ממסך האדמין
  21. נעילות — מה שרק שניר משחרר
  22. עדכוני המסמך, ‏HTML ו‑PDF
  23. סיכום: היום הראשון שלך, ברשימה אחת
  24. כיבוי Wix — הרנבוק המלא
  25. מה נשאר לסוף ומה אחרי המסירה
  26. יד ביד — מדריך ההרצה (אנגלית) + ‏dumpdev / makeprod
  27. וידאו חי, בריאות מצלמות ומוקשי הדמו — עבודה מול המערכות החיצוניות
  28. המגבלות — מה המערכת לא עושה, ומה לא מגלה לכם לבד
  29. מי מאשר מה
  30. משימות פתיחה מומלצות — הספרינט הראשון

נספחים — תוכן מלא מוטמע

הקישורים לעיל הם קפיצות בתוך המסמך. הנספחים הם העתק מלא של מסמכי העומק שבריפו — הובאו לכאן כדי שהמסמך יעמוד בפני עצמו, בלי תלות בקריאה חיצונית. נספחים ט׳ ו‑י״א נכתבו כאן במיוחד למפתח: המסמכים המקוריים שמאחוריהם הם מסמכי הנהלה שמודדים את המוצר מול השוק, ולכן הנספח מביא את החלק שנוגע לקוד.

1. מה המערכת ומה רץ היום

השטח (לא משתנה) משאיות 300+מצלמות · Jetson · GPS תחנות שקילהמאזני גשר · LPR עמודי ביטחוןמצלמות · YOLO בקצה חיישני מכולותמיקום + סוללה NVR / dvrהווידאו נשאר פה (D5) משדרים REST לאותןכתובות כמו ב-Wix השער Load Balancerapp.mis-26.comwildcard פר-לקוח Cloud ArmorWAF · אנטי-בוט · rate API Gatewayאימות טוקן לכל בקשה 35 נקודות קליטהחוזה ה-Wix 1:1 רק השער והדשבורדיםציבוריים. השאר פרטי ב-VPC השירותים (Cloud Run) mis-coreמשאיות · שקילות · משימות ·קליטה · התראות · משתמשים security-coreמצלמות · זיהויים · DB נפרד client-appאפליקציית הלקוחות (Next.js) report-engine · mcp-wrapperדוחות PDF/Excel · כלים לסוכני AI alerts-engine · ai-agentsחיים ב-dev (התראות בזמן אמת · שער LLM) פרוסות גם (בשתי הסביבות):רשויות · ביטחון · אדמין · נהג · קלאסי כל שירות: SA משלו + הרשאת DB משלו הדאטה Cloud SQL — sentrix-pgmis_core · security_coreפרטי לגמרי, ההווה החם BigQuery — האגםsentrix_lake + marts · העבר Cloud Storageתמונות · דוחות (לא וידאו) Secret Managerכל הסודות, פר סביבה Pub/Subsentrix-ingest-events + DLQמנויים: alerts · lake-sink כל שורה נושאת tenant_id —אין דאטה בלי שיוך ללקוח הלקוחות דפדפן / מובייל(מעטפת Median)holon.app-dev.mis-26.comכל לקוח בכתובתשלו, עם הלוגו שלו,רואה רק את שלו הלקוח נכנס דרך אותו שער בדיוק (HTTPS + טוקן) REST מאומת כותב/קורא מציג שתי סביבות זהות: mis26-dev (ניסויים) ו-mis26-prod (לקוחות). ההפרדה מלאה — פרויקט, רשת, DB וסודות נפרדים. אזור: me-west1 תל אביב · הכל מוגדר כקוד (Terraform) · scale-to-zero כשאין עומס
איור: התמונה הגדולה — איך כל רכיבי Sentrix מתחברים

MIS26 מפתחת פלטפורמת תפעול לרשויות מקומיות: פינוי פסולת, צי משאיות, תחנות שקילה, מצלמות אבטחה על עמודים, זיהויי AI, התראות ודוחות. הפלטפורמה החדשה — Sentrix — נבנתה מחדש על Google Cloud ומחליפה שלושה אתרי Wix. עיקרון הברזל של המעבר: הציוד בשטח (300+ משאיות, מאות מצלמות) ממשיך לשדר לאותן כתובות בדיוק ולא מרגיש כלום.

מבנה בקצרה: שער אחד (Load Balancer + Cloud Armor + API Gateway) ← שירותים פרטיים על Cloud Run‏ (mis-core, security-core, report-engine, mcp-wrapper, דשבורדים) ← Cloud SQL פרטי + Pub/Sub + BigQuery. הכל מוגדר כקוד (Terraform) במונו‑ריפו אחד.

מסמכי העומק: ARCHITECTURE-COMPLETE.md (הארכיטקטורה המלאה, כולל אנלוגיות לכל רכיב), PROJECT-MAP.md (התכנון), DECISIONS.md (כל החלטה סגורה, D1–D33), TOUR.md (סיור מנהלים), VISION-END-STATE.md (לאן זה הולך).

מפת הסביבות והכתובות

מה dev prod
פרויקט GCP mis26-dev mis26-prod
ענף Git שנפרס אליה dev main
הדלת הקדמית (LB + Armor) https://app-dev.mis-26.com https://app.mis-26.com
דומיין לקוח <slug>.app-dev.mis-26.com (למשל holon) <slug>.app.mis-26.com
API Gateway https://sentrix-gw-cnz6wcy2.ew.gateway.dev https://sentrix-gw-7omgn50c.ew.gateway.dev
בדיקת חיים /health ⇐ ‏200, ‏/trucks בלי טוקן ⇐ ‏401 אותו דבר
Cloud SQL sentrix-pg (private IP, אין גישה מהאינטרנט) sentrix-pg (HA + PITR)
בסטיון למסד sentrix-bastion (IAP בלבד) sentrix-bastion (IAP בלבד)

סטטוס תשתית — מה עוד חי בשתי הסביבות (שורה לרכיב, המסמך המעמיק בסוגריים):

עוד כתובות קבועות: הקוד — https://github.com/MIS-Make-It-Simple/sentrix; הקונסולה — console.cloud.google.com (לבחור פרויקט למעלה!); האתר השיווקי — mis-26.com (GoDaddy→Vercel, לא נוגעים); מסמכי סטטוס גרפיים — Drive‏ חומרים מוויקס/sentrix-status/. הדומיינים של שתי הסביבות מגישים מסך כניסה (redirect ל‑/signin); ‏401/403 מהשער בלי טוקן = האבטחה עובדת, לא תקלה.

כל הקונסולות — קישורים ישירים

לשמור כ‑bookmarks. אותו קישור, מחליפים mis26-devmis26-prod לפי הסביבה:

מה dev
Cloud Run — שירותים, revisions, לוגים https://console.cloud.google.com/run?project=mis26-dev
Cloud SQL — ‏sentrix-pg, גיבויים https://console.cloud.google.com/sql/instances?project=mis26-dev
Secret Manager — כל הסודות https://console.cloud.google.com/security/secret-manager?project=mis26-dev
Cloud Build — Builds (לצפות בפריסה) https://console.cloud.google.com/cloud-build/builds;region=me-west1?project=mis26-dev
Cloud Build — Triggers (‏deploy-dev-*) https://console.cloud.google.com/cloud-build/triggers;region=me-west1?project=mis26-dev
Cloud Scheduler — הג'ובים המתוזמנים https://console.cloud.google.com/cloudscheduler?project=mis26-dev
Monitoring — דשבורד sentrix-overview https://console.cloud.google.com/monitoring/dashboards?project=mis26-dev
Logs Explorer — חיפוש בכל הלוגים https://console.cloud.google.com/logs/query?project=mis26-dev
BigQuery — האגם (‏dataset ‏sentrix_lake) https://console.cloud.google.com/bigquery?project=mis26-dev
KMS — מפתחות הצפנה https://console.cloud.google.com/security/kms?project=mis26-dev
API Gateway — ‏APIs וקונפיגים https://console.cloud.google.com/api-gateway?project=mis26-dev

ברמת הארגון (לא פר פרויקט): ‏Security Command Center — https://console.cloud.google.com/security/command-center?organizationId=264541084239. נוחת בפרויקט הלא נכון? בורר הפרויקטים למעלה משנה הכל — לוודא לפני כל פעולה.

פרק צמוד — מלאי הענן: מה בדיוק עומד שם

הפרק הקודם נותן את התמונה. הפרק הזה נותן את הרשימה, כי בפעם הראשונה שמשהו נשבר בענן השאלה היא "מה בכלל קיים כאן", ולחפש את התשובה בקונסולה תוך כדי תקלה זה הזמן הגרוע לגלות אותה. כל שורה כאן קיימת כקוד תחת infra/terraform/ — 33 קובצי ‏.tf, מחולקים לפי תחום ולא לפי סוג משאב, ולכן sql.tf מחזיק את המסד עם ההרשאות שלו ו‑video-vault.tf מחזיק את הדלי, את הג'וב ואת ה‑IAM של הכספת באותו מקום.

שתי סביבות, אותו קוד.mis26-dev ו‑mis26-prod נבנות מאותו ריפו טרפורם עם ‏workspace שונה; ההבדל היחיד הוא ה‑tfvars. האזור הוא me-west1 (תל אביב) לכל דבר שיכול לחיות בו, פרט לגיבוי החוץ‑אזורי שיושב ב‑europe-west1 בכוונה.

‏11 שירותי Cloud Run ושני ג'ובים

שירות מה הוא ‏ingress ‏Service Account
mis-core הליבה: שקילה, צי, קליטה, ג'ובים, משתמשים, מדידה פרטי sa-mis-core
security-core מצלמות, זיהויים, וידאו חי — מסד נפרד פרטי sa-security-core
report-engine ‏PDF/Excel רב‑לשוני (Chromium בתוך האימג') פרטי sa-report-engine
alerts-engine צרכן pull של Pub/Sub, ‏min-instances=1 בכוונה פרטי sa-alerts-engine
notifications שליחה בפועל: מייל, וואטסאפ, פוש פרטי sa-notifications
ai-agents שער ה‑LLM: ניתוב מודלים ומדידת עלות פר לקוח פרטי sa-ai-agents
mcp-wrapper חשיפת אותם endpoints כ‑MCP לסוכני AI דרך השער sa-mcp-wrapper
client-app אפליקציית הלקוחות — כל המסכים, כל הלקוחות ‏LB בלבד sa-client-app
admin-console הקונסולה שלנו ‏LB בלבד sa-admin-console
exchange בורסות פסולת והובלה ‏LB בלבד sa-exchange
partner-portal תיעוד ה‑API לאינטגרטורים ‏LB בלבד sa-partner-portal

"פרטי" = ‏--no-allow-unauthenticated בתוך ה‑VPC: מי שלא מחזיק ‏roles/run.invoker על השירות מקבל 403 לפני שהקוד רץ. ארבעת ה‑apps נפרסים ‏--allow-unauthenticated --ingress=internal-and-cloud-load-balancing, כלומר גוגל מרשה כניסה אבל רק דרך ה‑Load Balancer — קריאה ישירה לכתובת ה‑run.app שלהם נחסמת ברשת. ל‑client-app בפרוד יש --min-instances=1 כדי שלקוח לא ייתקל ב‑cold start.

שני ג'ובים (‏Cloud Run Jobs, לא services): db-backup — הגיבוי הלוגי המוצפן, ו‑sentrix-migrate — ריצת ה‑Prisma migrations, שנקראת מתוך פס הפריסה של mis-core לפני שהתעבורה עוברת ל‑revision החדש.

רשת, זהות וסודות

דאטה

מה שם הערה
‏Cloud SQL (PostgreSQL) sentrix-pg שני מסדים: mis_core ו‑security_core. פרוד: HA + PITR
‏Memorystore (Redis) sentrix-cache ‏read-through ב‑mis-core
‏BigQuery — האגם sentrix_lake ‏Pub/Sub כותב אליו ישירות, בלי קוד ETL
‏BigQuery — החיתוכים sentrix_marts ‏scheduled queries: אנומליות דלק, תחזית מילוי, ניצולת צי
‏Cloud Storage <project>-lift-media תמונות פינוי
<project>-p2p-media מסמכי הובלות בין עסקים
<project>-cargo-media מסמכי מטען
<project>-construction-waste-docs טופסי פסולת בניין (טופס 4)
<project>-video-vault כספת קטעי הווידאו של הדמו והביקורת
<project>-dr-backups-euw הגיבוי החוץ‑אזורי (europe-west1)

Pub/Sub:sentrix-ingest-events + ‏sentrix-ingest-events-dlq, ו‑sentrix-notifications + ‏sentrix-notifications-dlq. לכל topic יש DLQ כי אירוע שנכשל חייב להישאר בעולם ולא להיעלם; מה שנכנס ל‑DLQ מופיע בהתראה.

הדלת הקדמית

‏Load Balancer עם תעודת wildcard על ‏*.app.mis-26.com (ובדב *.app-dev.mis-26.com), ‏Cloud Armor לפניו (WAF, אנטי‑בוט, ‏rate limiting), ומאחוריו ה‑API Gateway לכל תעבורת ה‑API. הנקודה החשובה ל‑url_map: client-app הוא ה‑default_service, ולכן תת‑דומיין חדש מגיע אליו בלי חוק host ובלי apply.

ארבע כתובות כן מחזיקות חוק host מפורש, כי הן מובילות לשירות אחר: ‏admin ⇒ admin-console, ‏mcp ⇒ mcp-wrapper, ‏partners ⇒ partner-portal ו‑exchange-* ⇒ exchange. הן שמורות, ולקוח לא יכול לקבל אותן כסלאג — אשף הקמת הלקוח בקונסולה קורא את אותה רשימה בדיוק ולכן הוא לא יכול לחלוק על ה‑LB.

auth הוא מקרה שלישי, וכדאי לא לבלבל אותו עם השניים: אין לו חוק host. הוא מגיע ל‑client-app כמו כל תווית אחרת, ושם ה‑middleware מרשה עליו רק את /__/auth/* ומחזיר 404 לכל השאר (פרק Firebase).

‏Cloud Build — חמישה טריגרים לסביבה

טריגר על מה הוא מופעל
deploy-<env>-<service> ‏9 שירותי ה‑backend, כל אחד עם ה‑cloudbuild-<service>.yaml שלו
deploy-<env>-client-app apps/client-app/** או חבילת פרונטאנד משותפת
deploy-<env>-admin-console apps/admin-console/**
deploy-<env>-exchange apps/exchange/**
deploy-<env>-partner-portal apps/partner-portal/**

הטריגרים הם אזוריים. זו הסיבה שכל פקודת gcloud builds חייבת ‏--region=me-west1; בלעדיה גוגל מחזיר את הבניות הגלובליות, כלומר רשימה ישנה עם עמודת טריגר ריקה, וזה נראה כמו "אין בניות" במקום "שאלת את האזור הלא נכון".

gcloud builds list --region=me-west1 --project=mis26-dev --limit=5 \
  --format='table(id,status,substitutions.TRIGGER_NAME,createTime)'

‏17 הג'ובים המתוזמנים

כולם נקראים כ‑POST /jobs/<name> על mis-core עם x-jobs-secret, ולכן כל אחד מהם אפשר להריץ ביד בדיוק כמו ש‑Scheduler מריץ אותו. השעות הן Asia/Jerusalem.

ג'וב ‏cron מה הוא עושה
site-offline-detection 23 23 * * * מסמן אתרי שקילה כלא‑מקוונים לפי סף heartbeat
daily-report 7 10 * * * שורות הדוח היומי פר לקוח
inside-truck-over-2h 0 * * * * התראה על משאית שנמצאת באתר מעל שעתיים
over-weight 21 20 * * * דוח חריגות משקל יומי
usage-rollup 10 3 * * * סוגר את מוני הצריכה של אתמול (טוקנים + LOAD_UNITS)
ituran-pull */10 * * * * ריענון מיקומים חיים לכל לקוח עם FLEET
ituran-history */5 * * * * משיכת היסטוריה צפופה, כדי שקצות מצבי העבודה יהיו מדויקים
ituran-segment 2-59/5 * * * * מגבש את הזרם לשורות TruckWorkSegment ולסמני מעבר‑מצב
statement-generate 40 3 2 * * סוגר את החודש שעבר לחשבוניות בלתי‑ניתנות לשינוי
partition-maintenance 17 3 1 * * פרטישן לחודש הבא + חלון חם על טבלאות האירועים
lake-anomalies-pull 10 4 * * * מודל אנומליות הדלק באגם ⇒ התראות FUEL_THEFT
legacy-pull-tick */20 * * * * זורע וממשיך את המשיכות מהמערכת המורשתית
video-vault-sweep 35 5-21 * * * דואג לקטע וידאו אמיתי אחד לכל משאית‑יום, שבעה ימים אחורה
signin-failure-pull 53 * * * * כשלי כניסה מ‑Identity Platform ⇒ שורות AuditLog
openai-costs-pull 20 7 * * * הוצאת OpenAI של אתמול ⇒ מונים + התראות תקציב
demo-connect-online */15 * * * * מכוון את משאית ה‑__live-demo של כל דמו למקליט שמקוון עכשיו
demo-refresh 0 4 * * * מריץ מחדש את זריעות יום‑הדמו לתאריך של היום
camera-health-sweep 20 * * * * לוכד ומסווג פריים מכל מצלמת משאית; פריים שחור = מצלמה מנותקת

כולם אידמפוטנטיים: הרצה חוזרת של אותו יום מתכנסת לאותה תוצאה ולא מכפילה שורות. זו לא נחמדות — זה מה שמאפשר להריץ ג'וב ביד אחרי תקלה בלי לחשוב.

מה שקוד טרפורם לא אומר לכם. כמה מהמשאבים כאן מסומנים בהערה ‏INTEGRATOR: NEW entry — takes effect only on the next terraform apply. זה אומר שהם קיימים בקוד וייכנסו לענן ב‑apply הבא, dev קודם. הדרך היחידה לדעת מה כבר עומד שם היא terraform plan בסביבה, ומי שמריץ apply הוא שניר (מי מאשר מה). עד ה‑apply הג'וב קיים אבל אף אחד לא קורא לו — ואפשר להריץ אותו ביד עם x-jobs-secret.

2. מפת הריפו — מה יושב איפה

מונו‑ריפו אחד (pnpm workspaces). שלוש משפחות קוד — apps (מה שהמשתמש רואה), services (ה‑backend), packages (משותף) — וסביבן תשתית, דאטה ותיעוד:

sentrix/
├── AGENTS.md              ← כללי הזהב המחייבים (אדם וסוכן AI) — נקרא ראשון
├── README.md              ← מפת־על קצרה · CLAUDE.md — הנחיות לסוכני קוד
├── package.json           ← שורש ה־monorepo · pnpm-workspace.yaml — רשימת החבילות
├── apps/                  ← פרונטאנד Next.js
│   ├── client-app/        ← **אפליקציית הלקוחות היחידה**. בוחרת מסכים לפי
│   │                        הסגמנט. products/{municipal,contractors,security,driver,shared}
│   ├── exchange/          ← בורסות פסולת והובלה
│   ├── partner-portal/    ← תיעוד ל-API לאינטגרטורים
│   └── admin-console/     ← הקונסולה שלנו: אישור משתמשים, roles, מודולים פר לקוח
├── services/              ← Fastify על Cloud Run, פרטיים מאחורי השער
│   ├── mis-core/          ← הליבה: שקילה, משאיות, 35 נקודות ingest‏ /_functions/*, ‏/jobs/*
│   │   └── prisma/schema.prisma ← עותק מסונכרן; הקנוני ב־packages/shared-db (פרק 7)
│   ├── security-core/     ← ביטחון: מצלמות ואירועים — מסד נפרד בכוונה
│   ├── report-engine/     ← מנוע דוחות PDF רב־לשוני (he/en/ar/es, מיתוג פר טננט)
│   ├── mcp-wrapper/       ← חשיפת המערכת כ־MCP לסוכני AI
│   ├── alerts-engine/     ← מנוע ההתראות (צרכן Pub/Sub, חי ב-dev) · notifications/
│   └── ai-agents/         ← שער ה-LLM: ניתוב מודלים, מדידה · db-backup/ — ג'וב הגיבוי
├── packages/              ← קוד משותף לכמה אפליקציות/שירותים
│   ├── catalog/           ← קטלוג המוצר: 37 מודולים, 32 סוגי רכב, 11 סגמנטים, פרופילי מסכים
│   ├── ui-kit/            ← מערכת העיצוב: רכיבים + src/theme.css (פרק 12)
│   ├── shared-db/         ← prisma/schema.prisma הקנוני + migrations של mis_core
│   ├── contracts-openapi/ ← החוזים — endpoint נולד קודם כאן (contract-first)
│   ├── api-client/        ← client שנגזר מהחוזים, בו משתמשים הדשבורדים
│   ├── shared-auth/       ← אימות והרשאות משותפים
│   └── shared-i18n/       ← כל מחרוזת, בארבע שפות he/en/ar/es
├── db/guardrails/         ← דרגות הגישה למסד וחוק ה־DDL (פרק 7) + apply.sh
├── infra/                 ← הענן כקוד
│   ├── terraform/         ← VPC, SQL, LB+Armor, ניטור — state ב־GCS
│   ├── cloudbuild/        ← cloudbuild-<service>.yaml — פס הפריסה פר שירות (פרק 5)
│   ├── api-gateway/       ← קובצי ה־openapi של השער ונוהל הגלגול (פרק 10)
│   └── env/               ← קונפיג לא־סודי פר סביבה
├── ingestion/             ← truck-ingest, pole-ingest, connectors — צינורות לאגם
├── scripts/               ← db-studio.sh (מנהרה+Studio) · lake-export.sh · ci/ (בדיקות CI)
├── docs/                  ← כל התיעוד · docs/conventions/ — המוסכמות המחייבות (פרק 17)
└── archive/               ← קוד ה־Wix הקפוא — רפרנס בלבד, לא מייבאים ממנו

הכלל: לוגיקה עסקית חדשה נכנסת ל‑service לפי תחום; UI — לאפליקציה לפי קהל; מה שמשותף לשניים ומעלה — package. לא בטוחים איפה משהו יושב? ‏README של אותה תיקייה.

פרק צמוד — אפליקציית הלקוחות האחת: איך זה בנוי היום

זה הפרק שחוסך את הבלבול הגדול ביותר בריפו: יש שירות Cloud Run אחד שמגיש את כל סוגי הלקוחות, ובתוכו ארבעה עצי מסכים. מי שמחפש "האפליקציה של הרשויות" או "האפליקציה של הביטחון" מחפש תיקייה, לא שירות.

המבנה בתוך apps/client-app/src

src/
├── middleware.ts          ← מחליט לאיזה טננט ולאיזה מוצר הבקשה שייכת
├── app/
│   ├── layout.tsx · globals.css
│   └── p/                 ← ארבעת עצי המסכים. לא נגישים מהאינטרנט (ראו למטה)
│       ├── municipal/     · contractors/    · security/    · driver/
├── products/              ← הקוד של כל מוצר: רכיבים, lib, טיפוסים, fixtures
│   ├── municipal/   (142 קבצים)   ← רשויות: פיקוח, מכולות, פסולת בניין
│   ├── contractors/ (149 קבצים)   ← קבלנים וצי: קוקפיט, משאיות, שקילה, דלק
│   ├── security/    (48 קבצים)    ← חדר בקרה: מצלמות עמודים, זיהויים
│   ├── driver/      (22 קבצים)    ← אפליקציית הנהג (PWA)
│   └── shared/      (7 קבצים)     ← מה שיותר ממוצר אחד צורך בפועל
├── components/            ← מעטפת האפליקציה, משותפת לכל המוצרים
├── lib/                   ← tenant.ts · tenant-runtime.ts · product.ts · session.ts
└── i18n/

products/shared/lib/ הוא הכלל שנוצר מהאיחוד: קובץ עובר לשם ברגע ששני מוצרים צורכים אותו בפועל, ולא "כי אולי יהיה שימוש". מה שיושב שם היום: הפרוקסי לאיתוראן, הווידאו החי, ה‑client של security-core, פיני המשאיות על המפה, ה‑hook של מקליט מקוון, וכיסוי ה‑i18n של הסיורים.

איך נבחר סט המסכים — שמונה צעדים ב‑middleware.ts

הסדר הזה הוא ה‑API של האפליקציה. הוא מתועד בקובץ עצמו, וזה התקציר:

# צעד למה הוא שם
1 קובץ סטטי בקשה לתמונה לא עוברת שער זהות. בלי זה /mis-logo.png מקבל 307 ל‑/signin
2 /__/* עוזר הכניסה של Firebase, שמוגש מהדומיין שלנו. מדלג על כל השאר בכוונה — פרק Firebase
3 נתיב פנימי בקשה שכבר מכילה /p/<product> נדחית ב‑404. הכתיבה‑מחדש היא הדרך היחידה לעצי המסכים
4 סלאג הטננט <slug>.<base> / apex / localhost
5 החלטת host חיפוש ב‑DB בזמן ריצה, ורק אם הוא נכשל — רשימת ההיתר שנאפתה לאימג'. ‏host שאין לו בעלים מקבל 404 לפני מסך הכניסה
6 הפניית רשות ‏308 עבור זוג mgroup- יחיד
7 המוצר lib/product.ts — הצעד שביטל את חוקי ה‑host בטרפורם
8 שער זהות קיום cookie בלבד. המאמת האמיתי הוא השער, לא הדפדפן
9 כתיבה מחדש /trucks/p/<product>/trucks, עם כותרות הטננט

צעד 7 נפתר בשש שכבות, וההיגיון בסדר שלהן הוא כל ההבדל בין "לקוח חדש עובד מיד" לבין "לקוח חדש דורש apply":

  1. תווית driver — אפליקציית הנהג היא אפליקציית תפקיד, לא אפליקציית לקוח: אותה PWA לנהג של כל טננט. התווית מכריעה.
  2. TENANT_PRODUCT_PINS — רשימה קפואה של כתובות שקדמו לפתרון בזמן ריצה. היא קיימת כדי שהאיחוד לא יזיז אף host חי, ובכלל זה demo-security ו‑demo-authorities, שמדגימים בכוונה את הפרופילים של אפליקציית הקבלנים. לקוח חדש לא נכנס לרשימה הזו לעולם.
  3. הסגמנט מה‑DB — התשובה האמיתית. הטננט נפתר בזמן ריצה, התשובה נושאת ‏segmentKey, סגמנט ממופה ל‑view-profile ב‑@sentrix/catalog, וה‑profile מצהיר איזו מעטפת ואילו מסכים המוצר מקבל. לקוח שנוצר לפני חמש דקות נוחת על המוצר הנכון בלי פריסה של שום דבר.
  4. TENANT_PRODUCT_MAP — גיבוי אפוי לצעד 3, בשימוש רק כשהחיפוש נכשל (תקלה, timeout), ולא כשהוא ענה. לקוח חדש לא נמצא בו ולא צריך להיות.
  5. אין API base בכלל — checkout טרי או dev מקומי. נופל ל‑DEFAULT_CLIENT_PRODUCT כדי ש‑pnpm dev יעבוד בלי קונפיגורציה.
  6. לא ידועמסרב. ‏503 עם מסך מוסבר, לא ניחוש. ניחוש כאן פירושו להגיש ללקוח אחד את המסכים של סוג לקוח אחר.

שני מוקשים שכדאי להכיר לפני שנוגעים ב‑middleware

הכתיבה‑מחדש נכנסת ל‑middleware פעם שנייה. על שרת Node עצמאי (מה שרץ בפרוד), ‏rewrite חוזר לצינור הבקשות, כלומר ה‑middleware רץ שוב על הנתיב הפנימי. בלי דרך להבדיל בין השניים, שומר הסף של צעד 3 נדלק על הכתיבה‑מחדש שלנו עצמה ומחזיר 404 לכל עמוד באפליקציה. הפתרון הוא REWRITE_MARKER — ערך רנדומלי שנטבע פעם אחת לתהליך ולא נכתב לשום תשובה. הוא חייב להיות לא‑ניחושי ולא רק "כותרת שקיימת": "תרשה את הנתיב הפנימי כשיש כותרת" היה מאפשר לקורא לשלוח GET /p/security/cameras עם ‏x-sentrix-runtime-tenant-id מזויף ולקרוא את חדר הבקרה של לקוח אחר.

כותרות שנקבעות בפנים נמחקות בכניסה. x-sentrix-product וכותרות הטננט של זמן ריצה נמחקות מהבקשה הנכנסת לפני שמשהו קורא אותן. מי שיכול לקבוע אותן בוחר לעצמו טננט ומוצר.

מה זה אומר בפועל ליום־יום

3. חוק הזהות — לפני הכל

פרק צמוד — ‏Firebase / Identity Platform: מה מוגדר ואיפה

הזהות של הלקוחות (לא שלכם — שלכם היא הזהות הארגונית מהפרק הקודם) מנוהלת ב‑Identity Platform, שהמסכים שלו יושבים בקונסולת Firebase. כדאי להתחיל מהמשפט שחוסך את החיפוש: אנחנו משתמשים ב‑Firebase רק לזהות. אין Firestore, אין Realtime Database, אין Firebase Hosting ואין Cloud Functions. הדאטה יושב ב‑Cloud SQL, האחסון ב‑Cloud Storage והשרתים ב‑Cloud Run. מי שמחפש קולקציית Firestore מחפש משהו שלא קיים.

שני פרויקטים, אחד לכל סביבה

מה dev prod
פרויקט Firebase (= פרויקט GCP) mis26-dev mis26-prod
מנפיק הטוקן (issuer) https://securetoken.google.com/mis26-dev …/mis26-prod
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN auth.app-dev.mis-26.com auth.app.mis-26.com
FIREBASE_AUTH_HANDLER_ORIGIN mis26-dev.firebaseapp.com mis26-prod.firebaseapp.com
מפתח ה‑web API ‏substitution בטריגר, לא סוד אותו דבר

מפתח ה‑web API וה‑authDomain ציבוריים בכוונה — הם מזהים את הפרויקט ולא מעניקים דבר. האכיפה היא בדיקת ה‑JWT בשער + רשימת הדומיינים המורשים ב‑Identity Platform. לכן הם יושבים כ‑substitutions בטריגר ה‑build ולא ב‑Secret Manager, והם נאפים לאימג' בזמן בנייה כמשתני NEXT_PUBLIC_*.

שני שדות נפרדים ל‑authDomain, וזו לא כפילות

זה החלק שנראה מבלבל עד שמבינים אותו, ולכן שווה את שלוש הפסקאות:

authDomain הוא הדומיין שהמשתמש רואה במסך בחירת החשבון של גוגל. כשהוא היה ‏mis26-prod.firebaseapp.com, לקוח שנכנס דרך גוגל התבקש "להמשיך אל ‏mis26-prod.firebaseapp.com" — כתובת שנראית ככלי פנימי, לא כמו המערכת שהוא קנה. לכן authDomain הוא auth.<app_domain>, כלומר הדומיין שלנו.

FIREBASE_AUTH_HANDLER_ORIGIN הוא איפה העוזר האמיתי של Firebase מתארח. ‏next.config.ts מגדיר rewrite מ‑/__/auth/* אליו, ולכן הדפדפן מדבר רק עם הדומיין שלנו וגוגל עדיין מקבל את הבקשה שלו:

// apps/client-app/next.config.ts
{ source: '/__/auth/:path*', destination: `https://${handler}/__/auth/:path*` }

הרווח השני, והחשוב יותר טכנית: הזרימה נהיית first-party. זה מה שמונע מ‑Safari לבלוע את תוצאת ה‑redirect בגלל storage partitioning — כל תווית *.<app_domain> היא אותו site כמו auth.<app_domain>.

‏host אחד משותף לכולם, לא אחד לכל לקוח. זו החלטת תכנון ולא קיצור דרך: ‏authDomain פר לקוח היה דורש רשומה ידנית בקונסולת גוגל (דומיין מורשה + redirect URI) בכל קליטת לקוח, ואחת ששוכחים שוברת את הכניסה של אותו לקוח בלי שום דבר בלוגים של השרת. ‏host אחד עולה זוג רשומות, פעם אחת.

auth אינה תווית לקוח: אין לה שורת Tenant והיא לא ברשימת ההיתר, ולכן כל נתיב עליה חוץ מ‑/__/auth/* מקבל 404 בשער ה‑host של ה‑middleware (צעד 5 בפרק הקודם). ה‑middleware מדלג עליה מפורשות בצעד 2 — בלי הדילוג הזה ה‑callback של OAuth, שמגיע מעצם הגדרתו בלי cookie, היה נזרק ל‑/signin, כלומר לקוח חוזר מגוגל אל מסך הכניסה שממנו יצא.

דרכי הכניסה שמוגדרות

ה‑SDK של Firebase מחזיק את ה‑session בדפדפן. אחרי כניסה, ובכל סבב טוקן שה‑SDK עושה, הלקוח שולח POST /api/session עם ה‑ID token הנוכחי, והוא נשמר בקוקי ‏httpOnly בשם sx-session. קוד השרת (עמודים, פרוקסי /api/*) קורא את הקוקי ומעביר את הטוקן כ‑Bearer אל ה‑API Gateway.

הנקודה שקובעת את כל מודל האבטחה: שרת ה‑Next לא מתייחס לקוקי כהוכחת זהות לעולם. ה‑middleware משתמש רק בקיום שלו כדי לנתב (אין קוקי ⇒ /signin), וכל בקשת דאטה מאומתת מחדש בשער מול JWKS של Identity Platform — בדיוק כמו תעבורת פרודקשן. לפני שמירה בקוקי נעשית בדיקת צורה בלבד (שלושה מקטעי base64url, אורך סביר); חתימה ותפוגה נבדקות בשער.

הקוקי חי 60 דקות, כמו תוקף ה‑ID token. ה‑SDK מסבב את הטוקן כחמש דקות לפני התפוגה ו‑SessionRefresher שולח את הקוקי מחדש בכל סבב, ולכן שעה שלמה נותנת לסבב מרווח אמיתי במקום להתחרות בו בדקה ה‑55.

מה עוד מחובר לזהות בצד הענן

4. היום הראשון — מסלול מודרך

ברירת המחדל: אשף התקנה גרפי, מהיום הראשון. אחרי clone (צעד 4 למטה) מריצים פקודה אחת, והאשף מבצע בפועל את כל צעדי ההקמה — עם התקדמות חיה, לוג מלא לכל צעד וכפתור Retry לצעד שנכשל:

pnpm setup:gui
# no pnpm yet? the wizard itself needs nothing beyond Node 20+:
node scripts/setup-wizard/server.mjs

נפתח דפדפן על http://localhost:4999 (מקומי בלבד). הצעד הראשון הוא שער זהות: האשף מאמת שה‑ADC של gcloud שייך לכתובת @mis.org.il ושמשתמש ה‑GitHub חבר בארגון MIS-Make-It-Simple — ומסרב להמשיך בלעדיהם (זה חוק הזהות מפרק 3, נאכף בקוד). משם הוא מריץ צעד‑צעד: בדיקת דרישות‑קדם, ‏pnpm install, יצירת לקוחות Prisma, קובצי env, בדיקת מנהרת ה‑DB, והרמת הדשבורד עד שהוא עונה על http://tzvi-cohen.localhost:3010. אלה בדיוק צעדי scripts/bootstrap-env.sh, רק עם ממשק; האשף גם מגדיר סביבת התקנה נכונה אוטומטית, אז אין דגלים לזכור.

מעדיפים טרמינל? אותם צעדים, שני סקריפטים. שניהם בטוחים להריץ שוב בכל פעם:

# fresh machine (installs tools, clones, installs, verifies):
curl -fsSL https://raw.githubusercontent.com/MIS-Make-It-Simple/sentrix/dev/scripts/new-station.sh | bash
# already cloned — bring the repo to a runnable state:
./scripts/bootstrap-env.sh            # add --tunnel to also open the dev-DB tunnel

new-station.sh מקים מחשב מאפס: מתקין כלים (Homebrew, ‏Node 20+, ‏pnpm דרך corepack, ‏gh, ‏gcloud, ‏Windsurf), מפעיל התחברויות, מושך את הריפו ל‑~/dev/sentrix, מתקין, מריץ prisma generate ו‑pnpm -r test. ‏bootstrap-env.sh לוקח ריפו קיים ומביא אותו למצב "אפשר להריץ אפליקציה מול dev": דרישות‑קדם, התקנה, לקוחות Prisma, תבנית .env ל‑DB, ועם --tunnel גם מנהרת IAP ל‑DB. שניהם מגדירים סביבת התקנה נכונה אוטומטית, ובסוף מדפיסים מה נשאר ידני (בקשות הגישה, פרק 3).

למי שמעדיף להתקין ידנית, כלי‑כלי: המסלול המלא נמצא ב‑docs/conventions/dev-environment.md. חשוב לדעת — יש שם שני מסלולי התקנה נפרדים, אחד ל‑Mac ואחד ל‑Windows (‏Homebrew מול winget/PowerShell). בוחרים את מערכת ההפעלה בתחילת ‏§5 וממשיכים רק במסלול שלה; שאר הסעיפים (התחברויות, ‏clone, הרצה) משותפים, עם הערות Windows בגוף הטקסט. ברירת המחדל לכולם היא האשף — המסלול הידני נועד למי שרוצה לראות כל בורג, או לאבחון תקלה.

מוסכמה קבועה בריפו: הוראות שלב‑אחר‑שלב נכתבות באנגלית (כלים משבשים עברית), ההסברים בעברית. המסלול הממוספר שלמטה הוא מה שהאשף מריץ מאחורי הקלעים — שימושי להבנה, לתקלות, ולמי שעובד בלי GUI.

  1. גישות (פרק 3) — ואימות: הריפו נפתח בדפדפן בלי 404, ו‑mis26-dev מופיע בבורר הפרויקטים בקונסולה.
  2. התקנת כלים — ‏git, ‏gh, ‏gcloud, ‏Node 20+, ‏pnpm ‏10.34.5 דרך corepack, ‏Windsurf (‏dev-environment ‏§5). אחרי כל התקנה: טרמינל חדש.
  3. התחברויות — ‏gh auth login --web + ‏gh auth setup-git; ‏gcloud auth login ‏+ gcloud auth application-default login; ‏gcloud config set project mis26-dev.
  4. Clone — הקוד יושב ב‑~/dev, לעולם לא בתוך Google Drive/OneDrive (הורס .git):
mkdir -p ~/dev && cd ~/dev
gh repo clone MIS-Make-It-Simple/sentrix
cd sentrix && git checkout dev
git config user.name "Your Name" && git config user.email "you@mis.org.il"
  1. התקנת תלויות:
CI=true pnpm install    # ~דקה; CI=true משתיק שאלות אינטראקטיביות

הערת שוליים (NODE_ENV): במחשבים מסוימים ה‑shell מייצא NODE_ENV=production באופן גלובלי, ואז pnpm מדלג על devDependencies (יופיע tsx: not found). מריצים env -u NODE_ENV CI=true pnpm install; האשף והסקריפטים מגדירים את סביבת ההתקנה הנכונה אוטומטית. פירוט: dev-environment §16 #1.

  1. להריץ משהו — הדשבורד ואז ה‑API (כל אחד בטרמינל משלו; פורטים: דשבורד 3000, ‏mis-core ‏8081, ‏security-core ‏8082, ‏report-engine ‏8083):
cp apps/client-app/.env.example apps/client-app/.env.local
pnpm --filter @sentrix/client-app dev
# browser: http://tzvi-cohen.localhost:3010  (subdomain = tenant, segment = screens)

pnpm --filter @sentrix/mis-core exec prisma generate
DATABASE_URL="postgresql://u:p@localhost:5432/db" PORT=8081 pnpm --filter @sentrix/mis-core dev
curl http://localhost:8081/health   # {"status":"ok"}

.env.local ריק זה מצב עבודה תקין (מסכי "not configured"). כל חמשת השירותים, אחד‑אחד — ‏dev-environment ‏§9.

להריץ שירות מול נתוני ה‑dev האמיתיים. שני טרמינלים. בראשון פותחים מנהרת IAP אל ה‑DB הפרטי דרך ה‑bastion (משאירים אותו רץ):

gcloud compute ssh sentrix-bastion --project=mis26-dev --zone=me-west1-a \
  --tunnel-through-iap -- -N -L 5439:10.17.0.3:5432 -o StrictHostKeyChecking=no

בשני מושכים את ה‑credential לקריאה בלבד ומריצים את mis-core מולו — הוא מדבר עם ה‑DB דרך המנהרה על 127.0.0.1:5439:

export DATABASE_URL="$(gcloud secrets versions access latest \
  --secret=SENTRIX_READONLY_DATABASE_URL --project=mis26-dev \
  | sed 's#@[^/]*/#@127.0.0.1:5439/#')"
PORT=8081 pnpm --filter @sentrix/mis-core dev
curl http://localhost:8081/trucks | head   # שורות אמיתיות מ‑dev

sentrix_readonly קורא בלבד בשתי הסביבות; עריכת נתוני דמו ב‑dev דורשת את ה‑credential של MIS_CORE_RUNTIME_DATABASE_URL (‏DML כן, ‏DDL יסורב), ופרוד לעולם לא נערך ביד. דרגות הגישה המלאות — פרק 7. 7. טסטים — ‏pnpm -r test (‏5,798 טסטים ירוקים בכל הריפו, ‏20 חבילות). 8. לגעת במערכת החיה — פתחו את /health ו‑/trucks של שער ה‑dev (טבלת פרק 1): ‏200 ואז 401. זו המערכת האמיתית שעונה לכם, וה‑401 הוא האבטחה שעובדת. 9. לקרוא את AGENTS.md — כללי הזהב המחייבים (אדם ו‑AI). ‏Windsurf/Cursor קוראים אותו אוטומטית דרך .windsurfrules/.cursorrules. בלי לקרוא אותו לא פותחים PR.

5. עבודה על קוד: ענפים, PR, ‏CI ופריסה

מפתח (או סוכן AI)‏feature branch → ‏PR ל-devבדיקות אוטומטיות + ‏auto-merge GitHub — ‏MIS-Make-It-Simple/sentrix ענף devאינטגרציה — כל מיזוג נפרס ענף main (מוגן)אין push ישיר — רק PR ‏PR ‏dev→main: סריקות חוסמות (Semgrep · Trivy · gitleaks) + ‏review — ממצא אחד והקוד לא נכנס mis26-dev — סביבת הפיתוח ‏Cloud Build me-west1, טריגר לכל שירות (רק מה שהשתנה נבנה): deploy-dev-mis-core · deploy-dev-alerts-enginedeploy-dev-client-app · … (12 טריגרים, אחד לשירות) ‏Cloud Run: ‏mis-core · security-core · mcp-wrapper · client-app ‏Cloud SQL ‏sentrix-pg (פרטי, ‏10.17.0.3) · סודות · ‏Pub/Sub · אגם ‏https://app-dev.mis-26.com ‏(34.160.36.137) ‏gateway: ‏sentrix-gw (config v16) · לקוח דמו: ‏holon ריצת CI מלאה: ‏40+ דקות → ‏~5 דקות (מקביליות + cache) mis26-prod — הסביבה החיה מיזוג ל-main מפעיל את אותם טריגרים בגרסת prod: deploy-prod-mis-core · deploy-prod-security-coredeploy-prod-client-app · … (אחד לכל שירות) תשתית זהה ל-dev, מבודדת לחלוטין: ‏DB, סודות ורשת נפרדים ‏rollback = החזרת ‏revision ב-Cloud Run (מיידי) ‏https://app.mis-26.com ‏(8.233.86.186) מיגרציות DB נוסעות עם הקוד — ‏expand-contract בלבד חי עם נתוני אמת — הקצה (המכשירים) עובר רק בקאט-אובר מסודר ‏sentrix-bastion (בשתי הסביבות)מנהרת IAP מהלפטופ אל ה-DB הפרטי:gcloud compute ssh sentrix-bastion --tunnel-through-iap ככה מריצים Prisma Studio או db push על DB פרטי בלי לחשוף אותו לאינטרנט (onboarding-he.md §5.1)
איור: סביבות, ענפים ופס הייצור (CI/CD)

הזרימה

feat/<scope>-<short>  ──PR──►  dev  ──פריסה אוטומטית──►  סביבת dev
                                │
                                └──PR (בשל, ירוק, מאושר)──►  main  ──►  prod
git checkout dev && git pull
git checkout -b feat/weighing-report
# ...work, small commits...
git push -u origin HEAD
gh pr create --base dev

מה רץ על כל PR (חובה ירוק)

בדיקה מה היא תופסת
ci — סודות + מבנה ריפו דפוסי מפתח/סיסמה בקוד; קבצים מחייבים חסרים
ci — משמעת סכמה שינוי ב‑schema.prisma בלי migration (פרק 7)
Semgrep (SAST) קוד לא בטוח — SQL ממחרוזות, קלט לא מאומת
Trivy (SCA+IaC) תלות עם CVE; ‏Dockerfile/Terraform לא מוקשחים
gitleaks סוד שקומט, כולל בהיסטוריה

תוצאה אדומה: לוחצים על ה‑check, רואים קובץ ושורה, מתקנים ודוחפים. השתקת ממצא — רק נקודתית ועם נימוק, לפי נספח ד׳. בדיקה מקומית מוקדמת: ‏pnpm -r test ופקודות הסריקה ב‑dev-environment ‏§14.

פריסה — Cloud Build, פר‑שירות

מיזוג ל‑dev מפעיל טריגר פר‑שירות (path filters): רק השירות שהשתנה נבנה ונפרס — ‏install ⇐ ‏build+test ⇐ ‏Docker ⇐ ‏Cloud Run, ‏~5 דקות. אותם טריגרים קיימים ל‑prod על main. ‏infra/cloudbuild/cloudbuild-<service>.yaml פר שירות (‏12 שירותים); ‏cloudbuild.yaml המלא נשאר לבנייה ידנית של הכל. מעקב: ‏GCP ⇐ ‏Cloud Build ⇐ ‏History (ירוק = באוויר).

היגיינת תור (skip-if-superseded): צעד 0 בכל אחד מ‑12 קובצי הפריסה בודק אם כבר ממתין build חדש יותר של אותו טריגר — ואם כן מבטל את עצמו. בהיסטוריה זה נראה ‏CANCELLED (אפור), לא FAILURE (אדום): כשממזגים כמה PRים ברצף זה צפוי ותקין — הקומיט האחרון הוא זה שנפרס. ההחלטה המתועדת: אם ה‑build החדש ביותר נכשל, הישן לא נפרס במקומו — השירות פשוט נשאר על ה‑revision הקודם.

אות אחד כש‑dev נשבר: ריצות CI על PR מקובצות (‏concurrency) — ‏push עוקב מבטל ריצה שהתיישנה במקום לתת לה להאדים. על push ל‑dev/main כל ריצה מקבלת verdict אמיתי, וכישלון על dev מדליק את ‏.github/workflows/dev-broken.yml: שורת לוג מובנית ל‑Cloud Logging ומייל Monitoring אחד (מוגבל לאחד בשעה) — במקום מבול "Run failed". מומלץ לכל מפתח לכבות את מייל ה‑Actions הפר‑ריצה: ‏GitHub ⇐ ‏Settings ⇐ ‏Notifications ⇐ ‏Actions ⇐ להוריד את הסימון מ‑Email.

קונפיגורציית build יושבת על הטריגר, ו‑Terraform הבעלים שלה

NEXT_PUBLIC_* — וכל ערך אחר ש‑Next צורב לתוך ה‑bundle בזמן בנייה — לא יכול להיות משתנה סביבה בזמן ריצה. הוא מגיע כ‑substitution של הטריגר, ומוצהר ב‑infra/terraform/cicd.tf. משם שני כללים:

merge  →  terraform apply  →  re-run של הטריגר  →  אימות

דילוג על ה‑re-run משאיר את השינוי בקוד ולא מופעל, וזה נראה בדיוק כמו "מה שעשיתי הלך לאיבוד".

הסדר ההפוך הוא המסוכן: קונפיגורציה שהוחלה מול קוד שעוד לא נפרס. ערך כמו authDomain שמופנה להוסט שהקוד עוד לא הקים מחזיר את Firebase ל‑404, כלומר אפס כניסות בפרוד. כשה‑apply תלוי בקידום, ‏makeprod.py דורש --allow-prod-drift ואומר את זה במילים.

ועוד מלכודת: ‏terraform plan קורא את ה‑checkout שעל הדיסק שלך, לא את GitHub. ‏No changes מיד אחרי מיזוג = כמעט תמיד git pull קודם.

Rollback — בשניות, בלי rebuild

קוד רע שנפרס? מזיזים תנועה ל‑revision הקודם ואז מתקנים בגיט:

gcloud run revisions list --service mis-core --region me-west1 --project mis26-prod
gcloud run services update-traffic mis-core --region me-west1 --project mis26-prod \
  --to-revisions <PREVIOUS_REVISION>=100

ואז git revert ל‑commit הרע. שני כללים שמשלימים את התמונה, ובלעדיהם החזרת תעבורה לא מספיקה:

הפירוט המלא, כולל החלטות חירום: ‏docs/conventions/rollback-and-db.md ‏§1+§4.

6. השינוי הראשון שלך מקצה לקצה

תרגיל חובה ביום־יומיים הראשונים: שינוי UI זעיר, מהעורך ועד שהוא חי אצל לקוח בסביבת dev. בסופו ראיתם את כל מסלול הפריסה פעם אחת בעיניים — ואין יותר "קסם".

1. השינוי — צבע כפתור בדשבורד הקבלנים. הכפתורים מקבלים צבע מ־classes של ui-kit‏ (פרק 12), אז "לשנות צבע" = להחליף class:

git checkout dev && git pull
git checkout -b feat/first-change-<yourname>
# open apps/client-app/src/app/p/contractors/(dashboard)/page.tsx
# find the button with className "sx-btn sx-btn--primary"  (line ~304)
# change it to "sx-btn sx-btn--danger"   (brand green -> danger red, from theme.css)
pnpm --filter @sentrix/client-app dev
# check http://tzvi-cohen.localhost:3010 - the button is red

2. קומיט ⇐ דחיפה ⇐ ‏PR ל‑dev:

git add -p && git commit -m "feat(dashboard): first-change exercise - button color"
git push -u origin HEAD
gh pr create --base dev --title "first change exercise" --fill

3. הבדיקות רצות על ה‑PR — ‏ci (סודות, מבנה, סכמה), ‏gitleaks, ‏Semgrep, ‏Trivy (פרק 5). אין דרישת review.ci ירוק הוא כל השער, והוא נדרש (ruleset ‏guard-dev) — כלומר כפתור המיזוג אפור עד שהוא חוזר ירוק, וזה חוסם ולא נימוס. הדרך המומלצת: ללחוץ Enable auto-merge ולעזוב; GitHub ימזג לבד. אם dev זז בינתיים תראו "This branch is out-of-date" — לוחצים Update branch ומחכים לריצה נוספת, וזה המחיר היומי של מיזוג מאוחר. רק main שמור ודורש אישור בעל‑קוד (בהמשך הפרק).

4. לצפות בפריסה — ‏Cloud Build. המיזוג מדליק בדיוק טריגר אחד: deploy-dev-client-app — כי השינוי נגע רק ב‑apps/client-app/** (טריגר פר שירות עם path filters; שירות שלא השתנה לא נבנה ולא נפרס). פותחים את Builds בקונסולה (טבלת הקישורים בפרק 1) ורואים את השלבים חיים: ‏install ⇐ ‏build+test ⇐ ‏Docker ⇐ ‏deploy. ‏~5 דקות עד ירוק.

5. לראות את זה חי אצל "לקוח":https://holon.app-dev.mis-26.com ⇐ מסך הבית — הכפתור אדום. זה היה deployment אמיתי, מקצה לקצה. עכשיו החזירו את הצבע (git revert באותו מסלול בדיוק) — ה‑revert הוא חצי מהתרגיל: לתקן קדימה זה הרגל, לא ידע.

ומה הלאה — המסלול לפרוד. ‏PR מ‑dev ל‑main, ושם הרף גבוה יותר:

מיזוג ל‑main פורס אוטומטית לפרוד (deploy-prod-*, אותו מנגנון בדיוק) — לכן ל‑main מגיע רק מה שכבר חי ב‑dev, ירוק ונצפה עובד.

פרק צמוד — לפרסם פיצ'ר חדש מקצה לקצה

זה המדריך המודרך, קליק‑אחרי‑קליק, שמלווה את פרקים 5 ו‑6. פרק 6 מראה את המסלול בקצרה; כאן עוברים אותו לאט עם פיצ'ר אמיתי אחד — מהעורך ועד שהוא חי אצל לקוח ב‑dev, ואז המסלול לפרוד. אחרי שעושים את זה פעם אחת, אין יותר "קסם" בפריסה.

הפיצ'ר לדוגמה: להוסיף אריח KPI בשם "מכולות פעילות" לשורת ה‑KPI בקוקפיט הקבלנים. שינוי אמיתי, קטן ונראה לעין — בדיוק מה שצריך בפיצ'ר ראשון. מי שרוצה גרסת שתי דקות: שינוי תווית בלבד בסוף הפרק. ומי שכבר עבר את התרגיל — הרמה השנייה: מסך שלם מאחורי מודול ופרופיל, הדפוס שבו נכנס כמעט כל פיצ'ר אמיתי היום.

שלב 0 — לפני שנוגעים

שלב 1 — ענף עבודה (או worktree)

כל משימה מקבלת ענף משלה מ‑dev מעודכן. שתי דרכים, בחרו אחת:

# דרך א' — ענף פשוט בתוך ה‑clone הקיים:
cd ~/dev/sentrix && git checkout dev && git pull
git checkout -b feat/dashboard-active-containers-kpi

# דרך ב' — worktree מבודד (מומלץ כשעובדים על כמה דברים במקביל; לא נוגע ב‑clone):
git -C ~/dev/sentrix fetch origin
git -C ~/dev/sentrix worktree add ~/dev/sx-kpi -b feat/dashboard-active-containers-kpi origin/dev
cd ~/dev/sx-kpi

שלב 2 — השינוי עצמו (שני קבצים)

2א. אריח ה‑KPI בקובץ apps/client-app/src/app/p/contractors/(dashboard)/page.tsx. שורת ה‑KPI היא ה‑grid הזה, עם חמישה StatCard מ‑@sentrix/ui-kit (פרק 12):

<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-5">
  <StatCard label={t('statActiveTrucks')} value={activeTrucks ?? dash} .../>
  {/* ...עוד ארבעה אריחים... */}
</div>

מוסיפים אריח שישי לפני סגירת ה‑</div>, ומרחיבים את מספר העמודות ל‑6:

  <StatCard
    label={t('statActiveContainers')}
    value={fleet ?? dash}
    tone="info"
  />
</div>

fleet ו‑dash כבר קיימים בקומפוננטה, אז זה מתקמפל מיד; חיווט ספירה אמיתית מ‑mis-core הוא follow‑up. שנו גם xl:grid-cols-5 ל‑xl:grid-cols-6 בשביל שורה של שישה. מבנה ה‑props של StatCard: ‏label ו‑value חובה, והשאר אופציונלי (of/sub/cta/tone/href).

2ב. הטקסט בארבע שפות. המפתח statActiveContainers חייב להתקיים תחת מרחב השמות cockpit בכל ארבעת קבצי ההודעות packages/shared-i18n/src/messages/{he,en,ar,es}.json (מפתח חסר בשפה אחת שובר את הבנייה):

// he.json  → בתוך "cockpit": { ... }
"statActiveContainers": "מכולות פעילות",
// en.json
"statActiveContainers": "Active containers",
// ar.json
"statActiveContainers": "حاويات نشطة",
// es.json
"statActiveContainers": "Contenedores activos",

שלב 3 — התקנה, ריצה ובדיקה מקומית

env -u NODE_ENV CI=true pnpm install     # רק אם הוספת/שינית תלות; כאן לא חובה
pnpm --filter @sentrix/client-app dev
# בדפדפן: http://tzvi-cohen.localhost:3010  → האריח "מכולות פעילות" מופיע בשורת ה‑KPI
pnpm --filter @sentrix/client-app test   # טסטים ממוקדים לאפליקציית הלקוחות
pnpm -r test                                        # כל הריפו ירוק לפני push

ירוק מקומי חוסך סבב אדום ב‑CI. אם tzvi-cohen.localhost לא נפתח — dev-environment §16 #9.

שלב 4 — קומיט

Conventional Commits, באנגלית, בזמן הווה (פרק 5):

git add "apps/client-app/src/app/p/contractors/(dashboard)/page.tsx" \
        packages/shared-i18n/src/messages/he.json \
        packages/shared-i18n/src/messages/en.json \
        packages/shared-i18n/src/messages/ar.json \
        packages/shared-i18n/src/messages/es.json
git commit -m "feat(dashboard): add active-containers KPI tile to contractor cockpit"

שלב 5 — push ו‑PR ל‑dev

git push -u origin HEAD
gh pr create --base dev --title "feat(dashboard): active-containers KPI tile" --fill

--base dev הוא קריטי: ה‑PR נפתח מול ענף האינטגרציה, לא מול main.

שלב 6 — שערי ה‑CI שחייבים לעבור

ברגע שנפתח PR, ארבע בדיקות רצות עליו אוטומטית. כולן חוסמות מיזוג:

בדיקה (שם ה‑check) מה היא בודקת מה תתפוס
ci install + prisma generate + typecheck + test בכל ה‑workspaces, ‏+ שומרי מבנה/סודות/סכמה טסט אדום, טייפ שבור, שינוי schema.prisma בלי migration
gitleaks (secrets) הריפו וכל ההיסטוריה מפתח/סיסמה שקומטו בטעות
semgrep (SAST) הקוד שלנו SQL ממחרוזת, ‏endpoint בלי בדיקת קלט
trivy (dependencies + IaC) תלויות ותשתית תלות עם CVE, ‏Dockerfile/Terraform לא מוקשח

בדיקה אדומה: לוחצים על שם ה‑check ב‑PR ⇐ רואים קובץ ושורה מדויקים ⇐ מתקנים ודוחפים שוב (כל push מפעיל את הבדיקות מחדש). מהטרמינל: gh pr checks --watch. לרוץ מקומית לפני push: pnpm -r test ופקודות הסריקה ב‑dev-environment §14.

שלב 7 — מי ממזג ל‑dev

אתם. מיזוג ל‑dev דורש דבר אחד: ‏CI ירוק. אין דרישת review ואין תפקיד שמאשר — מה שמחזיק את האיכות הוא הבדיקות והשומרים ב‑scripts/ci/, ולכן בדיקה אדומה היא עצירה מלאה ולא הצעה. הדרך המומלצת: ללחוץ Enable auto-merge ב‑PR ולעזוב אותו; GitHub ימזג לבד ברגע שהבדיקות ירוקות, בלי לשמור על הדף.

gh pr merge --auto --squash

רק המיזוג ל‑main שמור ודורש אישור בעל‑קוד (שלב 10).

מה dev באמת אוכף (ruleset ‏guard-dev):

הכלל מה זה אומר לכם
ci הוא בדיקה נדרשת כפתור המיזוג אפור עד שהיא ירוקה. זה לא נימוס, זה חוסם
Require branches to be up to date dev זז? תראו "This branch is out-of-date" ותצטרכו ללחוץ Update branch ולחכות לריצה נוספת. זו הסיבה למזג ביום שסיימתם — כל יום שעובר מוסיף עוד סבב
‏Block force pushes + אין מחיקה גם לא בטעות
אין דרישת PR תיקון תיעוד אפשר לדחוף ישר ל‑dev, בלי ענף

חריג אחד: מי שהוא Repository admin נמצא ב‑bypass list ולכן יכול למזג בלי לחכות ל‑CI ולדחוף ישר. זה קיים בכוונה, להחלטה של שניר, ולא כדי לדלג על בדיקה אדומה — ‏bypass חל על כל ה‑ruleset, כלומר הוא פותח גם force‑push ל‑dev. מי שמשתמש בו שובר סביבה לשלושה אנשים כשהוא טועה, ו‑dev-broken.yml יצעק על זה.

למה זה בטוח למזג ביום שסיימתם, גם אם הפיצ'ר לא אושר ללקוחות: כי מיזוג ל‑dev הוא החלטה הנדסית (זה נבנה? הטסטים עוברים?), והשאלה אם לקוח רואה את הפיצ'ר היא החלטה נפרדת של המוצר — דגל פיצ'ר, לא ענף שמחזיקים. ראו שלב 11.

שלב 8 — לצפות בפריסה ל‑dev (Cloud Build)

המיזוג ל‑dev מדליק טריגר אחד בלבד: deploy-dev-client-app — כי השינוי נגע רק ב‑apps/client-app/** (טריגרים פר‑שירות עם path filters; שירות שלא השתנה לא נבנה ולא נפרס). איפה רואים: קונסולת GCP ⇐ Cloud Build ⇐ History (פרויקט mis26-dev). השלבים חיים: ‏install ⇐ build+test ⇐ Docker ⇐ deploy ל‑Cloud Run, בערך 5 דקות עד ירוק.

רואים build שלכם ב‑CANCELLED אפור? זה מנגנון ה‑skip-if-superseded (פרק 5): מוזג אחריכם קומיט חדש יותר לאותו שירות, וה‑build הישן פינה לו את התור. הקוד שלכם נפרס בכל זאת — בתוך ה‑build של הקומיט החדש.

שלב 9 — לראות את זה חי

פותחים ‏https://holon.app-dev.mis-26.com (לקוח הדמו) או https://demo.app-dev.mis-26.com, מזדהים עם Google — האריח "מכולות פעילות" נמצא עכשיו בשורת ה‑KPI. זה היה deployment אמיתי, מקצה לקצה, על אותה תשתית שהלקוחות רואים.

שלב 10 — המסלול לפרוד (dev → main)

כשהשינוי בשל ונצפה עובד ב‑dev, מקדמים אותו לפרוד ב‑PR מ‑dev ל‑main. הרף גבוה יותר:

gh pr create --base main --head dev --title "release: active-containers KPI tile" --fill

לכן ל‑main מגיע רק מה שכבר חי ב‑dev, ירוק, ונראה עובד בעיניים.

שלב 11 — מי רואה את זה: דגלי פיצ'רים

עד כאן הקוד. עכשיו השאלה האמיתית של סוף הספרינט.

שלושה מפתחים עובדים במקביל. כל אחד מסיים משימה, בודק על localhost, ודוחף ל‑dev. אחת לשבועיים מקדמים את כל dev ל‑main וזה מגיע לפרודקשן. אז מה קורה כשבסוף הספרינט המוצר אומר שפיצ'ר X מוכן ללקוחות ופיצ'ר Y לא?

התשובה היא לא "נקדם רק את X". ‏PR מ‑dev ל‑main לוקח את כל מה שנמצא על dev, וזה בכוונה:

לכן מצב החשיפה יושב בנתונים. הקוד מתקדם בקצב שלו; מי רואה אותו זו החלטה נפרדת והפיכה, שמתקבלת בקונסולת האדמין במסך דגלי פיצ'רים (/feature-flags).

שלושה מפתחים, שלושה מתגים — ולא לבלבל ביניהם:

המתג השאלה של מי לכמה זמן
FeatureFlag הקוד בשל להיראות? מוצר זמני — נמחק אחרי ההשקה
TenantModule הלקוח קנה את זה? מכירות כל עוד הלקוח אצלנו
CapabilityExclusivity הלקוח בכלל רשאי? חוזה כל עוד ההתחייבות בתוקף

פיצ'ר מגיע למסך רק כששלושתם אומרים כן. אסור להשתמש ב‑TenantModule בתור דגל: הוא שורת החיוב החודשית (priceOverride, SubscriptionStatus, ו‑TruckModuleUsage מודד לפי המפתח שלו), כך שהדלקה שלו כדי שמוצר יבדוק משהו פותחת שורת חיוב, וכיבוי של פיצ'ר לא בשל נראה בדיוק כמו ביטול מנוי.

איך דגל נפתר:

השורה של הלקוח הזה  →  השורה הגלובלית (tenantId ריק)  →  false

סגור כברירת מחדל, בכל שכבה: אין שורה, הטבלה לא מוגרה, השאילתה נכשלה — הכול כבוי. ההיפך, "פתוח כשלא ידוע", היה שולח עבודה לא מאושרת ללקוחות בגלל תקלת DB אחת.

וה‑tenantId שיכול להיות ריק הוא כל התרגיל:

FeatureFlag  key=route-eta-v2  tenantId=<demo-sanitation>  enabled=true    ← מוצר בודק כאן, על פרוד
FeatureFlag  key=route-eta-v2  tenantId=NULL               enabled=false   ← כל לקוח אמיתי

אותו קוד בשני המקומות. זו גם הסיבה שאין סביבת stage (D23): מוצר מאמת על dev כשהדגל דלוק שם וכבוי בפרוד, והדגמה אמיתית רצה על טננט הדגמה בפרודקשן עצמו — תשתית אמיתית, בלי Cloud SQL שלישי.

להוסיף דגל — שתי שורות, באותו PR של הפיצ'ר:

  1. הצהרה ב‑packages/catalog/src/feature-flags.ts ומראה ב‑services/mis-core/src/lib/feature-flags.ts (שירותים רצים JS מקומפל ולא מייבאים את חבילת הקטלוג; טסט parity נופל אם השניים מתפצלים). להצהרה יש שם בארבע שפות, אחראי, ותאריך מחיקה (retireAfter).
  2. הבדיקה עצמה, במקום שבו הפיצ'ר נחשף:
if (!(await isFeatureEnabled(prisma, 'route-eta-v2', tenantId))) {
  return reply.code(404).send({ error: 'Not found' });
}

מפתח שלא הוצהר זורק שגיאה. טעות הקלדה שהייתה מחזירה בשקט false פירושה פיצ'ר שלא נדלק לעולם, בזמן שבקונסולה הדגל מוצג דלוק.

מה ש"כבוי" חייב להיות: הפיצ'ר צריך להיות לא מזיק, לא שבור. שתי דוגמאות שכבר בקוד: partner-api כבוי ⇐ כל טוקן שותף נדחה ב‑403 והקוד פרוס ולא משרת אף אחד; alert-dispatch-email כבוי ⇐ המנוע ממשיך לזהות ולכתוב התראות (הפעמון, מסך ההתראות והקופיילוט לא מושפעים), והשליחה נדלגת ונרשמת כשורת NotificationDelivery עם הסיבה channel-disabled.

שינויי סכמה של פיצ'ר מאחורי דגל הולכים ב‑expand‑contract כרגיל (פרק 7): העמודות מגיעות לפרוד באופן אדיטיבי לפני הקוד שקורא אותן, והדגל מחליט מתי מסתכלים עליהן.

ולמחוק את הדגל. דגל הוא זמני. אחרי שפיצ'ר דלוק לכולם ונשאר דלוק — מוחקים את ההצהרה ואת קריאות isFeatureEnabled. הטסט ב‑feature-flags.test.ts נופל ברגע ש‑retireAfter עובר, כך שדגל נשכח הופך לבילד אדום ולא ל‑if נצחי שאף אחד לא מעז להוציא. ברירת המחדל: שני ספרינטים.

וזה החלק הטוב: אם בסוף הספרינט המוצר אומר "לא בשל" — הדגל פשוט נשאר כבוי. אין מה להחזיר אחורה, אין מה להוציא מ‑dev, והספרינט הבא ממשיך מעל מה שכבר מוזג. בלי דגלים, "לא בשל" פירושו revert של קומיטים שבועיים אחרי שנכתבו, בקבצים משותפים, אולי עם מיגרציה — וזה בדיוק מה שדוחף אנשים להחזיק ענפים שבועיים, ומשם מגיעות שתי מיגרציות שנוצרו מול אותו baseline.

שלושה כללים שסוגרים את הנושא, וכולם נובעים מ‑D31:

המוסכמה הקנונית, על כל דוגמאותיה, יושבת ב‑docs/conventions/feature-flags.md.

הרמה השנייה — מסך חדש מאחורי מודול ופרופיל (הדפוס המודרני)

אריח KPI הוא תרגיל; פיצ'ר אמיתי ב‑Sentrix הוא כמעט תמיד מסך שנמכר כמודול: הוא מופיע רק אצל לקוח שקנה אותו, רק בסגמנטים שהוא רלוונטי להם, ורק למשתמשים שהורשו. כל זה קונפיגורציה בקטלוג — לא תנאים מפוזרים בקוד. ככה נכנסו לאחרונה ‏P2P (הוכחה‑לתשלום) ו‑NAVIGATION — שני התקדימים הכי טריים להעתיק מהם (docs/evidence/2026-07-26/p2p-earthworks.md, ‏navigation-module.md).

נניח שמכרנו מודול חדש בשם "ביקורת אתרים" (SITE_AUDIT) עם מסך site-audit בדשבורד הקבלנים. תשעה צעדים, לפי הסדר:

שלב א — שורת קטלוג (המודול נולד). מוסיפים mod(...) אחד ב‑packages/catalog/src/modules.ts: מפתח, משפחה, שם בארבע שפות, מחיר, סגמנטים וסוגי רכב (‏[] = כל הצי). חובה לשקף את אותה שורה במראה של mis-core — ‏services/mis-core/src/modules/admin-tenants/registry.ts; טסט ה‑parity נכשל על כל סטייה בין השניים, אז אי אפשר לשכוח בטעות.

שלב ב — המסך מצטרף לפרופיל. ב‑packages/catalog/src/view-profiles.ts מוסיפים את 'site-audit' לרשימת ה‑screens של הפרופילים הרלוונטיים (למשל ‏waste-contractor), וב‑packages/catalog/src/resolve.ts מוסיפים שורה ל‑SCREEN_MODULE: ‏'site-audit': 'SITE_AUDIT' — מעכשיו המסך קיים רק כשהמודול מזוכה לטננט. התראות של המודול נכנסות באותה צורה ל‑ALERT_KIND_MODULE.

שלב ג — כניסת הניווט.PROFILE_SCREEN_NAV ב‑apps/client-app/src/products/contractors/lib/segment-view.ts מקבל רשומה: ‏href (‏/site-audit) ואייקון. התפריט נבנה מהפרופיל — אין הוספת פריט תפריט ידנית פר‑אפליקציה.

שלב ד — העמוד עצמו, עם שומר URL.

// apps/client-app/src/app/p/contractors/(dashboard)/site-audit/page.tsx
import { requireProfileScreen } from '@/lib/catalog-view';

export default async function SiteAuditPage() {
  const view = await requireProfileScreen('site-audit');  // 403/redirect אם אין זכאות
  // ...הרכבת המסך מרכיבי ui-kit (פרק 12)...
}

הסתרת פריט התפריט היא נוחות; ‏requireProfileScreen הוא האכיפה — גם מי שמנחש את ה‑URL לא נכנס בלי זכאות. אם יש גם ‏API חדש ב‑mis-core, הוא נשמר באותו דפוס: ‏requireModulePermission('SITE_AUDIT','read') על כל endpoint (‏403 ‏MODULE_NOT_ENABLED / ‏MODULE_FORBIDDEN), והחוזה נולד קודם ב‑packages/contracts-openapi (פרק 11).

שלב ה — טקסטים בארבע שפות. כמו בדוגמת ה‑KPI: כל מחרוזת חדשה בארבעת קובצי packages/shared-i18n/src/messages/{he,en,ar,es}.json; מפתח חסר בשפה אחת שובר את הבנייה (טסט ה‑parity של המפתחות).

שלב ו — טסטים לפני PR.pnpm --filter @sentrix/catalog test מריץ בין השאר את segment-matrix.test.ts — טסט הדליפה שמוכיח שהמסך מגיע בדיוק לתאי (סגמנט × סוג רכב) שהגדרתם ולשום תא אחר; ואז pnpm -r test לכל הריפו.

שלב ז — לזכות לקוח ולבדוק חי. אחרי המיזוג, במסך Modules של ‏admin-console מדליקים את SITE_AUDIT לטננט דמו (פרק 20 שלב 3) — או מוסיפים אותו ל‑seed (‏packages/shared-db, בדפוס seed:demo-segments). ההרשאה פר‑משתמש כבר קיימת: מסך "גישה" באדמין מציג אוטומטית כל מודול שזוכה לטננט, עם ‏default/read/write/manage/deny — אין UI חדש לבנות.

שלב ח — PR ⇐ פריסה ⇐ אימות. אותם שלבים 5–9 מהדוגמה הראשונה. כאן יידלקו שני טריגרים (‏deploy-dev-client-app + ‏deploy-dev-mis-core, כי נגעתם גם ב‑registry). בדיקת הקצה: טננט עם המודול רואה את המסך; טננט בלי — לא רואה אותו בתפריט ומקבל סירוב על ה‑URL; משתמש עם deny — מקבל סירוב גם כשהטננט זכאי.

שלב ט — להכריז. פופאפ "מה חדש" ניזון מלוח monday (מודול whats-new ב‑mis-core): מוסיפים שם פריט על הפיצ'ר, ועמודת הקהל (מערכות (whats-new)) דואגת שהוא יוצג רק לקהלים הרלוונטיים — הכרזה על מסך פסולת לא תופיע אצל צי הסעות.

גרסת שתי הדקות — שינוי תווית בלבד

פיצ'ר ראשון קטן עוד יותר: לשנות טקסט של אריח קיים. עורכים קובץ אחד, packages/shared-i18n/src/messages/he.json, תחת "cockpit":

"statActiveTrucks": "משאיות פעילות כרגע",   // במקום "משאיות פעילות"

ואז שלבים 3–9 בדיוק כמו למעלה — בלי שינוי קוד ובלי תלות, רק טקסט. זה עדיין deployment מלא ואמיתי מקצה לקצה, והדרך המהירה ביותר לראות את כל הצינור עובד.

אחרי שסיימת: git revert לשינוי הוא חצי מהתרגיל. לתקן קדימה זה הרגל, לא ידע. נתקעת במשהו שאין לו תשובה כאן? פתור, ואז הוסף אותו למסמך הזה ב‑PR.

7. בסיס הנתונים: שלוש דרגות גישה, וחוק ה‑DDL

שתי בסיסי נתונים ב‑instance אחד פר סביבה: mis_core (תפעול) ו‑security_core (ביטחון, מבודד בכוונה). הגישה מדורגת בשלוש רמות, וכל רמה היא credential נפרד ב‑Secret Manager:

דרגה מי משתמש מותר אסור (ונחסם טכנית) הסוד
קריאה בני אדם: ‏Windsurf DB Client, ‏psql, ‏Prisma Studio SELECT בשני המסדים כל כתיבה, כל DDL SENTRIX_READONLY_DATABASE_URL
ריצה השירותים (Cloud Run) DML מלא (קריאה/כתיבה) כל DDL MIS_CORE_RUNTIME_DATABASE_URL, SECURITY_CORE_RUNTIME_DATABASE_URL
מיגרציה ג'וב ה‑CI בלבד (sentrix-migrate, security-core-migrate) DDL מלא במסד שלו המסד של השירות השני MIS_CORE_MIGRATOR_DATABASE_URL, SECURITY_CORE_MIGRATOR_DATABASE_URL

חוק ה‑DDL — שינויי מבנה עוברים רק דרך קוד

זה לא נוהל, זו אכיפה בתוך המסד עצמו (שתי שכבות: הרשאות+בעלות, ו‑event trigger). מי שיפתח את התוסף בווינדסרף — או כל כלי אחר — עם הרשאת מפתח וינסה להוסיף עמודה, למחוק טבלה או ליצור אחת, יקבל בדיוק את זה:

ERROR: DDL is locked on this database (role "sentrix_readonly", command ALTER TABLE).
Schema changes go through code only: edit schema.prisma, open a PR, let CI run
the migration job. See docs/conventions/rollback-and-db.md and db/guardrails/README.md.

הכלי לא "יסכים" — המסד מסרב. המסלול הנכון לשינוי מבנה:

  1. עורכים את schema.prisma (‏mis_core: ‏packages/shared-db, והעתק זהה ב‑services/mis-core; ‏security: ‏services/security-core).
  2. מייצרים migration‏ (pnpm prisma migrate dev --name <change>), מקמטים אותו.
  3. ‏PR. בדיקת ה‑CI תיכשל אם הסכמה השתנתה בלי migration (בתקופת ה‑db push הנוכחית אפשר לוותר במודע עם SCHEMA-PUSH-OK: <סיבה> בגוף קומיט).
  4. מיזוג ל‑dev מריץ את ג'וב המיגרציה על dev; מיזוג ל‑main — על prod.

שינוי לא‑שביר תמיד (expand‑contract): מוסיפים, מאכלסים, מחליפים קריאה, ורק בסוף מוחקים — rollback-and-db.md ‏§2. פעולה הרסנית = אישור + גיבוי לפני. אסון דאטה = ‏PITR‏ (§3).

איך ניגשים למסד בפועל (מנהרת bastion)

למסדים אין כתובת אינטרנט. הדרך היחידה מהלפטופ: מנהרת IAP דרך sentrix-bastion. פורטים מוסכמים: ‏5439 — עיון יומי ב‑dev; ‏5438/5440 — סקריפט ה‑guardrails ‏dev/prod.

Open a tunnel (leave the terminal running):

gcloud compute ssh sentrix-bastion --project=mis26-dev --zone=me-west1-a \
  --tunnel-through-iap -- -N -L 5439:10.17.0.3:5432 -o StrictHostKeyChecking=no

Get the read-only credential (username sentrix_readonly + password from):

gcloud secrets versions access latest --secret=SENTRIX_READONLY_DATABASE_URL --project=mis26-dev

Connect — Windsurf Database Client / psql / Prisma Studio על 127.0.0.1:5439, ‏database ‏mis_core או security_core. המתכון המלא עם צילומי מסך של התוסף: ‏dev-environment ‏§10.

כללי הדאטה: צפייה — עם sentrix_readonly, בשתי הסביבות. תיקון נתוני דמו — ‏dev בלבד, במודע, עם ה‑credential של MIS_CORE_RUNTIME_DATABASE_URL (‏DML כן, ‏DDL יסורב). ‏prod — לעולם אין עריכה ידנית, גם לא של שורה אחת. ‏./scripts/db-studio.sh פותח מנהרה+Studio בפקודה אחת. המודל המלא של הדרגות, ה‑cutover והאכיפה: db/guardrails/README.md.

8. גיבויים ו‑DR — כשמשהו נשבר באמת

שלוש שכבות הגנה על הדאטה, מהטעות הקטנה ועד אובדן ספק ענן:

שכבה מה היא איפה חיה מגינה מפני
PITR ‏transaction logs — שחזור לכל דקה ב‑7 הימים האחרונים ‏Cloud SQL, שתי הסביבות נזק לוגי ("מחקתי את הטבלה ב‑10:31")
גיבוי יומי ‏snapshot אוטומטי ‏02:00 UTC, נשמרים 7 אחרונים ‏multi‑region ‏eu — שורד גם אובדן מלא של me-west1 אובדן instance או region
Offsite ‏dump מוצפן ‏age — שבועי ל‑bucket חוץ‑אזורי (פעיל) + רגל S3 (ממתינה לסוד) ‏GCS ‏europe-west1 / ‏AWS מחוץ ל‑Google אובדן region, ספק או חשבון GCP

איפה רואים אותם: ‏Cloud SQL ⇐ ‏sentrix-pg ⇐ ‏Backups (קישור בטבלת פרק 1).

שכבת ה‑offsite: ג'וב db-backup מצפין כל dump עם age ומריץ שתי רגליים: גיבוי שבועי חוץ‑אזורי ל‑GCS (‏bucket ‏*-dr-backups-euw ב‑europe-west1 — פעיל בשתי הסביבות), ורגל S3‏ (AWS) שנדלקת ברגע שהסוד AWS_BACKUP_CREDENTIALS מאוכלס. עד אז רגל ה‑S3 מדלגת ומדווחת בלוג — הפעלתה היא הוספת סוד, לא שינוי קוד. מפתח הפענוח שמור מחוץ לענן (אצל שניר); בלעדיו קובץ הגיבוי הוא רעש חסר ערך.

שחזור — שלושת התרחישים (לתאם עם שניר, מורן או מוטי לפני נגיעה בפרוד):

# 1. logical damage -> PITR clone to one minute BEFORE the mistake:
gcloud sql instances clone sentrix-pg sentrix-pg-restore \
  --point-in-time="2026-07-24T10:30:00Z" --project=mis26-prod
# validate on the clone, then repoint the *_DATABASE_URL secrets to it (new version)

# 2. instance/region loss -> restore a daily backup onto a fresh instance:
gcloud sql backups list --instance=sentrix-pg --project=mis26-prod

# 3. provider loss -> pull from S3, decrypt, load:
# aws s3 cp s3://<offsite-bucket>/<latest>.dump.age .
# age -d -i <offline-key> <latest>.dump.age > latest.dump && pg_restore ...

תרגול שחזור חודשי (חובה, לא רשות): פעם בחודש משחזרים גיבוי אמיתי ל‑instance זמני ב‑dev, בודקים שפיות (ספירת שורות בטבלאות מרכזיות מול המקור, חיבור שירות ו‑/health ירוק), מתעדים את התוצאה ומוחקים את ה‑instance. גיבוי שמעולם לא שוחזר הוא תקווה, לא גיבוי.

אובדן region ‏(me-west1): הגיבויים ב‑eu שורדים. משחזרים ל‑instance ב‑region אחר, ‏terraform apply עם override ל‑region, והטריגרים פורסים את השירותים מחדש — הכל קוד, אין קונפיגורציה ידנית לשחזר. אובדן ספק: הקוד ב‑GitHub, הדאטה ב‑S3 מוצפן, התשתית כ‑Terraform — מקימים מחדש אצל ספק אחר מהעותקים החיצוניים.

הפקודות המלאות, שמות ה‑buckets והתיעוד מוטמעים כאן בנספח ו׳ — פקודות מלאות, שמות buckets ותיעוד תרגולים. הפרק הזה והרנבוק מיושרים לאותו מודל שלוש־השכבות; בפרטי ביצוע — הרנבוק קובע.

9. סודות — Secret Manager בלבד

10. השער והרשת — הדלת היחידה פנימה

1. המכשיר משדרPOST ‎/_functions/post_ramzorאותה כתובת ואותו auth כמו ב-Wix 2. השער בודקCloud Armor מסנן, ‏Gateway מאמת 3. mis-core קולטמזהה לאיזה לקוח שייך (resolver),מנרמל, כותב שורה לטבלהומפרסם את האירוע הלאה 4. הטבלה (Cloud SQL)ScanEvent / WeighTicket /Detection / StationVisit...תמיד עם tenant_id 5. Pub/Sub — sentrix-ingest-events (+DLQ לאירועים שנכשלו) 6א. מנוי alerts-engineמריץ כללים (פוליגון-חריגה,אתר לא מקוון...) — כלל שנדלק= שורת Alert עם dedupe 6ב. מנוי lake-sink‏Pub/Sub כותב ישירות ל-BigQuery(בלי שום קוד ETL) — ההיסטוריהנשמרת לנצח, בזול 6ג. מנויים עתידייםכל שירות חדש (סוכני AI, מנועאוטומציות) פשוט נרשם ל-topic,בלי לגעת בנתיב הכתיבה 7. הדשבורד מציגהפעמון קורא GET ‎/alerts, הקוקפיטGET ‎/trucks — דרך השער, עם הטוקן 8. דוחות‏report-engine מרנדר PDF/Excelמ-ReportRow ומה-marts שבאגם 9. סוכני AIשואלים דרך mcp-wrapper — אותםendpoints, אותן הרשאות בדיוק תמונות: ‏Cloud Storage,בטבלה רק reference.וידאו: נשאר ב-NVR,נצפה דרך dvr לפי דרישה העיקרון: ‏Cloud SQL מחזיק את ההווה (חלון חם), ‏BigQuery את העבר. הפרטים המלאים על גדילה למיליוני שורות — נספח ז׳
איור: זרימת הדאטה מקצה לקצה — ממשאית בשטח עד הדשבורד

גלגול קונפיג של השער: קונפיגים הם immutable. שינוי = קונפיג חדש (v2, v3...) ועדכון ה‑gateway אליו — אין עריכה של קיים:

gcloud api-gateway api-configs create v<N> --api=sentrix-api \
  --openapi-spec=gateway-openapi-dev.yaml --project=mis26-dev \
  --backend-auth-service-account=sa-api-gateway@mis26-dev.iam.gserviceaccount.com
gcloud api-gateway gateways update sentrix-gw --api=sentrix-api \
  --api-config=v<N> --location=<region> --project=mis26-dev

המוקש המתועד: ‏x-google-backend.address ו‑jwt_audience חייבים להיות בדיוק כתובת ה‑Cloud Run של mis-core — לוודא אחרי כל פריסת mis-core. הכל ב‑infra/api-gateway/README.md.

המוקש שצריך להכיר: נתיב חדש נוסף ל‑YAML, הטסטים עוברים, ה‑CI עובר, הפריסה עוברת — ואם הקונפיג לא גולגל, השער מחזיר 404 לפני שהבקשה מגיעה לשירות והמסך אומר "נכשל". ‏route-contract-drift.test.ts משווה את הקובץ לטבלת הראוטים ולא יכול לדעת אם הקונפיג גולגל — אין בדיקה אוטומטית על זה. אחרי כל גלגול תריץ את אימות ה‑HTTP: נתיב חדש חייב לענות 401 או 405, לעולם לא 404. פירוט מלא ב‑פרק 27.3.

11. מולטי‑טננסי: לקוח חדש = שורה ב‑DB

12. פרונטאנד ומערכת העיצוב

13. טסטים — מה יש, איך מריצים, ומה כל סוג שומר

‏Vitest בכל החבילות, ובנוסף שני מקרי קצה שרצים אחרת ומתועדים למטה. אין טסט שדורש מסד: מי שנוגע ב‑Prisma עושה זאת מול mock, כדי ששורת פקודה אחת תריץ הכל בכל מחשב.

ההרצות

pnpm -r test                                     # הכל
pnpm --filter @sentrix/mis-core test              # חבילה אחת
pnpm --filter @sentrix/mis-core test -- fleet     # קובץ/שם לפי חיפוש
node --test scripts/__tests__/build-partner-spec.test.mjs   # ראו "מחוץ ל-vitest"

מה יש בפועל

5,798 טסטים ירוקים ב‑468 קובצי טסט, ב‑20 חבילות, ועוד 13 טסטים שרצים ב‑node --test. חמישה טסטים מדולגים, וכולם אותו מקרה אחד: מסלול ה‑PDF, שדורש Chromium אמיתי (למטה).

חבילה קבצים טסטים
services/mis-core 177 2,156
apps/client-app 129 1,421
services/security-core 24 373
apps/admin-console 28 315
packages/api-client 1 208
packages/catalog 10 206
packages/ui-kit 18 199
services/alerts-engine 13 140
services/report-engine 12 130 (‏5 מדולגים)
packages/shared-db 4 120
services/notifications 12 95
ingestion/connectors 10 93
apps/exchange 8 90
services/ai-agents 10 64
services/mcp-wrapper 5 58
packages/shared-i18n 1 49
services/db-backup 2 36
packages/shared-auth 1 24
apps/partner-portal 3 20
packages/notifications 1 6

שבעה סוגי טסטים, ומה כל אחד באמת מונע

‏1. טסטי יחידה ולוגיקה — הרוב המכריע. חוק אחד: באג שתוקן מקבל טסט שמוכיח שהוא לא יחזור, באותו PR.

‏2. טסטי ראוט — קוראים ל‑endpoint עם Prisma מוקאפ ובודקים גם את מה שהוא לא מחזיר: שורה של טננט אחר, שדה סוד, מסך בלי entitlement.

‏3. טסטי חוזה (drift) — הסוג שהחליף ביקורות ידניות. שלושה כאלה:

טסט מה הוא משווה מה הוא תופס
packages/api-client/…/contract-drift.test.ts הטיפוסים הכתובים ביד מול openapi.yaml סכימה שהשירות שינה והחוזה לא. ‏114 סכימות מושוות, כל חריגה דורשת נימוק בקוד, וחריגה שהתיישנה נכשלת עד שמוחקים אותה
services/mis-core/…/route-contract-drift.test.ts טבלת הראוטים מול ה‑YAML של השער ראוט שנרשם בקוד ואין לו נתיב בשער (או ההפך)
services/security-core/…/route-contract-drift.test.ts אותו דבר, לשירות הביטחון אותו דבר

‏4. טסטי מראה (parity) — כל מקום שבו אותה עובדה יושבת פעמיים מקבל טסט שמכריח את שני העותקים להסכים. זה מה שקיים היום:

טסט שני העותקים
admin-tenants/…/catalog-parity packages/catalog ⟷ מרשם המודולים ב‑mis-core
packages/catalog/…/seed-parity packages/catalog ⟷ סקריפטי הזריעה
jobs/…/demo-data-parity נתוני הדמו ב‑shared-db ⟷ המראה שלהם ב‑mis-core
alerts-engine/engine/…/applicability טבלת ההתאמה של ההתראות ⟷ הקטלוג
usage/…/rates.tfvars-parity טבלת התעריפים בקוד ⟷ ה‑tfvars
weighing/…/waste-catalog-parity קטלוג סוגי הפסולת ⟷ הקנוניקליזציה
containers/…/category-parity קטגוריות המכולות ⟷ הקטלוג
partner-api/…/manifest-parity מניפסט השותפים ⟷ ה‑spec הנגזר
security-core/…/internal-claims-parity ה‑claims שה‑BFF חותם ⟷ מה שהשירות מאמת
admin-console/…/feature-flag-mirror דגלי הפיצ'רים בקונסולה ⟷ המקור
client-app/components/shell-parity מעטפת האפליקציה, שלא תתפצל שוב בין המוצרים

‏5. טסטי כיסוי־בכוח — לא בודקים התנהגות אלא שאין חור:

‏6. טסטי רכיב (React) — ‏@testing-library ב‑client-app, ‏admin-console, ‏ui-kit, ‏exchange ו‑partner-portal. מרנדרים מסך ובודקים מה נראה: תווית בשפה הנכונה, טבלה שממוינת כמו שהוכרז, מסך שלא מציג פאנל של אוצר מילים שלא שייך לסגמנט הזה.

‏7. טסטי כלל־ההתראה — לכל משפחת התראה יש קובץ משלה ‏(alerts-engine/src/rules/__tests__/): דלק, סרק, עבודה פרטית, פקיעת רישיון, מטען. כל אחד מוכיח שהכלל נדלק על הקלט שאמור להדליק אותו, ולא נדלק על מה שדומה לו.

שני דברים שרצים מחוץ ל‑vitest

node --test על ‏spec השותפים. ‏13 טסטים ב‑scripts/__tests__/build-partner-spec.test.mjs. הם מריצים את הבנייה של openapi.partner.yaml מהחוזה המלא ובודקים: שהקובץ שבריפו זהה לרינדור טרי, שאין משטח פנימי שדולף לשותפים, שלכל operation יש קבוצה, scope ותיאור, ושאין מפתחות נתיב כפולים בחוזה. הכפילות היא הטסט שהכי שווה להכיר: ‏YAML שומר את המפתח האחרון בשקט, ולכן נתיב שהוגדר פעמיים יכול לאבד הגדרה שלמה ועדיין להיפרסר בהצלחה.

node scripts/build-partner-spec.mjs                          # רינדור מחדש
node --test scripts/__tests__/build-partner-spec.test.mjs     # הטסטים

מסלול ה‑PDF.services/report-engine/src/__tests__/pdf.integration.test.ts מרנדר PDF אמיתי, ולכן הוא צריך Chromium אמיתי. בלי דפדפן במחשב הוא מדלג (חמשת המדולגים בטבלה למעלה) — אבל ב‑CI הוא נכשל בכוונה אם לא נמצא דפדפן, כי "דילגנו" בצינור אוטומטי נקרא כמו "עבר". ‏CI מתקין Chromium בשלב נפרד. מקומית:

PDF_CHROMIUM_PATH=/path/to/chrome pnpm --filter @sentrix/report-engine test

מה חייב להיות ירוק ב‑CI, ומה נבדק לפני הטסטים

בכל PR רצים שבעה שומרי סף לפני שמישהו מתקין חבילה — הם זולים, ולכן הם ראשונים:

שומר נכשל אם
אין סודות בקומיט ‏diff מכיל מפתח, טוקן, סיסמת RTSP או connection string
חוקיות הריפו מבנה שהוכרז ב‑AGENTS.md נשבר
אין נקודות במפתחות i18n מפתח הודעה מכיל . (שובר את הקינון)
‏spec השער בר‑פריסה ה‑YAML של השער לא נטען
מראת ה‑Prisma מסונכרנת services/mis-core/prisma/schema.prisma נבדל מהקנוני
חוזה השותפים מסונכרן ומתואר openapi.partner.yaml לא זהה לרינדור טרי
שינוי סכימה נושא migration schema.prisma השתנה בלי artifact של migration

אחריהם: התקנה עם lockfile קפוא, prisma generate (מסודר, כדי ששני תהליכים לא ידרכו זה על ה‑client של זה), typecheck ו‑test — שניהם רק על החבילות שהשתנו ועל מי שתלוי בהן. במקביל רצה workflow אבטחה נפרדת: ‏Semgrep (SAST), ‏Trivy (תלויות + IaC) ו‑gitleaks (סודות), עם סריקה שבועית מלאה בנוסף ל‑PR, וראיות שנשמרות 400 יום.

קומיט של תיעוד בלבד מדלג על התקנה/typecheck/test במכוון — עץ הקוד זהה להורה, שכבר עבר. שומרי הסף רצים בכל מקרה.

"Definition of done" — הרשימה המחייבת

מ‑AGENTS.md: מתקמפל, טסטים ירוקים, ‏i18n בארבע שפות, ‏tenant scoping נאכף בשירות, אפס סודות ב‑diff, התיעוד עודכן, ונפרס נקי ל‑dev. ‏endpoint חדש = חוזה קודם, ואז טסט יחידה + טסט חוזה, באותו PR.

פרק צמוד — טסטים ו‑CI מתוך Windsurf

אפשר לחיות רק מהטרמינל (pnpm -r test, ‏gh pr checks --watch), אבל נוח יותר לראות טסטים וריצות CI בתוך העורך. ‏Windsurf מושך תוספים מ‑Open VSX (לא מחנות מיקרוסופט); שני התוספים שלמטה קיימים שם ואומתו. הריפו כולל .vscode/extensions.json עם ההמלצות — ‏Windsurf יציע להתקין אותן בפתיחה הראשונה של התיקייה.

Run the Vitest suites from the editor (extension id: vitest.explorer):

  1. Open Extensions (Cmd+Shift+X / Ctrl+Shift+X) → search Vitest (publisher: vitest) → Install.
  2. Click the Testing icon (flask) in the sidebar — the extension picks up every vitest.config.ts in the monorepo automatically.
  3. Press ▶ next to a package / file / single test to run it; failures appear inline on the exact line.
  4. Right-click a test → Debug Test for breakpoints; the "watch" (eye) icon re-runs tests on every save.

זה בדיוק pnpm -r test, רק עם עץ טסטים, ריצה פר‑טסט וכישלון שמסומן על השורה. הרצת חבילה בודדת מהטרמינל נשארת כשהייתה (פסקה ראשונה בפרק).

See CI runs in-editor (extension id: GitHub.vscode-github-actions):

  1. Extensions → search GitHub Actions (publisher: GitHub) → Install.
  2. Sign in to GitHub when the extension prompts (once).
  3. The Actions icon in the sidebar lists workflow runs for the current branch — including the PR checks (‏ci, ‏gitleaks, ‏semgrep, ‏trivy) — with live logs per step, straight from the editor.

מי שמעדיף בלי תוסף: gh pr checks --watch בטרמינל (פרק 5) נותן את אותו מידע. שאר התוספים המומלצים (‏Prisma, ‏Database Client, ‏Terraform): ‏dev-environment ‏§7.

14. ג'ובים מתוזמנים (Cloud Scheduler)

הג'ובים התפעוליים (ירושה מה‑cron של Wix) חיים כ‑endpoints תחת /jobs/* ב‑mis-core (services/mis-core/src/modules/jobs/). ‏Cloud Scheduler קורא להם בשעות קבועות (‏UTC — בכוונה, כמו ב‑Wix) עם שתי שכבות אימות: ‏OIDC של SA ייעודי + ‏header ‏x-jobs-secret מול הסוד JOBS_SHARED_SECRET. הג'ובים פעילים בשתי הסביבות: דוח יומי, אתר לא‑מקוון, משאית בפנים >2 שעות, משקל חורג, ‏Ituran pull, ‏usage-rollup, תחזוקת פרטישנים (חודשי), הפקת דפי חשבון (‏statement-generate, חודשי), משיכת אנומליות מהאגם (‏lake-anomalies-pull — הופכת שורת mart להתראת ‏FUEL_THEFT בפעמון) וגיבוי לילי. ל‑alerts-engine יש שלושה ג'ובים משלו ב‑dev (‏license-expiry, ‏cargo-eod, ‏fuel-loss) — מוגדרים ב‑infra/terraform/alerts-engine.tf. ג'וב חדש = ‏endpoint תחת /jobs/* + טסטים + הגדרה ב‑Terraform (‏infra/terraform/scheduler.tf).

15. ניטור, לוגים ועלויות

16. מה אסור — הרשימה השחורה

  1. ‏push ישיר ל‑main (חסום טכנית) או עבודה ישירות על dev.
  2. שינוי ידני בפרוד — קונסולה, מסד, תשתית. הכל דרך git ⇐ ‏PR ⇐ ‏CI.
  3. ‏DDL ידני בכל סביבה — המסד יסרב (פרק 7). גם לא "רק אינדקס קטן".
  4. סוד מחוץ ל‑Secret Manager: בקוד, ב‑env מקומט, בצ'אט, בלוג.
  5. מחיקת דאטה או תשתית בלי אישור מפורש. ‏drop/‏delete המוני = אישור + גיבוי + PR.
  6. לגעת ב‑mis26-platform (ה‑Cloud SQL של ramzor — מערכת חיה, מחוץ לתחום) או באתר השיווקי (GoDaddy→Vercel).
  7. יצירת משאב ענן בתשלום בלי הערכת עלות ואישור (AGENTS כלל 9).
  8. חשבון פרטי/משותף, או כלי צד‑שלישי לא מאושר עם גישה לקוד.
  9. קוד בתיקייה מסונכרנת ענן (Drive/OneDrive) — הורס את הריפו.
  10. ‏endpoint גנרי שמקבל שם טבלה מהלקוח, או שאילתה בלי tenantId.
  11. להשתיק סריקת אבטחה או בדיקת CI בלי טריאז' מתועד (SECURITY-BASELINE.md).

17. מפת המסמכים החיים — מה קוראים בשביל מה

הפרק הזה הוא מפה, לא רשימת קריאה. מסמכי העומק מצורפים במלואם כנספחים א׳–י״ב בסוף המסמך, ולכן אין כאן שום דבר שחייבים לצאת בשבילו. מה שהרשימה למטה נותנת היא איפה כל עובדה חיה בריפו — כדי שתדעו לאן ללכת כשצריך לתקן אותה, ולא כדי להשלים ידע חסר.

בשורש: AGENTS.md — כללי הזהב המחייבים (לקרוא ראשון) · README.md — מפת הריפו.

מוסכמות עבודה (docs/conventions/): dev-environment.md — הקמת מחשב, הרצה מקומית, תוספי Windsurf, תקלות נפוצות · git-flow.md — ענפים ו‑PR · environments.md — שתי הסביבות · rollback-and-db.md — שינויי DB בטוחים (expand‑contract) ו‑rollback · how-to-add-endpoint.md · how-to-add-table.md · how-to-secrets.md · how-to-i18n.md · how-to-permissions.md · how-to-frontend.md · gcp-console-guide.md — איפה כל דבר בקונסולה.

ארכיטקטורה והחלטות: ARCHITECTURE-COMPLETE.md · PROJECT-MAP.md · DECISIONS.md (D1–D30) · tenancy-and-domains.md — המודל של טננט/מוצר/דומיין · ONE-CLIENT-APP.mdאפליקציית הלקוחות האחת: למה, איך נבחר סט המסכים, סדר ה‑apply, ומה מחכה לאישור · RUNTIME-TENANT-RESOLUTION.md — זיהוי הלקוח מה‑DB בזמן ריצה · VISION-END-STATE.md · docs/architecture/target-architecture.html.

תפעול ותשתית: db/guardrails/README.md — דרגות הגישה למסד וחוק ה‑DDL · infra/README.md + infra/terraform/README.md — עבודה עם Terraform (state ב‑GCS, ‏workspaces) · infra/api-gateway/README.md — גלגול קונפיג השער · infra/cloudscheduler.tf-snippet.md — הג'ובים · MIGRATION-RUNBOOK.md — קאט‑אובר מ‑Wix‏ (dual-write, קריטריוני יציאה) · SCALE-PLAN.md — גדילה · DATA-LAKE.md — האגם.

אבטחה: SECURITY.md — מדיניות ו‑SLA · SECURITY-BASELINE.md — טריאז' הסריקות · ISO-secure-dev-tooling-he.md — מסמך הסוקר (ISO 27001/17/18/701).

עלות ומוצר: COSTS.md · COST-PER-TENANT.md · AI-PRICING.md · CAPABILITY-MATRIX.md · WORK-PLAN.md — התוכנית שלב‑שלב · TOUR.md.

מורשת (רפרנס בלבד): legacy-mainmis.md, legacy-mgroupwix.md, legacy-missecurity.md, legacy-mis-oldsite.md, ‏SCHEMA-GAPS.md, ותיקיית archive/ (קוד ה‑Wix הקפוא — קריאה בלבד, לא מיובא).

למידה למי שחדש בזה: LEARN-branches-and-environments.md · LEARN-config-driven.md.

18. מושגי יסוד — למי שמגיע בלי רקע

העמקה בקצב שלכם: LEARN-branches-and-environments.md, ‏ARCHITECTURE-COMPLETE.md ‏§2 (מילון רכיבי הענן עם אנלוגיה לכל אחד).

19. מעבר מ‑Wix ל‑Sentrix — עם דוגמאות קוד

הפרק הזה למי שמגיע משלושת אתרי ה‑Wix שלנו. המטרה: להחליף את המודל המנטלי, לא רק את התחביר. הניתוחים המלאים של הקוד הישן: docs/legacy-*.md.

מה קורה למסד ה‑Wix הישן? (הנקודה שהכי מבלבלת)

באתרי ה‑Wix הקוד לא שמר נתונים בתוך Wix עצמו — הוא דיבר עם מסד חיצוני משותף, ‏externalMISDB (בפועל ramzor ו‑priority), דרך "external database adapter" שהיעד שלו מוגדר בלוח הבקרה של Wix ולא ברפו. שם יושבות שנים של נתוני אמת.

המסד הזה לא נעלם בקאט‑אובר — וזו נקודת המפתח. הוא נשאר מקור‑האמת לנתונים ההיסטוריים עד שהמעבר מסתיים. מה שקורה בפועל:

  1. שלב dual‑write: הציוד בשטח ממשיך לשדר לאותן כתובות. אנחנו כותבים במקביל גם לסכמה החדשה (PostgreSQL/Prisma) וגם — דרך גשר — לישן, ומשווים.
  2. גשר/משיכה (bridge/puller): ג'וב שקורא מ‑ramzor/priority ומעתיק את הרשומות לסכמה שלנו, מנורמל ומתויג ב‑tenantId. ההיסטוריה זורמת פנימה בלי שאף מכשיר מרגיש.
  3. קאט‑אובר לפי קריטריוני‑יציאה: רק כשספירות מתאזנות והבדיקות עוברות מעבירים את מקור‑האמת אלינו. עד אז המסד הישן חי וקריא. הפירוט — נספח ה׳ (MIGRATION-RUNBOOK).

כלומר "עברנו ל‑Sentrix" לא אומר "מחקנו את הישן ביום אחד", אלא "בנינו סכמה חדשה, זרמנו אליה את הישן דרך גשר, אימתנו — ואז החלפנו את מקור‑האמת".

לפני (Wix) — קריאה ישירה מהמסד החיצוני המשותף:

// backend/http-functions.js (Wix/Velo) — טוקן פר קולקציה, שם קולקציה מהלקוח
import { getRamzorData } from 'backend/DataQueryFromCollections.jsw';
export function get_getRamzorData(request) {
  const token = request.headers['auth'];            // secret === '<collection>Token'
  return getRamzorData(request.query.dataCollection, request.query.filter);
}

אחרי (Sentrix) — גשר ייעודי שמעתיק לסכמה שלנו, מתויג לקוח:

// ingestion/connectors/ramzor-puller.ts  (רץ בזמן dual-write בלבד)
const rows = await legacyRamzor.fetchSince(cursor);        // קורא מהמסד הישן
for (const r of rows) {
  await prisma.weighing.upsert({
    where:  { legacyId: r.id1 },
    create: { legacyId: r.id1, tenantId, net: r.fnet, at: parseWix(r.date, r.time) },
    update: { net: r.fnet },
  });                                                       // מעתיק לסכמה החדשה
}

ההבדל המהותי: אין יותר "תן לי קולקציה כלשהי לפי שם ממחרוזת". יש מודל מוגדר, מתויג ב‑tenantId, שהגשר ממלא — והמסד הישן חי עד הקאט‑אובר.

דוגמה 1 — קריאת נתונים

לפני (Wix): שכבה גנרית אחת (DataQueryFromCollections.jsw, ‏5,377 שורות) שקיבלה שם קולקציה + filter מהלקוח:

import wixData from 'wix-data';
// כל צרכן שלח dataCollection + fieldKey + condition + keysValue
const res = await wixData.query('truckCollection')
  .eq('contractor', contractorName)
  .find();                       // בלי בידוד לקוח מובנה — הכל באותה קולקציה

אחרי (Sentrix): endpoint שנולד מחוזה (packages/contracts-openapi), ומאחוריו Prisma — תמיד עם where: { tenantId }:

// services/mis-core/src/modules/fleet/routes.ts
app.get('/trucks', async (req) => {
  const { tenantId } = req.auth;                 // מהטוקן, לא מהלקוח
  return prisma.truck.findMany({
    where: { tenantId, contractor: req.query.contractor },
  });
});

הכלל: אסור endpoint גנרי שמקבל שם טבלה מהלקוח, ואסורה שאילתה בלי tenantId (פרק 11). זה בדיוק החור של Wix — סגרנו אותו.

דוגמה 2 — קליטת נתון ממכשיר בשטח (‏http-function)

לפני (Wix):post_externalTruckDataCollection ב‑http-functions.js — insert גנרי, אימות בהשוואת מחרוזת טוקן, כתיבה למסד החיצוני בלי ולידציה:

export function post_externalTruckDataCollection(request) {
  const token = request.headers['auth'];
  if (token !== secretFor('truckDataCollection')) return forbidden();
  return request.body.json().then((rec) =>
    wixData.insert('externalMISDB/truckDataCollection', rec)); // בלי סכמה
}

אחרי (Sentrix): אותה כתובת /_functions/* בדיוק (המכשירים לא מרגישים), אבל עם סכמה, ולידציה, בידוד לקוח, וזרימה לאגם:

// services/mis-core/src/modules/ingest/truck-lift.ts
app.post('/_functions/externalTruckDataCollection', async (req) => {
  const rec = TruckLiftSchema.parse(req.body);        // Zod — קלט לא תקין נדחה
  const row = await prisma.truckLift.create({
    data: { ...rec, tenantId: req.tenant.id },        // מתויג לקוח
  });
  await lake.publish('truck.lift', row);              // Pub/Sub → BigQuery
  return { ok: true, id: row.id };
});

דוגמה 3 — תגובה אוטומטית לאירוע (hook)

לפני (Wix): ‏hook סינכרוני על הקולקציה שהריץ התראות רחוב בתוך ה‑insert:

// backend/data.js (Wix) — רץ בתוך הכתיבה, קשה לבדיקה, מפיל את ה-insert אם נכשל
export function truckDataCollection_afterInsert(item, context) {
  return sendStreetAlerts(item.street, item.region);   // חוסם, לא ניתן לניטור
}

אחרי (Sentrix): ה‑ingest רק כותב ומפרסם אירוע; מנוע ההתראות צורך אותו בנפרד — מנותק, ניתן לבדיקה ולריטריי:

// alerts-engine צורך את האירוע מ-Pub/Sub — לא חלק מה-insert
onMessage('truck.lift', async (evt) => {
  await alerts.evaluate(evt);        // נכשל? ריטריי, בלי לפגוע בקליטה
});

דוגמה 4 (בונוס) — משימה מתוזמנת

לפני (Wix):jobs.config + Jobs.js (למשל דוח יומי ב‑UTC). אחרי: אותו לו"ז ב‑Cloud Scheduler שקורא ל‑/jobs/* ב‑mis-core (פרק 14) — עם OIDC + סוד משותף, וכל ג'וב הוא endpoint שנבדק ב‑CI.

טבלת המרה מהירה

בוויקס ב‑Sentrix
קולקציה ב‑Content Manager טבלה ב‑PostgreSQL, מוגדרת ב‑schema.prisma
הוספת שדה = קליק בעורך על אתר חי עריכת schema + migration ב‑PR (פרק 7); המסד מסרב לקיצור דרך
wix-data.query() Prisma client, תמיד עם where: { tenantId }
externalMISDB (ramzor/priority) נשאר מקור‑אמת היסטורי עד קאט‑אובר; גשר מעתיק לסכמה שלנו
backend/http-functions.js Fastify ב‑services/mis-core; אותם /_functions/*
data.js ‏afterInsert hook פרסום ל‑Pub/Sub → מנוע ההתראות (מנותק)
jobs.config + Jobs.js (cron) Cloud Scheduler → /jobs/* (פרק 14)
‏Secrets Manager של Wix GCP Secret Manager; השירות מקבל סוד כ‑env בפריסה
עורך גרפי + $w קומפוננטות React מ‑ui-kit (פרק 12), hot-reload
‏Publish דוחף לאתר החי PR → בדיקות → dev → main → prod (פרק 5)
מייל פיקטיבי פר לקוח הרשמה במייל אמיתי + אימות + אישור אדמין (פרק 20)

ההרגל האחד שהכי חשוב לשנות

בוויקס "לתקן משהו" היה לפתוח את האתר החי ולשנות בו. כאן זה שלוש פקודות ו‑PR — בכוונה. העלות: דקות. התמורה: אי אפשר להפיל פרודקשן בקליק, כל שינוי הפיך, ותמיד יודעים מה רץ איפה. איפה שזה מרגיש "מסובך מוויקס" — שם בדיוק Wix נתן לנו לירות לעצמנו ברגל.

20. הקמת לקוח חדש ממסך האדמין

קליטת לקוח חדש היא שורה ב‑DB. הכל קורה באשף בקונסולת האדמין (apps/admin-console, זמינה ל‑staff בלבד, והאכיפה בצד השרת — ראו למטה): טננט, רשויות‑משנה, מודולים, והזמנת המשתמש הראשון.

אין שלב טרהפורם בפתיחת לקוח, ואין "פתיחת דלת" ידנית: apps/client-app הוא ה‑default_service של ה‑url map, ולכן כל label מגיע לאפליקציה בלי כלל host, והאפליקציה בוחרת את סט המסכים לפי הסגמנט של הטננט. שמונת צעדי ההכרעה, והמוקשים שבהם: פרק צמוד ל‑2.

ההוראה המבצעית המלאה היא באנגלית וב‑GUI‑first: docs/HAND-IN-HAND-EN.md §10. הפרק הזה הוא ההסבר; שם נמצאים הצעדים להעתקה.

הדיאגרמה הבאה מראה את מסלול הזהות והאישור שכל משתמש לקוח עובר:

מסלול ההרשמה והאישור 1. נרשםמייל אמיתי + סיסמה,או Google / Apple 2. מאמת מייללינק אימות נשלחלמייל שנרשם 3. PENDINGרואה מסך "ממתין לאישור".ה-API מחזיר לו 403 על הכל 4. אנחנו מאשריםתור אישורים באדמין,כולל בחירת תפקיד (role) 5. ACTIVEרואה רק את הלקוח שלו,לפי ההרשאות שלו או: דחייה / חסימה (DISABLED) מה קורה בכל בקשה אחרי הכניסה (שלוש שכבות הגנה) הדפדפןמחזיק טוקן של IdentityPlatform בעוגיית httpOnly,מתרענן כל שעה שכבה 1 — השערבודק חתימה ותוקף שלהטוקן. בלי טוקן תקף — 401,הבקשה לא מגיעה לשירות שכבה 2 — השירותבודק שהמשתמש ACTIVE(שער האישורים) ומה מותרלפי ה-role שלו שכבה 3 — הדאטהכל שאילתה מסוננת לפיtenant_id. לקוח אחד לא יכוללראות שורה של אחר מי זה "לקוח"? Tenant = הגבול שמתחברים אליו (subdomain) · Account = תת-ארגון בתוכו (כל רשות תחת mgroup) Module = מה שהלקוח קנה (TenantModule = המתג + המחיר) · Brand = המותג (mis / mgroup, white-label) קיימים בנוסף: שכחתי-סיסמה, החלפת סיסמה תקופתית וניתוק אי-פעילות (ISO), ומסך מודולים פר לקוח
איור: זהות, הרשמה ואישור משתמשים

חמשת הצעדים באשף

האשף יושב במסך Tenants, מתחת לרשימת הלקוחות.

  1. לקוח. שם תצוגה, slug (תת‑הדומיין, נבדק חי מול הרשימה הקיימת ומול הסלאגים השמורים), סגמנט, מותג. הסגמנט הוא השדה החשוב בצעד הזה — הוא זה שקובע אילו מסכים הלקוח יקבל, ולא אנחנו בטרהפורם. חוקי הסלאג בהמשך הפרק.
  2. רשויות‑משנה.Account לכל רשות תחת אשכול (M Group). לקוח שנמכר ישירות מדלג על הצעד.
  3. מודולים. צ'ק‑בוקס לכל מודול שנקנה. אין מחירים במסך הזה — הם במסך המודולים.
  4. המשתמש הראשון. כתובת מייל ותפקיד ⇐ נוצרת TenantInvite.
  5. סיכום. מה נוצר, מה הכתובת, ושלוש עובדות שקובעות אם הלקוח חי.

הכתיבה קורית כולה בלחיצה אחת בסוף צעד 4:

POST /admin/tenants                      { slug, displayName, segment, brandId, moduleKeys[] }
POST /admin/tenants/{slug}/accounts      פר רשות‑משנה
POST /admin/tenants/{slug}/invites       { email, role }

‏slug תפוס מחזיר 409; סלאג שמור מחזיר 409 גם הוא; מפתח מודול לא מוכר מחזיר 400 לפני שנפתחת הטרנזקציה, כך שלא נשאר לקוח חצי‑בנוי.

שלוש העובדות במסך הסיכום

אין ביניהן יחס של גרירה, ולכן האשף מדווח כל אחת בנפרד. שלושתן מטופלות מהקונסולה.

העובדה מה נבדק מה עושים אם היא אדומה
הסלאג פנוי שהתווית לא שמורה לשירות אחר (‏admin, ‏mcp, ‏partners, ‏exchange-*) בוחרים סלאג אחר. ‏mis-core בכלל לא נותן ליצור סלאג כזה, אז זה מצב שקורה רק לשורה היסטורית
הכתובת נפתרת GET /tenants/by-host/{label} מחזיר טננט הטננט לא נוצר או לא פעיל — משלימים את היצירה או מפעילים
יש סגמנט לטננט יש segmentKey שהקטלוג מכיר קובעים סגמנט במסך הטננט. עד אז הכתובת עונה 503 ולא מגישה מוצר מנוחש

למה לא בודקים בפתיחת הכתובת בדפדפן. גם לקוח בלי סגמנט מגיש 200 על /signin עם מסך כניסה ממותג — פתיחת הכתובת לא מבדילה בין טננט מוכן לטננט חצי‑מוגדר. האשף מציג את קוד ה‑HTTP ליד שלוש העובדות דווקא כדי שיהיה אפשר לראות שהם סותרים.

חוקי הסלאג — מה מותר, מה שמור, ולמה

הסלאג הוא תווית DNS אחת מתחת ל‑app.mis-26.com, וזה נובע מהתעודה (‏*.app.mis-26.com מכסה בדיוק רמה אחת) ולא מהעדפה. לכן:

הקוד היחיד שמחליט: services/mis-core/src/modules/admin-tenants/slug.ts.

התוויות השמורות, ולמה כל אחת שמורה:

התווית למה
admin, ‏mcp, ‏partners, ‏exchange-* דלתות מוצר — כלל host מפנה אותן לשירות אחר. לקוח על תווית כזאת לא יגיע לאפליקציה בכלל, אלא יקבל את קונסולת האדמין או 401 מ‑mcp
classic שמורה בלי דלת. אין כלל host על התווית, אבל היא נשארת שמורה: לשחרר תווית זו שורה אחת, לקחת אותה חזרה מלקוח חי זו מיגרציה
driver מארח תפקיד — ‏driver.<domain> מגיש את מסכי הנהג (מוצר driver בתוך ‏client-app, לפי ‏DRIVER_HOST_LABELS) לנהג של כל טננט. לקוח עם הסלאג הזה יקבל מסכי נהג במקום את שלו
security, ‏contractors, ‏demo סלאגים של טננטים קיימים (ו‑demo הוא כינוי של טננט הקבלנים). לקוח שני על אחד מהם היה מתנגש עם דאטה אמיתי
mis, ‏p2p-e2e טננטים ללא דלת דפדפן: קליטה מדור קודם וטננט טסטים
api, ‏app, ‏www, ‏gateway, ‏health, ‏status, ‏auth, ‏login, ‏static, ‏assets, ‏cdn, ‏internal תוויות תשתית. אף אחת אינה דלת היום, וכל אחת מהן היא בדיוק סוג המארח שמישהו יפנה אליו כלי בעתיד

הרשימה הזאת קריטית: אין כלל host פר לקוח ואין terraform apply שיתפוס סלאג מתנגש, ולכן הבדיקה ב‑slug.ts היא ההגנה היחידה מפני תווית שדורסת שירות קיים.

הוספת משתמש ללקוח קיים

זה לא אותו תהליך כמו פתיחת לקוח, ומבלבלים ביניהם. שני מסלולים, ושניהם מהקונסולה:

  1. הזמנה יזומה — ‏POST /admin/tenants/{slug}/invites ממסך הלקוח, עם מייל ותפקיד. נוצר טוקן חד‑פעמי, נשמר רק הגיבוב שלו, והקישור מוצג פעם אחת. זה המסלול שמשתמשים בו למשתמש הראשון של לקוח חדש, ולכל מי שרוצים להכניס מיד.
  2. הרשמה עצמית + אישור — המשתמש נרשם בעצמו בכתובת של הלקוח (מייל אמיתי, ‏Google או Apple), מאמת מייל, ונכנס למצב PENDING: הוא רואה מסך המתנה בלבד וה‑API מחזיר לו 403 על כל בקשת נתונים. אנחנו מאשרים אותו בתור האישורים באדמין ובוחרים לו role, והוא הופך ל‑ACTIVE. אפשר גם לדחות או לחסום (DISABLED).

בשני המסלולים ההרשאות נאכפות בשלוש שכבות ולא ב‑UI: השער בודק חתימת טוקן, השירות בודק ‏ACTIVE + role, והשאילתה מסוננת ב‑tenantId. משתמש שהוזמן לטננט אחד לא רואה טננט אחר גם אם הוא יודע את הכתובת שלו.

מודולים ניתנים לאישור גם פר משתמש: מסך "גישה" באדמין מציג כל מודול של הטננט עם ‏default/read/write/manage/deny. מודול חדש שמזוכה לטננט מופיע שם אוטומטית.

הזמנה — מה קורה למי שהוזמן

ההזמנה היא הרשאה למייל אחד. ‏POST /admin/tenants/{slug}/invites יוצר טוקן חד‑פעמי (sx_inv_…), שומר רק את ה‑sha‑256 שלו, ומחזיר את הטקסט פעם אחת בתשובה — יחד עם inviteUrl, קישור הכניסה בכתובת של הלקוח, ועם delivery שאומר מה קרה למייל.

הקישור הוא המסלול העיקרי. האשף מציג אותו בסיכום עם כפתור העתקה, כדי שאפשר יהיה לשלוח אותו בוואטסאפ. הוא מוצג פעם אחת בלבד: בשורה נשמר רק גיבוב, ולכן לא מסך הרשימה, לא סקריפט תמיכה ולא דאמפ של מסד הנתונים יכולים לשחזר אותו. אין "שליחה חוזרת" — אם הקישור אבד, מבטלים את ההזמנה ומנפיקים חדשה.

המייל הוא הנוחות. האירוע tenant.invited נכתב לאפיק ההתראות ושירות ה‑notifications מרנדר אותו בשפת המוזמן (עברית כברירת מחדל, RTL) עם מי הזמין, שם הארגון, התפקיד, הקישור ותאריך התפוגה. הוא כפוף לאותו דגל alert_dispatch_channels כמו כל ערוץ יוצא אחר:

delivery.status מה כתוב באשף מה באמת נכון
QUEUED "ההזמנה נוצרה והמייל בדרך אל…" האירוע על האפיק. QUEUED ולא "נשלח" — השליחה קורית בשירות ההתראות אחרי התשובה
DISABLED "…שליחת מיילים כבויה בסביבה הזו" אין EMAIL ב‑ALERT_DISPATCH_CHANNELS, או שאין טופיק. שום דבר לא נשלח ולא יישלח
FAILED "…המייל לא יצא לדרך" המעטפה נדחתה לפני האפיק

הזמנה אינה התראת מוצר: היא לא מאחורי העדפת התראות והיא לא מסוננת ברשימת ההשתקה של הצוות — הזמנה לכתובת שלכם, לצורך בדיקה, באמת מגיעה.

המימוש. המוזמן נכנס בכתובת של הלקוח שלו (Google / Apple / מייל+סיסמה), ‏POST /users/self-register מוצא הזמנה ממתינה — לפי הטוקן שהגיע מהקישור, או לפי (טננט, המייל המאומת) — ובאותה טרנזקציה מסמן אותה ACCEPTED ויוצר את המשתמש ACTIVE עם התפקיד והרשות שנבחרו. ההזמנה היא האישור — בלי מסך המתנה ובלי תור אישורים. מי שכבר נרשם וישב PENDING מקודם באותה שורה, לא נוצרת שנייה.

מסלול הטוקן מחווט מקצה לקצה: /signin?invite=<token> שומר את הטוקן ב‑sessionStorage ומנקה אותו משורת הכתובת, ו‑/api/register שולח אותו כ‑ inviteToken. זה מה שמאפשר למי שפתח את הכתובת הלא נכונה לנחות בכל זאת אצל הלקוח הנכון, וזה שורד גם הפניה מלאה של ספק הזהות (ספארי חוסם פופאפ) וגם את העצירה ב‑/verify-email.

מצב מה מקבל המוזמן
הזמנה פגה הרשמה רגילה ⇐ ‏PENDING. עם טוקן מפורש: 403 INVITE_INVALID
בוטלה (DELETE …/invites/{id}) אותו דבר — הסטטוס REVOKED, לא נמצאת
כבר מומשה אותו דבר; החד‑פעמיות נאכפת ב‑UPDATE … WHERE status = PENDING
נכנס עם מייל אחר מזה שבהזמנה מסורב
טוקן לא קיים או של מישהו אחר 403 INVITE_INVALID — אותו קוד לכל כישלון, בכוונה

הדיאגרמה שלמעלה (הרשמה ⇐ אימות ⇐ PENDING ⇐ אישור) היא המסלול של מי שנרשם בלי הזמנה. מוזמן מדלג עליו.

מי בכלל מגיע לקונסולת האדמין

admin.app.mis-26.com נגישה מהאינטרנט, ולכן הגישה נחתכת בהרשאה, לא בכתובת. חשוב להבין למה עוגיית סשן לבדה לא מספיקה: ‏Identity Platform הוא פרויקט אחד בלי טננטים לכל סביבה, כלומר לכל לקוח שנכנס לדשבורד שלו יש טוקן שמייצר עוגייה תקפה גם כאן. לכן האכיפה היא לפי staff, בצד השרת:

מי מה מקבל
אנונימי הפניה ל‑/signin
מחובר, לא staff מסך סירוב אדום במקום הקונסולה, ו‑403 not_staff מכל /api/admin/*
מחובר, staff הקונסולה
‏mis-core לא זמין (או MIS_CORE_API_BASE חסר בפרוד) 503 והקונסולה נסגרת — נכשל סגור, בכוונה

ההחלטה לא נופלת בדפדפן ולא לפי דומיין המייל: הקונסולה שואלת את mis-core ‏GET /users/staff-tenants, שמחזיר 403 NOT_STAFF לפי הטוקן המאומת של הקורא. שתי נקודות אכיפה — ה‑layout של הקונסולה, וכל route handler בנפרד (src/lib/staff-guard.ts), כי layout לא מגן על route.

מיפוי מסך → קוד

פעולה במסך Route בקונסולה מה קורה ב‑mis-core
יצירת טננט (אשף, צעד 1+3) POST /api/admin/tenants שורת Tenant + כל ה‑TenantModule בטרנזקציה אחת
רשות‑משנה (צעד 2) POST /api/admin/tenants/{slug}/accounts שורת Account
הזמנת המשתמש הראשון (צעד 4) POST /api/admin/tenants/{slug}/invites TenantInvite + הטוקן פעם אחת בתשובה
ביטול הזמנה DELETE /api/admin/tenants/{slug}/invites/{id} REVOKED + revokedBy (השורה נשארת)
מצב הדלת (צעד 5) GET /api/admin/onboarding/door-state קריאה בלבד: המפה המיושמת + GET /tenants/by-host/{label} + probe
הדלקת/כיבוי מודול PUT /api/admin/tenant-modules upsert ל‑TenantModule (enabled/status/מחיר)
אישור משתמש שנרשם בלי הזמנה POST /api/admin/users/{id}/approve PENDING → ACTIVE + role + statusChangedBy
חסימת משתמש POST /api/admin/users/{id}/reject DISABLED

כל אחת מהפעולות האלה כותבת רשומת audit. בידוד: כל שורה נושאת tenantId; לקוח שדורש מסד פיזי נפרד מסומן Tenant.isolation = DEDICATED_DB (נספח ב׳).

21. נעילות — מה שרק שניר משחרר

מעבר לרשימת ה"אסור" ההתנהגותית (פרק 16), יש נעילות שהן אכיפה טכנית: לא בקשה יפה, אלא המערכת מסרבת. לכל אחת יש "שחרור חירום" (break‑glass) יחיד — רק שניר. אין דרך לעקוף בלי בקשה מפורשת שלו, וכל שחרור מתועד, תחום בזמן, ומוחזר אחריו.

הנעילה איך נאכפת טכנית שחרור (break‑glass)
‏DDL על המסד (שינוי מבנה) ‏event trigger בכל DB + grants/ownership — לתפקידים הרגילים אין בעלות ואין CREATE (פרק 7) שניר בלבד: מנהרת bastion כתפקיד הבעלים ו‑DROP EVENT TRIGGER זמני, מתועד
שינוי סכמה מה‑UI אותה נעילה — המסד מסרב ל‑ALTER/CREATE/DROP מכל כלי דרך קוד בלבד: schema.prisma + migration + PR
שינוי ישיר בפרוד (קונסולה/מסד/תשתית) ‏IAM least‑privilege — לבני אדם אין write על משאבי prod; הכול דרך Cloud Build שניר (org owner) מעניק הרשאה נקודתית וזמנית, ומסיר אחרי
‏push/merge ל‑main ‏ruleset: PR ירוק + אישור CODEOWNERS; ממזגים — שניר, מוטי או מורן אין bypass קבוע (bypass_actors ריק); בחירום org admin עורך את ה‑ruleset זמנית, מתועד ומחזיר
‏force‑push / מחיקת ענף חסום ב‑ruleset org admin בלבד
סוד מחוץ ל‑Secret Manager ‏gitleaks חוסם את הקומיט (כולל בהיסטוריה) אין שחרור — מעבירים את הסוד ל‑Secret Manager
יצירת משאב ענן בתשלום מדיניות + התראות תקציב (AGENTS כלל 9) אישור עלות מול שניר לפני

איך "שחרור חירום" עובד

  1. מבקשים משניר במפורש, עם סיבה. אין "אולי לא ישימו לב" — הנעילות אכיפות.
  2. שניר (בעל ה‑org ב‑GCP ו‑admin ב‑GitHub) מבצע את השחרור: הענקת IAM זמנית, עקיפת ruleset נקודתית, או הסרת ה‑event trigger למשך הפעולה.
  3. הפעולה מבוצעת, מתועדת (מי / מה / מתי / למה), והנעילה מוחזרת מיד — ל‑DDL זה ./db/guardrails/apply.sh <env> שמחזיר את ה‑trigger וה‑grants.
  4. בלי בקשה מפורשת של שניר — אין מעקף. זו לא בירוקרטיה; זו רשת הביטחון שמונעת שאדם אחד (או סוכן AI) יפיל פרודקשן או ימחק דאטה בטעות בלתי הפיכה.

מי שנתקל ב‑ERROR: DDL is locked on this database — זה עובד כמתוכנן, לא תקלה. המסלול הנכון תמיד: קוד → PR → CI (פרק 7).

22. עדכוני המסמך, ‏HTML ו‑PDF

המסמך הזה הוא כעת מקור‑האמת היחיד לקליטת מפתחים — הפרקים והנספחים באותו קובץ, ‏docs/onboarding-he.md. כדי שיישאר עדכני:

איך תורמים שינוי (ב‑PR)

HTML — נבנה עם כל שינוי; PDF — ידני בלבד

גרסת ה‑HTML הכהה (RTL, קובץ עצמאי אחד שנפתח בכל מקום, גם בלי אינטרנט) נבנית מקומית:

./scripts/build-onboarding-html.sh    # -> docs/onboarding-he.html; מאמת את עצמו

חוויית הניווט. לצד התוכן, גרסת ה‑HTML נותנת:

רכיב מה הוא עושה
תוכן עניינים צדי נבנה מהמסמך עצמו בזמן ריצה (כל h2 ו‑h3), ולכן פרק חדש ב‑Markdown מופיע בניווט בלי לגעת בבנייה
תיבת סינון סינון חי לפי מילה. קיצור: מקש /
פרקים מתקפלים לחיצה על כותרת פרק מקפלת אותו. כפתורי "פתח הכל" / "סגור הכל" מימין למטה. קישור פנימי אל תוך פרק מקופל פותח אותו — אחרת הקפיצה נוחתת בלא‑כלום
סימון מקום הפרק הנוכחי מסומן בצד, ושמו מופיע בשורה העליונה
פס התקדמות קו דק בראש העמוד שמראה איפה אתה במסמך
מגירה למובייל מתחת ל‑1100px התוכן נפתח כמגירה; Esc סוגר
הדפסה ה‑CSS מסתיר את הניווט ופותח כל הפרקים, כדי שהדפסה תהיה מסמך שלם

הבנייה עצמה עברה לסקריפט גנרי שמשמש כל מסמך עברי בריפו:

./scripts/build-doc-html.sh docs/<doc>.md <out>.html "כותרת" "כותרת משנה"

build-onboarding-html.sh נשאר כמעטפת דקה סביבו, כדי שהפקודה שכולם מכירים תמשיך לעבוד. הקוד: ‏scripts/doc-nav.css + scripts/doc-nav.js. בלי JavaScript המסמך עדיין נקרא במלואו עם כל העוגנים — הניווט הוא תוספת, לא תנאי.

‏PDF לכל מסמך עברי, בפקודה אחת (אותו צינור בדיוק כמו ה‑Action, ואותה בחירה שמנוע הדוחות עושה — ‏pandoc ל‑HTML ‏RTL, ואז Chromium מדפיס):

./scripts/build-doc-pdf.sh docs/<doc>.md <out>.pdf "כותרת"

ה‑PDF (docs/onboarding-he.pdf) מופק ידנית בלבד, כדי שעריכות שוטפות של התוכן לא יריצו בניית PDF על כל push: ‏GitHub ⇐ ‏Actions ⇐ ‏workflow ‏onboarding-pdf ⇐ ‏Run workflow. ההפעלה היחידה היא workflow_dispatch — אין טריגר push, ולכן עריכת תוכן לא מריצה בניית PDF. הצינור: ‏pandoc ל‑HTML ‏RTL, ואז Chrome headless מדפיס PDF, כך שהעברית וה‑SVG נשמרים נכון. עותק ה‑HTML נשמר גם ב‑Drive: חומרים מוויקס/sentrix-status/sentrix-onboarding-final.html.

23. סיכום: היום הראשון שלך, ברשימה אחת

  1. גישות (פרק 3) ⇐ התקנה והתחברויות (פרק 4, צעדים 2–3) ⇐ ‏clone.
  2. pnpm setup:gui — האשף עובר את שער הזהות ומריץ התקנה, ‏Prisma, ‏env והרמת הדשבורד. ‏mis-core מקומית — המתכון בפרק 4 צעד 6.
  3. pnpm -r test ירוק; ‏/health ‏200 ו‑/trucks ‏401 מול שער ה‑dev.
  4. קריאה: ‏AGENTS.md, הפרקים 5–8 כאן, ו‑rollback-and-db.md.
  5. תרגיל השינוי הראשון מקצה לקצה (פרק 6): צבע כפתור ⇐ ‏PR ל‑dev ⇐ ‏Cloud Build ⇐ לראות אותו חי ב‑holon.app-dev ⇐ ‏revert.
  6. מנהרת bastion ראשונה + חיבור read-only למסד ה‑dev (פרק 7) — ולנסות DDL, כדי לראות את הסירוב בעיניים. זו לא ענישה, זו רשת הביטחון של כולנו.

נתקעתם על משהו שאין לו תשובה כאן או במסמכי העומק? תפתרו, ואז תוסיפו את התשובה למסמך המתאים ב‑PR — ככה המדריך הזה נשאר המדריך היחיד שצריך.


24. כיבוי Wix — הרנבוק המלא

המסגרת של שניר: רצים במקביל עם הגשר עוד כמה חודשים, מבצעים backfill מדי פעם, ובקאט‑אובר הסופי מעדכנים את אפליקציית ה‑Median. הפרק הזה הוא סדר הפעולות של אותו קאט‑אובר, מה מאמתים לפני כל צעד, ומה נשבר אם מדלגים.

הכלל היחיד שאין ממנו יציאה: ברגע שאתר Wix כבוי, האוספים שלו — כולל ה‑*Archive — הופכים בלתי‑קריאים. מה שלא נמשך עד אז אבד לתמיד. זו הסיבה שהצעד הראשון בפרק הזה הוא לא כיבוי אלא שאיבה (§24.3).

24.1 המצב היום — כתיבה כפולה, לא הגירה

כתיבה בוויקс
     │
     ├──► האוסף בוויקס         (מקבל את השורה בדיוק כמו קודם)
     │
     └──► hook יוצא ──► /_functions/bridge/<collection> ──► mis-core
                             │                                  │
                        סוד משותף                        mapper אידמפוטנטי
                        (401 בלי סוד)                    על (tenantId, legacyId)
מה מצב
אתרי Wix שמשדרים ‏4 — ‏main, ‏mgroup, ‏security, ‏oldsite
אוספים עם נתיב גשר 72
יעדי מסירה ‏2 בשורת קונפיג אחת (baseUrl + baseUrl2) — ‏dev ופרוד מקבלים כל כתיבה
אימות סוד משותף. בוויקס בשם ‏sentrixBridgeSecret, אצלנו ‏wix-bridge-secret → ‏WIX_BRIDGE_SECRET. ‏fail‑closed
כשלים שורות retry פר‑יעד; מה שלא מזוהה נכנס להסגר (legacy-inbox) ולא נעלם
*Archive בכוונה בלי hook — §24.3
מסמכי העזר docs/WIX-BRIDGE-GO-LIVE.md · ‏docs/LEGACY-COVERAGE.md · ‏integration/wix-bridge/

למה אין hook על הארכיונים. שורה מגיעה לאוסף ארכיון רק אחרי שג'וב בוויקס העביר אותה מהאוסף החי — כלומר היא כבר עברה ב‑hook החי וכבר אצלנו, לפי ‏(tenantId, legacyId). hook על ארכיון היה שולח שוב שורות שכבר יש לנו. כל ה‑mappers אידמפוטנטיים ולכן שום דבר לא היה נהרס, אבל תעבורת הגשר ועומק התור היו מוכפלים בתמורה לאפס מידע חדש.

הדבר האחד שהארכיונים כן מחזיקים ואנחנו לא: כל מה שנכתב לפני שהגשר עלה ב‑26–27.7.2026. זו בעיית קריאה, לא בעיית כתיבה, ו‑hook הוא הכלי הלא נכון לה.

24.2 שער ההיסטוריה — הפריט היחיד עם דדליין קשיח

המקור: ‏docs/plans/HISTORY-GATE.md. הרעיון: משיכה לפי דרישה במקום זרימה.

המכניקה כבר קיימת: ‏modules/wix-pull/runner.ts מדפדף אוסף מורשתי דרך הייצוא לקריאה‑בלבד ב‑sentrix-bridge.jsw ומנחית כל שורה דרך אותם mappers שה‑hooks החיים משתמשים בהם. אידמפוטנטי על ‏(tenantId, legacyId), ולכן משיכה יכולה להצטלב עם תעבורה חיה בכל סדר. הוא כבר מקבל ‏plateScope ו‑siteScope — כך תוחמה הדגמת נתניה לתחנה 10. ו‑onScanWrite מוגדר בכוונה כלא‑מוגדר בנתיב המשיכה, כדי ששורות שנמשכו אחורנית לא ידליקו התראות רחוב או פוליגון על אירועים ישנים.

מה שחסר זו רק הדלת הקדמית, ארבעה פריטים:

# מה נדרש למה
1 להוסיף את שמות ה‑*Archive לרשימת ההיתר של ה‑runner ה‑mappers כבר מכסים את התאומים החיים שלהם — שורת משאית מארכיון היא באותה צורה בדיוק כמו חיה
2 משטח בקשה: "תן לי לוחית X / תחנה Y בין תאריך A ל‑B" להגיש קודם מהטבלאות שלנו; רק כשהחלון קודם לשורה הראשונה שיש לנו למפתח הזה — לירות משיכה תחומה ואז לענות
3 לזכור שחלון כבר נמשך הבקשה השנייה לאותה תקופה היא קריאת טבלה רגילה
4 סקופ חובה. בלי כפתור "תמשוך הכל" תחום לפי לוחית/תחנה/תאריך זה קטן. לא תחום על ‏RamzorStatsArchive זה לא

האוספים שבהם מדובר: ‏camerasCollectionArchive (security) · ‏truckCollectionArchive (main) · ‏RamzorStatsArchive · ‏priorityold · ‏ramzorold (oldsite).

הדדליין: השער חייב להיות קיים לפני כיבוי Wix. ברגע הכיבוי הארכיונים הופכים בלתי‑קריאים ומה שלא נמשך אבד. זו הסיבה שהפריט יושב ב‑docs/plans/ ולא ב‑backlog.

24.3 הרנבוק — סדר הפעולות

שבעה שלבים. שלב אינו מתחיל לפני שהאימות של קודמו עבר, וכל שלב אומר במפורש מה נשבר אם מדלגים עליו.


שלב 0 · תמונת מצב לפני שנוגעים

./scripts/dumpdev.py --env prod --i-know     # קריאה בלבד; ה-MANIFEST הוא הראיה

אימות:MANIFEST.md בתיקיית הפלט מציג מספרי שורות שאינם אפס בטבלאות העוגן. אם מדלגים: אין מספר "לפני". כל דיון על "חסרות לי שורות" יהפוך לדיון בלי נתונים.


שלב 1 · לוודא שהגשר באמת מכסה את כל מה שנכתב

# 72 האוספים המוצהרים בשער, בשתי הסביבות
grep -oE "/_functions/bridge/[A-Za-z0-9_+-]+" infra/api-gateway/gateway-openapi-dev.yaml  | sort -u | wc -l
grep -oE "/_functions/bridge/[A-Za-z0-9_+-]+" infra/api-gateway/gateway-openapi-prod.yaml | sort -u | wc -l

ואז לעבור על ‏docs/LEGACY-COVERAGE.md עמודה‑עמודה: לכל אוסף בייצוא, האם יש לו מודל אצלנו, והאם יש לו וו חי.

מה שידוע חסר כרגע, ולכן צריך הכרעה בשלב הזה:

אוסף מצב ההכרעה הנדרשת
supervisorAlertsCollection (146 שורות ב‑mgroup) יובא פעם אחת, בלי וו חי התראות פיקוח שנוצרו בוויקס אחרי 26.7 לא זורמות אלינו. או להוסיף hook, או להכריע שהמסך החדש הוא מקור האמת מכאן והלאה
userRequestsCollection (153 שורות) הוכרז ש‑monday הוא מקור האמת 153 הבקשות ההיסטוריות לא הועברו לשום מקום. לייצא ל‑CSV או להכריע שהן נזרקות
containersSensors, ‏weighStationDrivers/Trucks/Containers בלי מודל, היו ריקים או כמעט ריקים בייצוא לפתוח את ה‑CMS החי ולוודא שהם באמת ריקים ולא שהייצוא פספס
רשימת ה‑CMS של main מעולם לא צולמה פעולה של דקה, וזו הבדיקה היחידה שסוגרת את השאלה "מה לא ידענו שקיים"

אימות: אין שורה ב‑LEGACY-COVERAGE.md עם דאטה חי, בלי מודל ובלי הכרעה כתובה. אם מדלגים: מגלים אוסף חסר אחרי הכיבוי, כשאין ממה לשחזר.


שלב 2 · לבנות את שער ההיסטוריה ולמשוך

ארבעת הפריטים מ‑§24.2. ואז, לכל אתר, למשוך את החלונות שמישהו עוד עשוי לבקש — תחום לפי לוחית/תחנה/תאריך.

אימות: לבחור 5 לוחיות ו‑3 תחנות, לבקש חלון שקודם ל‑26.7.2026, ולראות שהתשובה מגיעה מהטבלאות שלנו ולא משגיאה. ואז לבקש את אותו חלון שוב ולראות שהוא לא ירה משיכה שנייה (פריט 3). אם מדלגים:זה הצעד הבלתי הפיך. אחרי הכיבוי אין מאיפה למשוך.


שלב 3 · חלון הרצה מקבילה שקטה

מדידה, לא תחושה. לאורך כמה שבועות, בכל שבוע:

# ההסגר חייב להיות ריק, או שכל שורה בו מוסברת
curl -s -H "authorization: Bearer $TOK" -H "x-tenant-id: <tenant>" \
  "https://sentrix-gw-7omgn50c.ew.gateway.dev/legacy-inbox?status=quarantined" | python3 -m json.tool | head -40

ובמסך ‏legacy-intake בקונסולת האדמין: ‏dry‑run על תקופה, ולראות ש"מה שהיה נכנס" תואם למה שכבר בפנים.

אימות שלושה שבועות רצוף: ‏(א) ההסגר ריק או מוסבר; ‏(ב) ספירת שורות שבועית בטבלאות העוגן זזה באותו קצב שהאוספים בוויקס זזים; ‏(ג) אפס שורות retry שנתקעו. אם מדלגים: מכבים על סמך "נראה שעובד". פער שיטתי — אתר שהודבק בו hook לא נכון, טננט שלא נפתר — מתגלה רק כשמישהו מחפש דאטה שלא קיים.


שלב 4 · להעביר את המשתמשים לכתובות החדשות

לפני שהאתר הישן כבוי, המשתמשים צריכים להיות כבר על החדש.

  1. כל לקוח מקבל טננט ותת‑דומיין. תת‑דומיין חדש עולה מעצמו ברגע שיש שורת Tenant עם סגמנט — אין שורה להוסיף בטרהפורם (פרק 20).
  2. משתמשים נרשמים בכתובת המייל האמיתית שלהם ומאושרים בקונסולת האדמין (פרק 20). מי שהיה משתמש במערכת הישנה — מקשרים אותו לשורה המורשתית שלו במסך קישור הזהות, כדי שההרשאות וההעדפות שלו יעברו איתו והשורה הישנה תיסגר.
  3. הדומיינים הישנים (main / mgroup / security תחת mis.org.il) נשמרים כ‑alias ומופנים לטננט הנכון, כדי שהלקוח לא יראה שינוי בכתובת ביום המעבר.

אימות: לכל לקוח פעיל, אפס משתמשים ב‑PENDING שהיו פעילים במערכת הישנה, וכל משתמש כזה מקושר לשורה המורשתית שלו. אם מדלגים: ביום הכיבוי מוקד השירות מקבל טלפונים מאנשים שאין להם חשבון.


שלב 5 · להפנות את הקצה

הציוד בשטח משדר לאותן 35 כתובות בדיוק. בקאט‑אובר מפנים אותן לשרת החדש והוא לא יודע שמשהו השתנה. ‏SEC‑1 דו‑מצבי: הטוקן הישן ממשיך לעבוד בזמן שטוקנים פר‑מכשיר מונפקים, ולכן ההפניה וההחלפה אינן צריכות לקרות באותו יום.

# כל 35 הנקודות המורשתיות קיימות בשער (405 ל-GET = הנתיב קיים, המתודה נדחית)
curl -o /dev/null -w '%{http_code}\n' https://sentrix-gw-7omgn50c.ew.gateway.dev/_functions/post_ramzor

אימות: אחרי ההפניה, לבחור 3 משאיות ו‑2 תחנות ולראות שורות נכנסות בטבלאות בזמן אמת, וגם שההתראות שאמורות להידלק נדלקות. אם מדלגים או ממהרים: אירועים מהשטח נופלים בשקט. ה‑DLQ תופס הודעות שנכשלו, אבל מכשיר שמדבר לכתובת שכבר לא עונה לא מגיע ל‑DLQ בכלל.


שלב 6 · אפליקציית Median

מעטפת ה‑Median (webview) נשארת. מה שמשתנה זה לאן היא מצביעה.

מה פעולה
‏URL בסיס לעדכן ב‑appConfig.json מהמארח הישן למארח החדש (<slug>.app.mis-26.com)
‏JS bridge לשמור. הפוש עובר דרכו
פוש (OneSignal) אותו App ID. לוודא שמשתמש שנרשם בשלב 4 מקבל את אותו external id, אחרת פוש ילך למכשיר של מישהו אחר או לאף אחד
‏Apple Sign‑In דרך אותו ‏bundle של ה‑webview. הדומיין החדש חייב להיות ב‑Authorized domains של ‏Identity Platform
בדיקה לפני הפרסום: build פנימי, כניסה עם Apple ועם Google, פוש אחד לבדיקה, ועיבור מסך אחד עם דאטה אמת
פרסום ‏App Store ו‑Play. לתכנן 3–7 ימי סקירה ולא לכבות שום דבר עד שהגרסה זמינה בפועל להורדה

אימות: מכשיר שהוריד את הגרסה החדשה מהחנות (לא build פנימי) נכנס, רואה דאטה אמת, ומקבל פוש בדיקה. אם מדלגים: האתר הישן כבוי והאפליקציה בטלפון של הנהג עוד מצביעה עליו. מבחינת הנהג המערכת מתה, והוא לא יכול לתקן את זה לבד.


שלב 7 · לכבות, בסדר הזה

לא בבת אחת. אתר‑אתר, עם חלון של לפחות שבוע בין אתרים.

  1. להשאיר את האתר חי ולכבות רק את ה‑UI שלו (להסיר הרשאות משתמשים / להוסיף הפניה לכתובת החדשה). ה‑hooks ממשיכים לשדר, ה‑API עוד עונה, והארכיונים עוד קריאים. זו נקודת החזרה האחרונה — מכאן והלאה הכיבוי מתחיל להיות בלתי הפיך.
  2. לחכות. שבוע לפחות. אם מישהו צריך משהו מהאתר הישן — זה השבוע שבו זה יתגלה.
  3. לכבות את ה‑jobs בוויקс (jobs.config), אחרון ה‑hooks. הזרם החי נעצר.
  4. להוריד את האתר רק אחרי שכל השאר עמד שבוע.
  5. רוטציה של 7 סודות המורשת — עכשיו, ולא לפני. הם משרתים את האתר החי, ורוטציה לפני הכיבוי משביתה שירות ללקוחות. הרשימה: ‏מעבר ל-GCP/07 - טבלת מיפוי סודות.
  6. להסיר את ה‑alias-ים הישנים מ‑mis.org.il לפי לוח זמנים שלנו, לא בו‑ביום.

אימות אחרי כל אתר: ‏24 שעות בלי ‏404 חדש בלוגים של השער, בלי שורה חדשה בהסגר, ובלי טלפון מלקוח. אם מדלגים על ההמתנות: מכבים ארבעה אתרים ביום אחד, ואז מנסים לאבחן איזה מהם היה זה שהחזיק את הדבר שנשבר.

24.4 רשימת בדיקה אחת, לתלות על הקיר

[ ] 0  dumpdev prod — MANIFEST עם מספרים
[ ] 1  LEGACY-COVERAGE נסקר, כל אוסף חי בלי מודל קיבל הכרעה כתובה
[ ] 1b רשימת ה-CMS של main צולמה
[ ] 2  שער ההיסטוריה בנוי · *Archive ברשימת ההיתר · חלונות נמשכו · משיכה כפולה לא קורית
[ ] 3  שלושה שבועות רצוף: הסגר ריק, ספירות זזות יחד, אפס retry תקוע
[ ] 4  כל משתמש פעיל קיים ומאושר בחדש · קושר לשורה המורשתית · alias-ים מופנים
[ ] 5  הקצה מופנה · 3 משאיות + 2 תחנות מאומתות חי · התראות נדלקות
[ ] 6  Median פורסם בחנויות ואומת ממכשיר אמת (לא build פנימי)
[ ] 7  אתר-אתר: UI כבוי → שבוע → jobs כבויים → האתר מורד → סודות בסבב → alias-ים מוסרים

הצעד הבא בפרק הזה: להוסיף את חמשת שמות ה‑*Archive לרשימת ההיתר של ה‑runner ולפתוח PR. זה הפריט היחיד בכל הרנבוק שיש לו תאריך אחרון שנקבע על ידי מישהו אחר.


25. מה נשאר לסוף ומה אחרי המסירה

מיושר לסיכום האודיט (docs/audit/2026-07-26/full-audit.md ‏§6 ולראיות ‏docs/evidence/2026-07-27/). שלוש רשימות, כי הן שלושה דברים שונים: מה שנדחה בהחלטה, מה שאחרי המסירה, ומה שפער אמיתי בלי החלטה.

הפרק הזה מול פרקים 28–30, כדי שלא תחפשו במקום הלא נכון. כאן נמצא מה שהוחלט לדחות ומי החליט — פריט עם החלטה מתועדת מאחוריו. פרק 28 הוא המגבלות הטכניות שחיות איתנו גם אחרי שהכל הודלק, ופרק 30 הוא מה שראוי לעשות בספרינט הראשון. פריט יכול להופיע בשניים מהם משתי זוויות שונות; מה שלא יקרה הוא שהוא יופיע רק כאן ויעלם מהתמונה הטכנית.

25.1 נדחה בכוונה — החלטות שניר

פריט ההחלטה מה בנוי בזמן ההמתנה מה נדרש להדליק
גיבוי offsite ל‑AWS לסוף. "בסדר שלפרוד אין גיבוי חוץ עדיין, אבל כשיידלק חייב להיות מנגנון אימות" רגל ה‑S3 מדלגת, מדווחת בלוג ויוצאת ב‑0. מנגנון האימות נבנה ורדום: ‏BACKUP_MODE=verify מאמת קיום, טריות (8 יום לשבועי / 36 ש' ליומי), קריאות וכותרת הצפנה — ועליו התראת היעדרות על "אין אימות מוצלח ב‑36 שעות", שתופסת scheduler מושהה או ג'וב שנמחק סוד ‏AWS_BACKUP_CREDENTIALS + ‏backup_verify_enabled = true ב‑prod.tfvars
דיספאץ' התראות לערוצים להזכיר ולהחליט יחד. שניר נוטה להדליק הכל בנוי: טבלאות העדפה פר‑משתמש ופר‑סוג, תור Pub/Sub, שירות ומתאמי מייל/פוש/ווטסאפ, ופופאפ+סירנה לחמ"ל. ברירת המחדל ‏[] = מזהה וכותב, לא משגר alert_dispatch_channels = ["popup"] ב‑tfvars. ההמלצה: שלב 1 בלבד, שבוע על dev, ולראות כמה פעמים הפופאפ קפץ. המספר יחליט אם שלב 2 מוצדק — ‏docs/FEATURE-alert-dispatch.md
‏grok אחרי המסירה, מחוץ לסקופ הראוטר אגנוסטי; ספק נוסף הוא מתאם ולא ארכיטקטורה החלטה + מפתח
רוטציית 7 סודות המורשת לסוף הערכים מוצערים ב‑HEAD ומוכרים לפי fingerprint עם הערת רוטציה אחרי כיבוי Wix (פרק 24 שלב 7.5). רוטציה לפני זה משביתה את האתר החי
ANTHROPIC_API_KEY לא נדרש — מתקננים על OpenAI אינו בפריסה, ב‑IAM או בסדר ברירת המחדל. המתאם עצמו קיים בעץ ונגיש בהצמדה מפורשת
הטבלה הדחוסה המורשתית של סטטוס משאיות apps/client-app/src/products/contractors/components/screens/classic-truck-status-screen.tsx בריפו ולא מנותב העמודות מכוסות בשני מסכי ‏/trucks שורה אחת, אם שניר יגיד שהוא רוצה אותה כטאב רביעי
holon ו‑kedumim אינם בסט ההדגמה ("משכפלים את רעיון נתניה") השורות קיימות בכוונה: ‏holon הוא יעד הקליטה המורשתית החי לאתר שקילה 270270270, ו‑kedumim מחזיק את "קדומים כתום" ואת מסלול ה‑GPX האמיתי. הדלתות שלהם מצביעות על המוצר הנכון

25.2 אחרי המסירה

פריט למה זה יושב שם
שער ההיסטוריה (docs/plans/HISTORY-GATE.md) הוחלט בעקרון, לא תוזמן. Wix חי עד המסירה ולכן הארכיונים נגישים. חייב להיות קיים לפני הכיבוי — פרק 24 §24.2
אכיפת ‏VPC‑SC נאכף על שני הפרויקטים. גישת ‏SA לזרימות הדאטה החוצות-פרויקט + גישת צוות לפי זהות נקובה (SSO/MFA, ללא ‏access level מבוסס-IP); התראת חסימת-אכיפה פעילה + גלגול-אחורה בפקודה אחת
AuditLog רוחבי החלטת סכמה. קיים ‏AuditEvent (שינויי סיסמה) + ‏/credentials/audit + יומני ענן
נוהל מחיקה לפי בקשת נושא מידע ‏27701. המידע האישי הישיר מרוכז במעט טבלאות, ולכן זו כתיבת נוהל ומחיקה ממוקדת
אכיפת ‏TLS בחיבור למסד דורש קודם ‏sslmode=require בכל סודות החיבור ואז שינוי המופע. תפוגה מוצהרת: 31.10.2026. המופע ‏private‑IP בלבד, ולכן החשיפה נמוכה
מפתח ‏OpenAI ייעודי לפרוד פרוד חולק כרגע את מפתח ה‑dev. זו הייתה הדרך המהירה להחזיר את ה‑Copilot לפני ההדגמה, והיא מטשטשת ייחוס צריכה
‏MCP מעל ‏Median API תוכנן ב‑PROJECT-MAP, לא נבנה, לא נדרש עדיין
איחוד חמש מראות ה‑Prisma לחבילה אחת עובד וזול היום; מתיישן רע. לעשות לפני השירות השישי

25.3 פערים אמיתיים — בלי החלטה, וצריך אחת

# פער חומרה מה נדרש
1 מודול תקלות (Fault) — אין תיקייה ב‑mis-core, אין נתיב בשום שער, ו‑api/faults/route.ts מחזיר 503 במכוון החור הגדול ביותר במוצר. ‏§3.8 הוא מסך חתום עם 503 מאחוריו החלטת סכמה: Fault עם חומרה, תחום, משאית, סוג, פתיחה/סגירה, סטטוס טכנאי וקישור תקלה חוזרת. כל שאר משפחת התקלות מחכה לזה
2 העדפות התראה מרובות‑ערוצים — ‏/notification-preferences/me קיים, ומסך הקבלנים עוד מדבר עם ‏/api/team/.../email-prefs הישן מסך חי מחובר ל‑endpoint הלא‑נכון הפניית הפאנל. הפריט בעל התשואה הגבוהה ב‑CAPABILITY-MATRIX ‏§A.2
3 externalMISDB — הקוד המורשתי מפנה אליו 1,270+ פעם הליבה החיה שליטה בפרויקט שמריץ אותו, ואז חיבור ישיר. הגשר מכסה את הזרם החי אבל לא את ההיסטוריה שכבר שם
4 supervisorAlertsCollection בלי וו חי התראות פיקוח מ‑26.7 והלאה לא זורמות פרק 24 שלב 1
5 פערי מפה — ‏24 YES / 12 PARTIAL / 44 NO מתוך 81 שלושה אשכולות: ארבע שכבות נקודות שמציירות ואף אחד לא מזין · ‏replay מסלול עם מהירות · שמירת פוליגונים (ציור עובד, הצורה מתה ברענון) MAP-PARITY.md. השכבות הן הזול ביותר: שורת checkbox + זריעה
6 ‏auto-refresh על המפה לא הועבר מקלאסיק ‏poller משותף עם backoff. ‏setInterval פר‑מסך הוא מה שיצר את סופת ה‑429
7 תבניות ה‑PDF מ‑pdfgeneratorapi ‏report-engine בנה חדשות לא חוסם. אבל אם יש דוח שלקוח מכיר מהעין — התבנית הישנה היא ה‑spec, ואף אחד לא הוריד אותה
8 מילוי טופס 1 / דיווח למפ"ס הדוח מפיק כל שדה שהטופס מבקש, לא ממלא את ה‑PDF הרשמי לטופס אין API והוא משתנה. מילוי אוטומטי של גרסה מיושנת גרוע מהדבקה ידנית של מספרים נכונים
9 יכולות קצה — זיהוי זיהום, גלישה, שתי תמונות מנוף, הצלבת טונות מול נפח, אותות מטאטא, ראש שקילה, ‏Proof of Service לביוב, חיישני מפלס לטמונים כולן דורשות סוג אירוע קליטה ומכשיר שמשדר אותו נכון שהן לא מוצגות. מסך על אירוע שאף מכשיר לא שולח הוא כפתור מת, לא תיקון

25.4 שורה תחתונה

‏7 פריטים נדחו בהחלטה · ‏8 אחרי המסירה · ‏9 פערים אמיתיים, מהם שלושה חוסמים: מודול התקלות (‏#1 — מסך חתום עם 503), שער ההיסטוריה (§25.2 — הדדליין היחיד שנקבע בידי מישהו אחר), והשליטה ב‑externalMISDB (‏#3 — הליבה החיה).

הצעד הבא: לפתוח את החלטת הסכמה על ‏Fault. היא חוסמת חמישה פריטים אחרים במשפחת התקלות, והיא ההחלטה היחידה כאן שאף אחד לא יכול לקבל במקומך.


26. יד ביד — מדריך ההרצה

הפרק הזה מצביע במקום לשכפל, כי המדריך עצמו באנגלית בכוונה: כל שורה בו נועדה להדבקה בטרמינל, ועברית בטרמינל מתהפכת.

docs/HAND-IN-HAND-EN.md — תשע מקטעים שאפשר לרוץ עליהם שורה‑שורה, ואז ללמד מהם:

# מה יש שם
1 ‏clone ופתיחה ב‑Windsurf: מה מותקן קודם, ‏gh auth, ‏pnpm install, ‏.env, ומה לצפות בפעם הראשונה
2 התמצאות: איזה מארח מגיש איזו אפליקציה, ואיפה יושבים המסך, נתיב ה‑API והשירות שמאחוריו — ארבעת הקפיצים
3 שינוי קוד קטן וראייתו מקומית: ‏pnpm -F <app> dev פר אפליקציה עם הפורט שלה
4 שינוי מסד קטן בבטחה: שלוש דרגות הגישה, חוק ה‑DDL, ומתי ‏SCHEMA-PUSH-OK: הוא התשובה הנכונה ומתי לא
5 dumpdev.py ו‑makeprod.py — הוורקפלואו המומלץ
6 פרסום ל‑dev: ענף, קומיט, ‏PR, מה כל בדיקת CI אומרת ומה עושים כשהיא אדומה, מה ‏guard-dev אוכף — ו‑6.7: לשלוח פיצ'ר כבוי (הצהרת דגל, מה "כבוי" חייב להיות, ומחיקתו ב‑retireAfter)
7 קידום לפרוד — המקטע שאפשר לתת למורן כמו שהוא: מה היא מאשרת, חמש הבדיקות שלה (החמישית: אם הדיף נגע ב‑YAML של השער, לוודא שמישהו מגלגל), ומה קורה ברגע שהיא מממזגת
8 ‏troubleshooting לכשלים שנתקלנו בהם באמת — ‏8.1 הוא זה שתפס אותנו פעמיים
9 גרסת עמוד אחד להעתקה

שני הסקריפטים, בקצרה

./scripts/dumpdev.py --schema-only     # לפני שנוגעים: תמונת מצב + MANIFEST עם מספרים
./scripts/makeprod.py                  # חזרה יבשה של הקידום — קוראים אותה
./scripts/makeprod.py --go             # מריץ, ועוצר בשער האישור
./scripts/makeprod.py --go --from-step 6   # אחרי שהמאשר מיזג: סכמה, בילדים, smoke

עותק של המדריך יושב גם ב‑Drive: ‏חומרים מוויקס/sentrix-hand-in-hand-EN-2026-07-27.md.


27. וידאו חי, בריאות מצלמות ומוקשי הדמו

הפרק הזה מכסה את החלקים שנוגעים למערכות חיצוניות — נגן ה‑CMS, שער המדיה, שער ה‑API, ה‑CMS של המקליטים ו‑Cloud Scheduler. באזורים האלה טסט ירוק אינו הוכחה שהדבר עובד: ההוכחה היא הרצה מול השרת האמיתי, קריאת לוגים אמיתיים ופענוח פריים אמיתי. אם אתה נוגע בווידאו, בבריאות מצלמות, בשער או בדמו — קרא את זה קודם.

27.1 יש שני מנגנוני וידאו חי, לא אחד

שני מסלולי וידאו חי חיים במקביל, והם לא מתחלפים זה בזה:

מה דרך מה איך זה עובד
משאית (מקליט DVR) נגן ה‑CMS של 808gps dvr.mis.org.il/808gps/open/player/video.html?devIdno=<did>&jsession=<…> — נדרשת התחברות לפלטפורמה, וה‑URL נושא סוד (jsession).
מצלמת עמוד שער מדיה (MediaMTX) dvr.mis.org.il/security/<שם המצלמה>/index.m3u8 — HLS, ודף נגן live.html?cam=… שעוטף אותו. אין jsession, ה‑URL לא נושא סוד.

למה זה מהותי: מצלמת עמוד (Hikvision) אינה מכשיר CMS. אם דוחפים Camera.externalId שלה לנגן ה‑CMS מתקבל מסך כחול, כי הנגן הזה מדבר רק עם מקליטי DVR. הראוט חייב לפצל לפי סוג המכשיר.

איך הראוט מפצל: services/security-core/src/vault/hls-gateway.tscameraId → שער המדיה, truckRef/deviceRef → נגן ה‑CMS. התשובה נושאת kind: 'cms-player' | 'hls-gateway' כדי שהצד הקדמי ידע מה הוא מקבל.

שרשרת הבקשה של מצלמת עמוד במלואה: לחיצה → live.html ב‑nginx → ‏video.js מקומי → ‏GET /security/<cam>/index.m3u8 → nginx ‏proxy ל‑MediaMTX על ‏127.0.0.1:8888 → ‏sourceOnDemand פותח RTSP אל ה‑NVR בעמוד → MediaMTX אורז HLS.

שלושה מוקשים שיעלו לך שעות

  1. מקור מת לא נכשל מהר.sourceOnDemand: yes — MediaMTX פותח את ה‑RTSP רק כשמבקשים, ולכן עמוד שהמצלמה שלו מתה עונה 500 אחרי ~10.3 שניות, בזמן שה‑redirect שלפניו עונה ב‑0.2. תקציב ה‑probe הוא 20 שניות בגלל זה. אם תקטין אותו — כל מצלמה מתה תדווח "השרת לא זמין", וזה שולח את הטכנאי לכיוון הלא נכון.
  2. העוגייה בנויה ל‑iframe.SameSite=None; Secure; Partitioned (CHIPS), וגם בלי עוגייה בכלל ה‑playlist נטען. לכן הבחירה ב‑iframe של live.html היא נכונה, ולא למשוך את ה‑m3u8 חוצה‑מקור.
  3. status הוא לא האמת. עמודי גליל‑עמקים אין להם edge‑PC שמדווח, ולכן הם תמיד ‏UNKNOWN. הכפתור מוצג ל‑ONLINE וגם ל‑UNKNOWN, וההחלטה נופלת על ה‑probe בצד השרת. אם תגייט על status === 'ONLINE' — סגרת את הלייב על כל עמודי גליל.

מה משדר בפועל, ומה לא יכול

מתוך 373 עמודים 4 מפורסמים בשער (כולם Shomron-*). השאר לא — וזו פעולה על ה‑DVR אצל מורן, לא בקוד. אשקלון (198) על רשת פנימית 10.0.x.x עם סיסמאות בתוך כתובת ה‑RTSP: לא מגיעה מהענן, וגם השמות בעברית ש‑MediaMTX דוחה מפורשות. שם מחשב‑קצה מושך RTSP מקומית ומעלה סטילס וקליפים בלבד; לייב באתר כזה ידרוש שרת מדיה בשטח.

27.2 בריאות מצלמות — איך מזהים מצלמה מנותקת

הפיצ'ר: איזו משאית אונליין אבל עם מצלמה שהפריים שלה שחור לגמרי. ‏services/mis-core/src/modules/camera-health/.

הכיול נעשה על צילומים אמיתיים, ואל תשנה אותו בלי לחזור על זה:

ערוץ מרכז התמונה (crop 50%)
חי, לילה ממוצע 109–124, פער 185–198
מנותק ממוצע 16, פער 0

שני דברים שאסור לפספס:

  1. "שחור" הוא 16, לא 0. זו רצפת ה‑video range. בדיקת === 0 מפספסת כל מצלמה מתה בצי.
  2. בודקים ריבוע במרכז בלבד. הטקסט הצרוב על התמונה (GPS למעלה, שעה + OFF CH3 למטה) מרים את המקסימום של הפריים המלא ל‑243, ולכן פריים מת "נראה" כאילו יש בו אות. במרכז הוא שטוח לחלוטין.

הסף: ממוצע ≤32 וגם פער ≤10. שניהם נדרשים — פריים בהיר ואחיד (עדשה מול קיר ביום) הוא שטוח אבל לא שחור, ואינו תקלת חיווט.

איך צולם הפריים: StandardApiAction_capturePicture.action?…&Type=1 מחזיר DownUrl עם JPEG של הפריים הנוכחי. שני דברים: המקליט עונה גם על ערוץ מנותק (הוא מרנדר שחור עם OSD) — וזה בדיוק האות; וה‑DownUrl חוזר עם IP גולמי על פורט 16611, שאנחנו ממפים בחזרה לשם המתועד.

הרצה: POST /jobs/camera-health-sweep, מתוזמן כל שעה בדקה 20 ב‑dev וב‑prod. מתוקצב פר‑ריצה (הפחות‑נבדקים קודם), ולכן צי בכל גודל מתכסה בכמה שעות. מסך: קונסולת ניהול → בריאות מצלמות (/admin/camera-health).

תבנית שכדאי לחפש בתוצאות: כמה משאיות שמחזירות בדיוק ימנית + אחורית שחורות אינן בהכרח מצלמות שנשרפו — זו החתימה של התקנה שבה שני הערוצים האחרונים לא חוברו. תשאל את המתקין לפני שמזמינים חומרה.

ואל תקרא את המספר הזה כמספר הצי. הסריקה מתוקצבת ל‑120 מכשירים לריצה (הכפתור במסך: 60) מול כ‑200 משאיות עם מקליט, ונבדקות רק משאיות שה‑CMS מדווח אונליין באותו רגע. מכשיר שלא מותאם לשורת Truck נספר ב‑devicesUnmatched ונדלג. כל ריצה היא מדגם, לא מפקד — summary.total / 4 הוא מספר המשאיות שבאמת נבדקו.

משאית דמו ומשאית לקוח חולקות מקליט — וזו לא בעיה קוסמטית

demo-connect-online מכוון את משאית הדמו למקליט אמיתי שאונליין כרגע, ודורס את הלוחית שלה במספר המקליט (externalId: did, plate: did). ו‑TruckCameraHealth הוא @@unique([devIdno, channel])שורה אחת לכל ערוץ מקליט בכל הפלטפורמה. כלומר שתי המשאיות לא מייצרות שתי שורות; הן נלחמות על אותה שורה, ומי שהסריקה התאימה הוא הבעלים שלה. בלי טיפול, תקלה של לקוח אמיתי מוצגת בטננט דמו בתור "רכב <סריאל>", ובמסך זה נראה כאילו הדוח פשוט לא מסנן דמו.

שני חצאים לתיקון, ושניהם נחוצים:

  1. matchTruck מעדיף את המשאית האמיתית על המשאית המושאלת על אותו did — העדפה, לא החרגה, כדי שתקלה אמיתית שרק השורה המושאלת מצביעה על המקליט שלה תמשיך להירשם.
  2. /admin/camera-health מחריג את השורה המושאלת כברירת מחדל (?includeDemo=true כדי לכלול). הספירות מסוננות באותו אופן כדי שה‑KPI יתאים לטבלה, ו‑summary.demoExcluded אומר כמה הוסתרו. דוח המייל מחריג בלי opt‑in.

ואל תסנן לפי טננט. החרגה לפי צורת ה‑slug (demo, demo-*, *-demo) מסתירה תקלות אמיתיות: netanya-demo מחזיק משאיות נתניה אמיתיות (RamsaZC בייבוא המורשת, לוחיות אמיתיות, בלי כינוי של live‑demo), ולכל אחת עלולה להיות ימנית ואחורית מנותקות. ה‑slug אומר דמו, המשאיות לא.

מה שמזוהה הוא השורה המושאלת עצמה, לפי שני סימנים שכל אחד מהם מספיק: הכינוי הוא סמן ה‑live‑demo, או שהלוחית היא מספר המקליט — מה שלא קורה באף משאית אמיתית. הכל במקום אחד: services/mis-core/src/lib/live-demo-truck.ts.

הרגיסטר של ה‑CMS הוא לא ה‑DB שלנו

קונסולת ה‑CMS היא רגיסטר נפרד. העץ שלה (Monitoring Center (online/total)) סופר מכשירים רשומים מול מחוברים באותו רגע, ומספרם משתנה כל הזמן. הסריקה בודקת רק מה שאונליין באותו רגע, ולכן מקליט שחונה כל היום בלתי נראה לה גם אחרי מאה ריצות.

אל תסיק גודל צי מ‑Truck.externalId. מספר שורות ה‑Truck אינו מספר המקליטים, ואינו אפילו מספר הרכבים הייחודי: אותו צי מיובא ליותר מטננט אחד, כך שלוחיות זהות חוזרות על פני mis/contractors. המקליטים שלנו אינם נשמרים על שורת ה‑Truck בכלל.

Truck.externalId מחזיק את הלוחית, לא את מספר המקליט. לכן הענף המתועד ב‑matchTruck (externalId === did) לא מתאים משאיות לקוח, וכל ההתאמות עוברות דרך ה‑fallback של vid.startsWith(plate). מכשיר שתווית ה‑CMS שלו לא מתחילה בלוחית נספר ב‑devicesUnmatched ונעלם.

מכשיר שה‑CMS מכיר והפלטפורמה לא הוא כמעט תמיד רכב שלא יובא, לא באג התאמה: הלוחית שבתווית ה‑CMS לא מוצאת שורת Truck, והסריאל לא מוצא nvrId בייבוא המורשת. עמודת recorderSerial על ה‑Truck — התיקון שצורת matchTruck מזמינה לחשוב עליו — לא תתקן אותם. זה ייבוא, לא לוגיקה.

צילום מצב מלא של המבנה, כולל עץ הקבוצות ב‑CMS והפילוח לפי ענף:docs/evidence/2026-08-04/cms-fleet-structure-he.md. מתוארך בכוונה — העץ משתנה, אז תרעננו ואל תסתמכו על המספרים בלי בדיקה.

GET /admin/camera-health?coverage=true עונה על זה במספרים חיים במקום בהשערות: cmsDevices / cmsOnlineNow מהרגיסטר, matchedToTruck לפי אותו matchTruck שהסריקה מריצה, ו‑שתי סיבות נפרדות לחוסר — neverScanned (לא היה אונליין באף סריקה, בעיה של רכב) מול unmatchedToTruck (ה‑CMS מכיר, הפלטפורמה לא, בעיה של שורת Truck), ושני הדליים חוזרים בשמםneverScannedSample עם לוחית וטננט, unmatchedSample עם תווית ה‑CMS, כל אחד עם דגל truncation נפרד, ומכשיר מופיע באחד מהם בלבד. זה פרמטר על הנתיב הקיים ולא ראוט חדש — ראוט חדש היה דורש גלגול קונפיג של השער לפני שהוא מפסיק להחזיר 404 (27.3).

ו‑POST /admin/camera-health/sweep מוגבל ל‑60 מכשירים, לא 500.deviceLimit: 120 מחזיר 504 upstream request timeoutהשער מוותר הרבה לפני Cloud Run. סריקה רחבה היא עבודה של ה‑scheduler, שקורא ל‑runner ישירות.

27.3 עריכת ה‑YAML של השער לא עושה כלום עד שמגלגלים

זה כתוב בפרק 10, וזה חוזר בכל נתיב חדש: מוסיפים אותו לספק, הטסטים עוברים, ה‑CI עובר, הפריסה עוברת — ואם הקונפיג לא גולגל, השער מחזיר 404 לפני שהבקשה בכלל מגיעה ל‑mis‑core, והמסך אומר "נכשל".

למה הטסטים לא תופסים את זה: route-contract-drift.test.ts משווה את הקובץ לטבלת הראוטים. הוא לא יכול לדעת אם הקונפיג גולגל. אין שום בדיקה אוטומטית על זה.

הכלל: נתיב חדש בשער = שני צעדים, תמיד, בשתי הסביבות:

python3 scripts/ci/validate-gateway-spec.py \
  infra/api-gateway/gateway-openapi-dev.yaml infra/api-gateway/gateway-openapi-prod.yaml
# ~2-4 דק' ליצירה, ~5-8 דק' לעדכון, ורק אחד בכל רגע לכל API
gcloud api-gateway api-configs create sentrix-api-config-v<N>-$(date +%Y%m%d) \
  --api=sentrix-api --openapi-spec=infra/api-gateway/gateway-openapi-dev.yaml \
  --project=mis26-dev --backend-auth-service-account=sa-api-gateway@mis26-dev.iam.gserviceaccount.com
gcloud api-gateway gateways update sentrix-gw --api=sentrix-api \
  --api-config=sentrix-api-config-v<N>-$(date +%Y%m%d) --location=europe-west1 --project=mis26-dev

האימות שאסור לדלג עליו — נתיב חדש חייב לענות 401 (מוצהר, נעצר באימות) או 405 (מוצהר, שיטה אחרת), לעולם לא 404:

GW=$(gcloud api-gateway gateways describe sentrix-gw --location=europe-west1 \
      --project=mis26-dev --format='value(defaultHostname)')
curl -s -o /dev/null -w "%{http_code}\n" https://$GW/admin/camera-health   # 401, לא 404

27.4 מוקשי הדמו

מה demo-refresh עושה./jobs/demo-refresh שומר את שבוע הצי של כל טננט דמו טרי — הוא מזריע שבוע של היסטוריה כדי שהמסכים ייראו מלאים בכל כניסה, ולשני הדמואים המובילים (demo-agriculture, demo-port) הוא מרכיב יום עסקים מלא. הג'וב מדווח בכל ריצה ב‑coverage אילו טננטים רועננו ואילו לא — לא כל טננט דמו נכלל, וטננט כמו netanya-demo (דמו ראש העיר, רץ בפרודקשן על netanya-demo.app.mis-26.com) אינו מתרענן. הסטים אינם מתחלפים בין טננטים: משאית תערובות בחברת אוטובוסים היא לא דמו, היא באג.

היום הוא יום ישראלי. הג'וב בונה את חלון "היום" ב‑Asia/Jerusalem, לא משעון ה‑UTC של Cloud Run — אחרת בין חצות לארבע לפנות בוקר אין "היום" בכלל והריצה כותבת את יום האתמול. משתמשים ב‑yardDayKey/yardDayWindow הקיימים ב‑modules/ituran/segment.ts (‏DST‑תקינים). אל תכתוב עוזר יום שלישי — יש שניים, אחד מהם ב‑modules/cargo/board.ts.

מסך הסיכום היומי הוא תצוגת חריגים. מסך הצי היומי מסודר סביב שורה פר רכב שמציגה ק"מ מול תוכנית, שעות מנוע/עבודה/סרק, ניצולת, מהירות שיא ותגי חריגה; קווי הזמן מקופלים בתוך כל שורה ונפתחים לפי דרישה. הכיוון: קודם מה חריג, ורק אז הפירוט.

עמודים חיים בדמו. העמודים המשדרים מוזרעים לטננטי הדמו בשתי הסביבות (‏services/security-core/scripts/seed-live-poles-into-demos.ts --env dev|prod). הטריק: ה‑externalId של השורה הוא נתיב הזרם בשער, ולכן עותק בטננט אחר משדר בדיוק כמו המקור. --env הוא חובה — למזהי הטננטים יש uuid שונה בכל סביבה.

מעבר שעון. החלון DST‑תקין, אבל ההיסטים בדקות שבתוכו לא: ביום המעבר באביב היום הישראלי הוא 23 שעות וארבע שורות נופלות ליום הבא, וביום המעבר בסתיו השעות זזות שעה. פעם בשנה, דמו בלבד.

27.5 שני מוקשים תפעוליים

ffmpeg כאן בלי fribidi.drawtext בלי fontfile נותן ריבועי טופו על עברית — אבל פונט עברי לבדו לא פותר: ה‑build הזה מקושר ל‑harfbuzz ולא ל‑fribidi, ולכן ‏drawtext מסדר עברית בסדר לוגי משמאל לימין, כלומר הפוך. libass כן מיישם bidi — עברית עוברת דרך מסנן ass, ו‑drawtext נשאר ל‑ASCII בלבד (טיימקוד, תוויות). בונוס: ב‑drawbox הביטוי t ב‑x/y הוא עובי המסגרת, לא הזמן — אנימציה שם נכשלת בשקט.

סודות לא נכתבים לקובץ. אף פעם. קל להיגרר לכתוב מחרוזת חיבור עם סיסמה או מפתח חתימה לקובץ עבודה זמני כשמריצים משהו ידני. אל תעשו את זה. הכלל: מעבירים סוד ישר לפקודה הצורכת באותה הרצה, דרך משתנה סביבה, ולא מדפיסים ולא שומרים:

DATABASE_URL=$(gcloud secrets versions access latest --secret=MIS_CORE_RUNTIME_DATABASE_URL \
  --project=mis26-dev | sed 's/?schema=.*//') psql "$DATABASE_URL" -c '...'

27.6 השיעור, בשורה אחת

באגים שנוגעים למערכות חיצוניות נשארים ירוקים בטסטים ונתפסים רק בהרצה — מול השרת האמיתי, מול לוגים אמיתיים, מול פריים אמיתי. אם השינוי שלך נוגע ב‑CMS, בשער מדיה, בשער API או ב‑Cloud Scheduler — הטסט הוא לא ההוכחה. ההרצה היא ההוכחה.

28. המגבלות — מה המערכת לא עושה, ומה לא מגלה לכם לבד

הפרק הזה קיים כדי שלא תגלו את הדברים האלה תוך כדי תקלה. כל שורה כאן היא מצב קיים ומוכר, ולא באג פתוח; מה שראוי לטיפול מופיע בפרק 30 כמשימה.

מגבלות שהטסטים לא יכולים לכסות

המגבלה מה זה אומר בפועל
גלגול קונפיג השער אינו אוטומטי נתיב חדש ב‑YAML של השער נכנס לתוקף רק אחרי api-configs create + gateways update. ‏route-contract-drift משווה קוד ל‑YAML ואינו יכול לדעת אם הקונפיג גולגל. הסימן: השער מחזיר 404 לפני שהבקשה מגיעה לשירות, והמסך אומר "נכשל". האימות היחיד הוא HTTP — נתיב חדש חייב לענות 401 או 405, לעולם לא 404
אין טסט מול מסד אמיתי כל הטסטים מריצים Prisma מוקאפ. שגיאת migration, index חסר או התנהגות של ON CONFLICT נתפסות רק בהרצה מול dev
מסלול ה‑PDF מדלג מקומית בלי PDF_CHROMIUM_PATH חמשת טסטי ה‑PDF מדלגים. ‏CI נכשל אם אין דפדפן, כדי שהדילוג לא ייקרא כמו "עבר"
מערכות חיצוניות ‏CMS הווידאו, ‏MediaMTX ואיתוראן חיים מחוץ לענן שלנו. טסט לא מדמה אותם; פרק 27 הוא הפרק שכתוב מהניסיון הזה

מגבלות של מצב הפריסה

המגבלה מה זה אומר בפועל
קוד טרפורם ≠ ענן חלק מהמשאבים ב‑infra/terraform/ מסומנים INTEGRATOR: NEW entry — הם ייכנסו ב‑apply הבא, dev קודם. עד אז הג'וב קיים ואף אחד לא קורא לו. terraform plan הוא הדרך היחידה לדעת מה עומד שם
alerts-engine ו‑ai-agents חיים ב‑dev בפרוד הם טרם נפרסו. משפחות ההתראות מונחות‑האירועים רצות מקצה לקצה ב‑dev
ייצוא החיוב של GCP לא מופעל ולכן כל מספר כספי במסכי העלות הוא הערכה, והוא מסומן כך. ה‑provenance של כל figure אומר את זה במפורש — list-price הוא לא measured
מד האחסון לא מחובר storage ב‑provenance מדווח unpriced, והמסך לא מציג מספר במקום להציג ניחוש
רגל הגיבוי ל‑AWS מדלגת עד שקיים סוד AWS_BACKUP_CREDENTIALS, רגל ה‑S3 מדווחת בלוג ויוצאת ב‑0. מנגנון האימות בנוי ורדום: BACKUP_MODE=verify מאמת קיום, טריות, קריאות וכותרת הצפנה, ומעליו התראת היעדרות

מגבלות מבניות שהוחלטו במכוון

המגבלה ההחלטה שמאחוריה
הווידאו לא נשמר אצלנו ‏D5. הוא נשאר ב‑NVR ונצפה לפי דרישה. משמעות: צפייה חיה תלויה במקליט שמקוון עכשיו — ולכן קיים הג'וב שמכוון את משאית הדמו למקליט חי
packages/api-client כתוב ביד ‏8,016 שורות שמשקפות את openapi.yaml. יש שומר סחף שמשווה 114 סכימות ונכשל על כל פער בלי נימוק כתוב, אבל ה‑codegen עצמו לא הונחת — משימה 1 בפרק 30
חמישה עותקים של סכימת Prisma הקנוני ב‑packages/shared-db; שומר CI מכשיל PR שבו מראה נבדלה. עובד וזול, ומתיישן רע — ראוי לאיחוד לפני השירות השישי
שני מסדים mis_core ו‑security_core מופרדים בכוונה. משמעות: אין JOIN ביניהם. הגשר הוא קריאת HTTP עם claims חתומים
הדמו נזרע ליום demo-refresh מריץ את זריעות יום‑הדמו לתאריך של היום, אידמפוטנטית ומצטברת. סביבה טרייה בלי הרצה אחת ⇒ מסכי דמו ריקים
supervisorAlertsCollection יובא פעם אחת ‏146 שורות מהמערכת המורשתית, בלי hook חי. התראות פיקוח שנוצרו שם אחרי הייבוא לא זורמות אלינו. צריך הכרעה: להוסיף hook, או להכריע שהמסך החדש הוא מקור האמת מכאן והלאה
ההיסטוריה ב‑externalMISDB הגשר מכסה את הזרם החי ולא את ההיסטוריה שכבר יושבת שם

29. מי מאשר מה

הרשימה הזו היא התשובה ל"האם אני יכול פשוט לעשות את זה". ברירת המחדל: כן, ואת היוצאים מן הכלל צריך להכיר בשמם.

מה מפתח עושה לבד, בלי לשאול

מה דורש אישור, ומי נותן אותו

הפעולה מי מאשר למה
מיזוג ל‑dev ביקורת עמית + ‏CI ירוק הזרימה הרגילה
קידום devmain שניר main הוא פרודקשן. אין cherry-pick של חלק מ‑dev — או הכל, או כלום (כלל 10)
terraform apply שניר הענן הוא כספת. ‏plan מותר לכולם, ‏apply לא
כל דבר שנוצר בתשלום ב‑GCP שניר, לפני היצירה כלל 9: קודם אומדן ב‑me-west1, ואז עוצרים לאישור. גם ג'וב Scheduler אחד נוסף הוא ~$0.10 לסביבה לחודש, וגם זה נאמר במספר ולא ב"זניח"
מחיקת דאטה או תשתית שניר, מפורשות כלל 8. אין "מחקתי כדי לנקות"
‏DDL ידני על המסד אסור לחלוטין כלל ה‑DDL: שינוי מבנה עובר רק דרך migration בקוד (פרק 7)
שינוי AGENTS.md, ‏DECISIONS.md, ‏docs/conventions/ שניר אלה כללי המשחק
שינוי החוזה (packages/contracts-openapi/) שניר (CODEOWNERS) החוזה קודם לקוד, ולכן הוא לא משתנה בשקט
שינוי infra/, ‏packages/shared-auth/, ‏packages/shared-db/ שניר (CODEOWNERS) תשתית, זהות וסכימה
הוספת כלי צד‑שלישי עם גישה לקוד שניר, מראש הכלים המאושרים הם אלה שרצים ב‑CI: ‏Semgrep, ‏Trivy, ‏gitleaks
השתקת סריקת אבטחה או בדיקת CI דורש טריאז' מתועד בייסליין האבטחה (נספח ד׳)
פתיחת לקוח חדש בפרוד שניר אשף בקונסולה, לא קוד. פרק 20
הדלקת דגל פיצ'ר ללקוח החלטה מוצרית — שניר הקוד מוזג ל‑dev ביום שהוא נגמר; מי רואה אותו זו החלטה נפרדת (כלל 10)

שני דברים שאסור לעשות ל‑PR, בלי קשר לאישורים

אין להוסיף מבקרים אנושיים ל‑PR — לא ב‑--reviewer ולא ב‑API. בקשת ביקורת הופכת אדם למשתתף בשרשור, וגיטהאב מתחיל לשלוח לו כל בדיקה אדומה וכל הרצה חוזרת, לנצח. ‏.github/CODEOWNERS כבר מכסה את main. הסלמה נעשית בהודעה לשניר (כלל 11).

main בשלב הבנייה מקודם בידי אדם אחד. ‏CODEOWNERS מצמצם היום את הבעלות לשניר בלבד, וה‑ruleset מרשה ל‑Repository admin לקדם בלי אישור. זו החלטה מכוונת של שלב הבנייה, והדרך להחזיר אכיפת שני‑אנשים מתועדת בתוך CODEOWNERS עצמו תחת ‏RESTORE AT HANDOVER — שלוש פעולות ב‑PR אחד.

30. משימות פתיחה מומלצות — הספרינט הראשון

הרשימה הזו נכתבה כדי שהספרינט הראשון לא יתחיל בשאלה "במה מתחילים". כל משימה כאן היא מצב קיים מוכר, לא באג שנפל בין הכיסאות: הן מסודרות לפי סיכון, וכל אחת אומרת מה המצב היום, מה חסר, ואיך יודעים שנגמר.

‏1. ‏codegen ל‑packages/api-client — הפער המסוכן ביותר

המצב היום: ‏8,016 שורות טיפוסים כתובים ביד ב‑packages/api-client/src/index.ts, שמשקפים את packages/contracts-openapi/openapi.yaml. יש שומר סחף שרץ בכל PR ‏(src/__tests__/contract-drift.test.ts): הוא טוען את החוזה, מוציא את הטיפוסים דרך ה‑TS compiler API ומשווה 114 סכימות — שדה חסר, שדה מיותר, ‏required מול optional ו‑nullable. כל חריגה דורשת נימוק כתוב בקוד, וחריגה שהתיישנה מכשילה את הבנייה עד שמוחקים אותה, כדי שרשימת ההיתר לא תהפוך לשטיח.

מה שעוד פתוח: הטיפוסים עצמם נכתבים ביד. ‏endpoint שמשנה שדה דורש שני עדכונים במקומות שונים, והשומר תופס את הפער אחרי שהוא נוצר במקום למנוע אותו.

מה לעשות: להנחית codegen מ‑openapi.yaml לטיפוסים, ולהשאיר את השכבה הכתובה ביד רק למה שאינה סכימה (ה‑client עצמו, ה‑ApiError, שמות הפונקציות). זה שינוי ב‑build של כל הריפו, ולכן הוא משימה מתוכננת ולא תיקון אגב.

איך יודעים שנגמר:packages/api-client נבנה מהחוזה, שומר הסחף נשאר בירוק ומאבד את הצורך ברשימת היתר לסכימות, ושינוי שדה בחוזה שובר קומפילציה לפני שמישהו מריץ טסט.

‏2. אימות אוטומטי לגלגול קונפיג השער

המצב היום:route-contract-drift מוודא שהראוטים בקוד וה‑YAML של השער מסכימים. אף בדיקה לא יודעת אם הקונפיג גולגל לשער, וזה בדיוק המקרה שנראה כמו באג באפליקציה: 404 לפני שהבקשה מגיעה לשירות.

מה לעשות: שלב אחרי הפריסה שמריץ את אימות ה‑HTTP על הנתיבים החדשים בדיף — כל נתיב חייב לענות 401 או 405, לעולם לא 404 — ומכשיל את הבנייה אחרת.

איך יודעים שנגמר: ‏PR שמוסיף נתיב לשער בלי גלגול נהיה אדום, ולא "עובד עד שמישהו יפתח את המסך".

‏3. איחוד חמש מראות ה‑Prisma לחבילה אחת

המצב היום: הסכימה הקנונית ב‑packages/shared-db, ולה מראות מסונכרנות; שומר CI מכשיל PR שבו מראה נבדלה, ו‑scripts/ci/sync-schema-copies.sh מסנכרן.

מה לעשות: לצרוך את החבילה במקום להעתיק אותה. לפני שנוסף שירות שישי שקורא למסד — כל מראה נוספת מכפילה את עלות האיחוד.

איך יודעים שנגמר: קיים עותק אחד של schema.prisma בריפו, ושומר הסנכרון נמחק כי אין מה לסנכרן.

‏4. הכרעה על התראות הפיקוח המורשתיות

המצב היום:supervisorAlertsCollection יובא פעם אחת (146 שורות) בלי hook חי, ולכן התראת פיקוח שנוצרה במערכת הישנה אחרי הייבוא לא מגיעה אלינו.

מה לעשות: אחת משתיים, והכרעה מתועדת: להוסיף hook חי, או להכריע שהמסך החדש הוא מקור האמת מכאן והלאה ולסמן את הייבוא כחד‑פעמי בכוונה.

איך יודעים שנגמר:DECISIONS.md מחזיק את ההכרעה, והתיעוד לא מתאר שני מקורות אמת בלי לומר מי מנצח.

‏5. חיבור מד האחסון

המצב היום: ה‑provenance של האחסון מדווח unpriced, והמסך לא מציג מספר. מהות ההחלטה נכונה — לא להציג ניחוש — אבל היא זמנית.

מה לעשות: ג'וב לילי שמודד prefix בדלי וכותב מונה STORAGE_BYTES.

איך יודעים שנגמר: ה‑provenance מדווח measured, והמסך מציג מספר עם מקור.

‏6. פרוד ל‑alerts-engine ול‑ai-agents

המצב היום: שניהם חיים ב‑dev, כולל צרכן ה‑pull של Pub/Sub עם ‏min-instances=1 (מנוי pull לא נשאב ב‑scale-to-zero — זה לא בזבוז, זה תנאי).

מה לעשות: פריסה לפרוד עם אותו קונפיג, ואימות שה‑DLQ ריק אחרי סבב אחד.

איך יודעים שנגמר: התראה שמונעת אירוע ב‑dev נדלקת גם בפרוד, ומגיעה ליעד ההתראה בפועל.

‏7. שתי משימות תפעול שדורשות סוד קיים

שתיהן דורשות פעולה של שניר בקונסולה, ולכן הן משימות של תיאום ולא של קוד.


נספחים — תוכן מלא מוטמע


נספח א׳: הארכיטקטורה המלאה

מקור: docs/ARCHITECTURE-COMPLETE.md — משוכפל כאן במלואו כדי שהמסמך יעמוד בפני עצמו.

Sentrix — מסמך הארכיטקטורה המלא

נכתב 24.7.2026, ריצת הסיום. מיועד לכל אחד: מנהל, איש מכירות, מפתח חדש, או שניר בעוד חצי שנה. ההנחה: הקורא לא נגע מעולם ב-Google Cloud. כל מושג מוסבר, לכל רכיב יש אנלוגיה. הגרסה הגרפית (אותו תוכן + 4 דיאגרמות SVG, RTL) יושבת בדרייב: חומרים מוויקס/sentrix-status/sentrix-architecture-complete-2026-07-24.html. מסמכי עומק: PROJECT-MAP (התכנון), DECISIONS (ההחלטות), WORK-PLAN (השלבים), TOUR (סיור מנהלים).


1. מה בנינו, בשפה של בן אדם

ללקוחות שלנו יש ציוד בשטח: משאיות אשפה עם מצלמות ומחשב קטן, תחנות שקילה עם משקל גשר, מצלמות אבטחה על עמודים, וחיישנים על מכולות. כל הציוד הזה שולח כל היום דיווחים קטנים: "הרמתי פח ברחוב הרצל", "משאית 12-345-67 שקלה 8 טון", "זיהיתי אדם בגדר הדרומית".

Sentrix היא המערכת שקולטת את כל הדיווחים האלה, שומרת אותם מסודר, ומציגה אותם לכל לקוח בדשבורד משלו: הקבלן רואה את הצי שלו, הרשות רואה את הניקיון בעיר, קצין הביטחון רואה את המצלמות שלו. כל לקוח רואה רק את שלו, בכתובת משלו, עם הלוגו שלו.

עד היום זה רץ על Wix (שלושה אתרים שנבנו לאורך שנים). עכשיו זה נבנה מחדש על Google Cloud, הענן של גוגל, בישראל (region תל אביב, me-west1). היתרון: מערכת אחת מסודרת במקום שלוש, אבטחה אמיתית, יכולת לגדול פי מאה בלי לבנות מחדש, וכל שורת קוד מתועדת ב-GitHub.

עיקרון הברזל של המעבר: הציוד בשטח לא מרגיש כלום. 300+ המשאיות ומאות המצלמות ממשיכות לשדר לאותן כתובות בדיוק (35 נקודות קליטה ששוחזרו אחת-לאחת מ-Wix). ביום המעבר מפנים אותן לשרת החדש, והן לא יודעות שמשהו השתנה.


2. מילון: כל רכיב ענן שאנחנו משתמשים בו, בשפה פשוטה

רכיב מה הוא בפועל האנלוגיה
Cloud Run מריץ את התוכנות שלנו (השרתים והדשבורדים) בלי שננהל מחשב. גדל כשיש עומס, נכבה כשאין שכירת אולם אירועים לפי שעה: פתוח כשיש אורחים, לא משלמים כשהוא ריק
Cloud SQL (PostgreSQL) בסיס הנתונים: טבלאות מסודרות של כל המידע. אצלנו instance בשם sentrix-pg בכל סביבה, בלי כתובת אינטרנט ציבורית ארון תיוק ענק בחדר נעול, שרק העובדים שלנו מחזיקים מפתח אליו
Pub/Sub לוח מודעות פנימי: שירות אחד מפרסם "קרה אירוע", וכמה שירותים קוראים אותו במקביל בלי להפריע זה לזה קבוצת וואטסאפ של אירועים: הקולט מפרסם פעם אחת, כל המנויים מקבלים
BigQuery (אגם הנתונים) מחסן היסטוריה: כל אירוע שאי פעם קרה, זול לאחסון, מהיר לחיתוכים ("כמה טון בשנה?") הארכיון העירוני: לא ניגשים אליו כל יום, אבל הכל שמור ואפשר לחפש בו בשניות
Cloud Storage אחסון קבצים: תמונות מהמשאיות, snapshots מהמצלמות, דוחות PDF. וידאו בכוונה לא אצלנו (נשאר ב-NVR בשטח) מחסן קרטונים ממוספרים; בטבלה שומרים רק את מספר הקרטון, לא את התוכן
Secret Manager הכספת: סיסמאות, מפתחות API וטוקנים. הקוד מקבל אותם בזמן ריצה, הם לא כתובים בשום קובץ כספת בנק: כל שירות מקבל גישה רק לתאים שלו
API Gateway הדלת הקדמית ל-API: כל בקשה עוברת בה, היא בודקת תעודת זהות (טוקן) לפני שמעבירה פנימה שומר בכניסה לבניין: בלי תג עובד לא נכנסים
Load Balancer + Cloud Armor הכתובת הציבורית (app.mis-26.com) + חומת אש חכמה שחוסמת התקפות, בוטים וסריקות דלת הכניסה הראשית של הקניון, עם מאבטח שמזהה מי בא לעשות בעיות
Identity Platform מערכת ההזדהות: אימייל+סיסמה, Google, Apple, ואימות דו-שלבי (MFA). מנוהלת דרך מסכי Firebase משרד הפנים של המערכת: מנפיק תעודות זהות ובודק אותן
Cloud Build פס הייצור: כל דחיפת קוד מפעילה בנייה, בדיקות ופריסה אוטומטית מכונת מטבעות: מכניסים קוד, יוצא שירות רץ. אותו תהליך בדיוק בכל פעם
Artifact Registry מדף האריזות: שומר את גרסאות התוכנה הארוזות (Docker images) שכבר נבנו מחסן מוצרים מוגמרים, כל גרסה עם תאריך ומספר
Terraform כל התשתית כתובה כקוד ב-infra/terraform/. אין "קליק נסתר" בקונסולה שאף אחד לא זוכר מתכון כתוב: אפשר לבשל את כל הענן מחדש מאפס, זהה בדיוק
Bastion מחשב זעיר (e2-micro, sentrix-bastion) שדרכו מתחברים בבטחה ל-DB הפרטי מהלפטופ דלת שירות אחורית עם אינטרקום: הדרך היחידה פנימה למי שמורשה
Cloud Scheduler שעון מעורר לענן: מפעיל ג'ובים בשעות קבועות (דוח יומי, בדיקת אתר לא מקוון) מזכירה שמתקשרת כל בוקר ב-6:00 להזכיר משימה
MCP פרוטוקול שמחבר סוכני AI (כמו קלוד) למערכת דרך אותם endpoints ואותן הרשאות בדיוק מתורגמן: הסוכן שואל בשפה שלו, המערכת עונה, בלי דלת אחורית

3. התמונה הגדולה — איך הכל מתחבר

(הציור המלא בגרסת ה-HTML בדרייב. כאן הגרסה הטקסטואלית.)

   שטח                        ענן (GCP me-west1)                          לקוחות
┌──────────┐   HTTPS   ┌──────────────────────────────────────┐   ┌─────────────────┐
│ משאיות    │ ────────► │ שער: LB + Cloud Armor + API Gateway   │◄──│ דפדפן/מובייל     │
│ משקלים    │  אותן    │   │ 35 נקודות קליטה (חוזה ה-Wix)       │   │ <לקוח>.app.mis-  │
│ מצלמות    │  כתובות  │   ▼                                  │   │ 26.com + Median │
│ עמודים    │  כמו     │ שירותים על Cloud Run:                 │   └─────────────────┘
│ חיישנים   │  היום    │ mis-core │ security-core │ client-app │
└──────────┘           │  report-engine │ mcp-wrapper           │
                       │   │                                   │
                       │   ▼ Pub/Sub (sentrix-ingest-events)   │
                       │   ├── mis-core (עדכון state)           │
                       │   ├── alerts-engine (התראות)           │
                       │   └── BigQuery lake (היסטוריה, ישירות) │
                       │                                       │
                       │ דאטה: Cloud SQL (mis_core,             │
                       │ security_core) · Cloud Storage        │
                       │ (תמונות/דוחות) · BigQuery (אגם+marts) │
                       │ · Secret Manager (סודות)              │
                       └──────────────────────────────────────┘

מה כל שירות עושה:

שירות תפקיד סטטוס
mis-core הליבה התפעולית: משאיות, שקילות, סריקות פחים, מכולות, משימות, נהגים, ייבוא ספקים, התאמות, ג'ובים, משתמשים ואישורם, מנוע ההתראות הראשון (פוליגון). כולל 35 נקודות הקליטה /_functions/* חי ב-dev+prod
security-core דומיין הביטחון: מצלמות, עמודים (EdgePc), מסכות יום/לילה, זיהויים (Detections), כללי התראה, אישורי צפייה. DB נפרד (security_core) לבידוד חי ב-dev+prod, 92 טסטים
client-app אפליקציית הלקוחות היחידה (Next.js): מסכי רשות, קבלן/צי לפי סגמנט, חדר בקרה ואפליקציית נהג באותו שירות; 4 שפות RTL/LTR, זיהוי לקוח וזיהוי מוצר לפי ה-Host בזמן ריצה המבנה וההכרעה: פרק צמוד ל‑2
report-engine מחולל דוחות: PDF עברית RTL + Excel, 4 שפות, לוגו MIS + לוגו לקוח קוד+טסטים; רץ כספרייה/ג'וב, טרם כשירות פרוס
mcp-wrapper חשיפת ה-API ככלים לסוכני AI (קלוד וכו') באותן הרשאות חי ב-dev+prod
alerts-engine מנוע התראות כשירות נפרד: צרכן pull של Pub/Sub, שער ישימות לפי סוג רכב (fail-closed), כללי FUEL/IDLE/PRIVATE_WORK/CARGO/P2P + ג'ובים מתוזמנים חי ב-dev (min-instances=1, ‏E2E מוכח ממכשיר עד פעמון); פרוד — טרם נפרס
ai-agents שער LLM אגנוסטי (Claude/OpenAI): ניתוב מודלים, caching, מדידת עלות פר-לקוח חי ב-dev
notifications דיספאץ' התראות לערוצים, על topic ‏sentrix-notifications קוד+טסטים, פס פריסה קיים
exchange (אפליקציה) בורסות פסולת והובלה — מרקטפלייס בין לקוחות חי ב-dev: ‏exchange-waste / exchange-haulage.app-dev.mis-26.com
client-app (אפליקציה) אפליקציית הלקוח היחידה: קוקפיט קבלנים וצי, קוקפיט רשויות, חדר בקרה ו-PWA לנהג — שירות אחד, והסגמנט של הטננט בוחר את סט המסכים בזמן ריצה חי ב-dev+prod; ‏default_service של ה-LB, כלומר כל ‏<לקוח>.app.mis-26.com
אפליקציות נוספות admin-console (אדמין: תור אישורים, מודולים ללקוח), partner-portal (תיעוד לאינטגרטורים) פרוסות ב-dev+prod

4. זהות, הרשמה ואישור משתמשים

הבעיה שפתרנו: בוויקס כל לקוח קיבל "מייל פיקטיבי" משותף, ולא באמת ידענו מי נכנס. עכשיו (החלטה D25) כל משתמש נרשם עם המייל האמיתי שלו, מאמת אותו, ומחכה לאישור שלנו.

הזרימה, צעד אחרי צעד:

נרשם עם מייל אמיתי ──► מקבל לינק אימות למייל ──► נכנס למצב PENDING
        │                                            │
        ▼                                            ▼
(או Google / Apple)                     רואה מסך "ממתין לאישור" בלבד.
                                        ה-API מחזיר לו 403 על הכל.
                                                     │
                              אנחנו מאשרים ב-admin-console (+בחירת role)
                                                     │
                                                     ▼
                                    ACTIVE ──► רואה רק את ה-tenant שלו,
                                    לפי ה-role שלו. אפשר גם לחסום (DISABLED)

5. זרימת הדאטה מקצה לקצה

מה קורה כשמשאית מרימה פח:

  1. המכשיר משדר — יחידת הקצה על המשאית שולחת POST לאותה כתובת שהיא מכירה מ-Wix (למשל /_functions/post_ramzor), עם אותו header הזדהות ישן. שום שינוי בקצה.
  2. השער בודק — Cloud Armor מסנן התקפות, ה-Gateway מאמת את הבקשה ומעביר ל-mis-core.
  3. mis-core קולט — מודול ה-ingest (35 endpoints, שוחזרו 1:1 כולל צורות השגיאה) מזהה לאיזה לקוח האירוע שייך (resolver דיירים), מנרמל, וכותב שורה לטבלה הנכונה (ScanEvent / WeighTicket / SecurityEvent / StationVisit...).
  4. האירוע מתפרסם — hook מפרסם את האירוע ל-Pub/Sub (topic sentrix-ingest-events).
  5. שלושה קוראים במקביל:
    • alerts-engine (subscription) — מריץ כללים: פוליגון-חריגה, אתר לא מקוון וכו'. כלל שנדלק = שורת Alert, שמופיעה בפעמון בדשבורד.
    • lake-sink (BigQuery subscription) — מוסיף את האירוע לאגם ההיסטורי, בלי שום קוד.
    • עתידי: כל שירות חדש שירצה להאזין (סוכני AI, מנוע אוטומציות) פשוט נרשם ל-topic.
  6. הדשבורד מציג — הקבלן פותח את הקוקפיט; הדשבורד שואל את mis-core דרך השער (GET /trucks, /alerts), ומקבל רק את הנתונים של ה-tenant שלו.
  7. דוחות והיסטוריה — report-engine מרנדר PDF/Excel מנתונים מוכנים (ReportRow, ובהמשך marts מהאגם); סוכני AI שואלים דרך mcp-wrapper באותן הרשאות.

הפרדת חם/קר (העיקרון שכל התכנון בנוי עליו): Cloud SQL מחזיק את ההווה (מה שהדשבורד שואל כל שנייה), BigQuery מחזיק את העבר (כל מה שאי פעם קרה, זול, ערוך לחיתוכים). תמונות ב-Cloud Storage עם reference בטבלה; וידאו נשאר ב-NVR בשטח ונצפה דרך dvr לפי דרישה (החלטה D5). ה-DLQ (sentrix-ingest-events-dlq) תופס הודעות שנכשלו, כדי ששום אירוע לא ילך לאיבוד בשקט.


6. סביבות, ענפים ופס הייצור (CI/CD)

שתי סביבות מבודדות לגמרי, כל אחת פרויקט GCP משלה עם DB, סודות ורשת משלה:

dev (mis26-dev) prod (mis26-prod)
ייעוד פיתוח וניסויים. שוברים פה לקוחות אמיתיים. לא נוגעים ידנית
ענף git dev main (מוגן, רק דרך PR)
כתובת app-dev.mis-26.com (IP ‏34.160.36.137) app.mis-26.com (IP ‏8.233.86.186)
DB sentrix-pg (db-g1-small, ‏Private IP ‏10.17.0.3) sentrix-pg (זהה, מוקשח)

הזרימה של שינוי קוד:

feature branch ──PR──► dev ──(אוטומטית)──► פריסה ל-mis26-dev ──בדיקה──►
PR ‏dev→main (סריקות אבטחה חוסמות + review) ──מיזוג──► פריסה ל-mis26-prod

7. מסד הנתונים — כל טבלה, למה היא קיימת, ומה גר בה

שני מסדים על אותו instance (בידוד לוגי מלא): mis_core (הליבה התפעולית) ו-security_core (ביטחון). הסכמות: packages/shared-db/prisma/schema.prisma (זהה ל-mis-core) ו- services/security-core/prisma/schema.prisma. כל טבלה נושאת tenantId — אין שורה בלי שיוך ללקוח. "סדר גודל" = צפי שורות בעומס של היום (300 משאיות, מאות מצלמות); הטיפול בטבלאות שגדלות למיליונים מפורט בנספח ז׳.

עדכון 26.7.2026 — טבלאות שנוספו עם מודולי המוצר החדשים (כולן עם tenantId):RoutePlan (+עמודת geometry — תוואי OSRM/קו-ישר) ו-Mission.route — מנוע המסלולים · ‏Station — מרשם 298 התחנות הארצי עם geofence ‏PostGIS · ‏CargoEvent (מפורטש חודשית) — משפחת CARGO · אירועי דלק + ‏FuelSite — משפחת FUEL · ‏P2pTicket / ‏P2pRateCard / ‏ChargeLine — הוכחה-לתשלום (עבודות עפר) · ‏BillingLine — הנהלת חשבונות ודפי חשבון · ‏TruckModuleUsage — מנייה פר (משאית × מודול × חודש), הבסיס לחיוב פר-משאית · ‏MondayLeadPush — תור לידים עמיד. הסכמה הקנונית נשארת ב-packages/shared-db, עם ארבע מראות byte-identical (‏mis-core, ‏alerts-engine, ‏notifications, ‏ai-agents) שטסט סנכרון שומר עליהן.

7.1 ‏mis_core — לקוחות, אנשים והרשאות

טבלה בשביל מה עמודות מפתח סדר גודל
Tenant הלקוח. גבול הבידוד: subdomain, לוגו, מותג, אזור, ורמת בידוד (SHARED או DB נפרד) slug (→‏.app.mis-26.com), name, logoUrl, customDomain, region, isolation, databaseSecret, brandId עשרות
Account תת-ארגון בתוך לקוח (כל רשות תחת mgroup). לקוח ישיר = account אחד tenantId, name, slug עשרות-מאות
User משתמש אנושי. כולל מנגנון האישור (D25) email, role (ADMIN/MANAGER/VIEWER), status (PENDING/ACTIVE/DISABLED), statusChangedBy/At, permissions (Json), preferences (Json) מאות-אלפים
Brand מותג white-label: ‏mis, ‏mgroup key, name, logoUrl, primaryColor, marketingDomain בודדים
Module קטלוג המוצרים הנמכרים (WEIGHING, VIDEO, NAVIGATION, P2P, משפחת CARGO...) עם מחיר מחירון; מקור-האמת: packages/catalog + מראת mis-core (טסט parity) key, name, monthlyPrice, currency, vehicleTypes, active 37
TenantModule מה הלקוח קנה: המתג + שורת החיוב החודשית tenantId, moduleId, enabled, status, priceOverride, config עשרות-מאות

7.2 ‏mis_core — צי ואירועי שטח

טבלה בשביל מה עמודות מפתח סדר גודל
Truck רישום המשאיות: מי קיימת, של מי, מאיזה סוג externalId (מזהה ה-NVR), plate, nickname, kind, contractorId, remoteAccessId (AnyDesk) מאות
TruckEvent אירועי משאית גולמיים: טלמטריה, GPS, סטטוסים. append-only truckId, type, payload (Json), occurredAt הגדולה במערכת — מיליונים בשנה כשהטלמטריה תחובר. partitioning חובה (SCALE-PLAN)
ScanEvent הרמת פח / סריקה — "רמזור" הישן. כל הרמה: אילו פחים, צבע, כמה, איפה, תמונות licensePlate, scannedTags (ארוקו), binType/2, binCount/2, weightKg, region, street, lat/lng, images, scannedAt ~20 אלף ביום ← ‏7 מיליון+ בשנה. במורשת: עשרות מיליונים
TruckDailySummary שורת הסיכום היומית פר משאית — מה שכל הדשבורדים מציגים truckId, day, totalBins, binsPerHour, workMinutes, mileageKm, netWeightKg, binsByType (Json), statuses (Json) ‏1 למשאית ליום — ‏~110 אלף בשנה
Driver / Mission / Route / BinPlan נהגים, משימות (כולל שבועיות + תמונות לפני/אחרי), מסלולים בנויים ותוכניות פינוי driverId, status, isWeekly, route (Json), stops (Json), plan (Json) מאות-אלפים
WorkZone / WorkPlan / ContractorInput אזורי עבודה (פוליגונים), תוכניות עבודה של פיקוח טיאוט, וקלטי קבלן (משמרות, קנסות) polygon (Json), plan+polygons (Json), isActive, payload (Json) עשרות-מאות

7.3 ‏mis_core — שקילה ותחנות

טבלה בשביל מה עמודות מפתח סדר גודל
WeighSite תחנת שקילה / מאזני גשר (חולון = 270270270), כולל heartbeat אונליין/אופליין siteNumber, name, lat/lng, status, lastHeartbeatAt, contractExpiresAt עשרות
WeighTicket תעודת השקילה — הרשומה העסקית המרכזית. מאחדת את priority (התחנות שלנו) + קבצי ספקים direction (IN/OUT), source (STATION/SUPPLIER_IMPORT), misNumber, licensePlate, grossKg/tareKg/netKg, wasteTypeId, contractorId, destinationId, weighedAt, exitAt, images, raw ~1,500 ביום ← ‏550 אלף בשנה; מורשת: מיליונים
StationVisit מי בפנים עכשיו: משאית שנכנסה וטרם שקלה החוצה. מזין את התראת "מעל שעתיים בפנים" siteNumber, misNumber, licensePlate, enteredAt, clearedAt מאות פתוחות; מצטבר כמו tickets
WasteType / Contractor / Destination טבלאות מאסטר: סוגי פסולת, קבלנים מובילים, יעדים (מטמנה/טיפול/מקור, כולל כינויים) code+name / name+externalRef / name+kind+aliases עשרות פר לקוח
Container / BinTag / ContainerEvent מכולות וכלי אצירה, תגי ארוקו שעליהם, ודגימות חיישן (מיקום+סוללה מ-Digital Matter) externalId, sensorDeviceId; tagNumber; lat/lng, batteryPct, recordedAt מאות; אירועים: אלפים ביום

7.4 ‏mis_core — התראות, דוחות, טפסים

טבלה בשביל מה עמודות מפתח סדר גודל
AlertRule כלל התראה כקונפיגורציה (לא קוד): פוליגון-חריגה עם חלון שעות, dedupe type, config (Json: polygon/schedule/mode), severity, dedupeMinutes, enabled עשרות פר לקוח
Alert ההתראה עצמה: מה קרה, ממי, מי אישר. מוזנת מהג'ובים ומהמנוע; הפעמון בדשבורד קורא מכאן kind, ruleId, severity, status (OPEN/ACK/RESOLVED), refId, truckId, dedupeKey, firedAt, ackedBy/At עשרות-מאות ביום
ReportRow שורת אגרגט מוכנה פר (לקוח, דוח, יום) — ה-report-engine מרנדר ממנה, לא מחשב מחדש report, day, payload (Json) ‏1 פר דוח פר יום
FormSubmission / PaymentRecord טפסי בקשות (פסולת בניין וכו') + רשומות תשלום Grow/Meshulam שמגיעות ב-webhook formNumber, status, pdfUrl; provider, reference (אסמכתא), payload מאות-אלפים
SecurityEvent אירועי VCA/ANPR שנדחפים מה-NVR דרך נקודות הקליטה של main (נפרד מ-security-core) channelName, eventType, plate, vehicleBrand/Color, startAt/endAt, images אלפים-עשרות אלפים ביום

7.5 ‏security_core — ביטחון (DB נפרד)

טבלה בשביל מה עמודות מפתח סדר גודל
EdgePc מחשב הקצה על העמוד (מריץ YOLO): ספים, סטטיסטיקות, heartbeat externalId, siteName, settings (Json: ספי יום/לילה), lastStats, status עשרות
Camera רישום המצלמות: איפה, של מי, LPR או לא, תמונה אחרונה externalId, edgePcId, name, lat/lng, rtspPath (בלי סיסמאות!), isLpr, latestPicture (Json), status מאות
CameraMask המסכות מה-YOLO labeler: אזורי זיהוי פר מצלמה, פרופיל יום/לילה, עם version לזיהוי drift cameraId, kind (DAY/NIGHT), polygons (Json), version ‏2 פר מצלמה
CameraRtspError יומן תקלות סטרימינג שה-watchdog בקצה דוחף cameraId, errorCode, occurredAt, resolvedAt אלפים
Detection הזיהוי החי: מה המצלמה ראתה (תגיות, צבעים, ספירות, לוחית), עם הפניות למדיה. חלון חם בלבד — ההיסטוריה עוברת לאגם cameraId, label, tags[], colors[], classCount (Json), licensePlate, recordId, media (Json refs), detectedAt עשרות אלפים ביום בפריסה מלאה. מורשת: עשרות מיליונים. partitioning + ייצוא לאגם (SCALE-PLAN)
AlertRule (security) כללי התראה על זיהויים (מחליף את alertLogicCollection): אילו תגיות, אילו מצלמות, באילו שעות, כמה name, config (Json), severity, enabled עשרות
Alert (security) התראת ביטחון: מי קיבל, באיזה ערוץ (פופאפ/פעמון/מייל), עם dedupe ruleId, detectionId, targetUser, delivery (Json), dedupeKey, firedAt, status מאות ביום
Acknowledgment מי אישר איזו התראה ומתי (audit) alertId, ackedBy, ackedAt, note כמו Alerts

7.6 האגם (BigQuery) — datasets ‏sentrix_lake ו-sentrix_marts

טבלה בשביל מה מבנה
weigh_tickets_history כל שקילה אי פעם. מקור לדוחות חודשיים והתאמות ספקים partition יומי על weighed_at, clustering לפי tenant_id+site_number, חובה לסנן לפי תאריך (require_partition_filter)
detections_history כל זיהוי אי פעם. מקור לתחקורים וניתוחי עומס partition יומי על start_at, clustering לפי tenant_id+event_type
truck_events_history ארכיון TruckEvent — מזין את מודלי הדלק והניצולת ואת אימות ניתוק הפרטישנים partition יומי, clustering לפי tenant_id
marts אגרגטים ומודלים מוכנים: ‏weigh_monthly, ‏fuel_anomalies_daily (חשד לאובדן דלק), ‏fill_predictions (תחזית מילוי), ‏fleet_utilization_daily. הדוחות והסוכנים קוראים מפה, לא מה-raw scheduled queries יומיים; הסוכנים מוגבלים ל-marts בלבד

8. כל הכתובות והלינקים

המוצר

מה כתובת
דשבורד dev (לקוח דמו חולון, יש דאטה) https://holon.app-dev.mis-26.com — משתמש demo@mis-26.com, סיסמה בסוד DEMO_USER_CREDENTIALS
דשבורד dev (לקוח ריק) https://tzvi-cohen.app-dev.mis-26.com
קדומים — חלון הראווה (מסלול אמיתי) https://kedumim.app-dev.mis-26.com — תוכנית "קדומים כתום", 36 עצירות, תוואי OSRM
בורסות (dev) https://exchange-waste.app-dev.mis-26.com · https://exchange-haulage.app-dev.mis-26.com
טננטי דמו סגמנטליים (dev) demo-.app-dev.mis-26.com — כל 11 הסגמנטים (הרשימה המלאה בפרק 0 של onboarding-he)
כניסה כללית dev / prod https://app-dev.mis-26.com · https://app.mis-26.com
‏API דרך השער (dev) https://sentrix-gw-cnz6wcy2.ew.gateway.dev — ‏/health פתוח, ‏/trucks מחזיר 401 בלי טוקן (בכוונה)
האתר השיווקי (לא נגענו) https://mis-26.com (GoDaddy→Vercel)

קוד ותיעוד

מה כתובת
הריפו https://github.com/MIS-Make-It-Simple/sentrix (ענפים dev/main)
התיעוד ‏docs/ בריפו: PROJECT-MAP, DECISIONS, WORK-PLAN, COSTS, AI-PRICING, DATA-LAKE, SECURITY-BASELINE, onboarding-he, tenancy-and-domains, COVERAGE-SWEEP-24-7, COST-PER-TENANT, SCALE-PLAN
מסמכים גרפיים לשיתוף ‏Drive ‏חומרים מוויקס/sentrix-status/ (סטטוס, ארכיטקטורה, היררכיית משתמשים)

קונסולות GCP (החלף mis26-dev ב-mis26-prod לפרוד)

משאב לינק
השירותים (Cloud Run) https://console.cloud.google.com/run?project=mis26-dev
בסיס הנתונים (Cloud SQL) https://console.cloud.google.com/sql/instances?project=mis26-dev
השער (API Gateway, dev בלבד; פרוד דרך ה-LB) https://console.cloud.google.com/api-gateway/gateway?project=mis26-dev
הדלת הקדמית (Load Balancer) https://console.cloud.google.com/net-services/loadbalancing/list/loadBalancers?project=mis26-prod
חומת האש (Cloud Armor) https://console.cloud.google.com/net-security/securitypolicies/list?project=mis26-prod
הזדהות (Identity Platform) https://console.cloud.google.com/customer-identity/providers?project=mis26-dev
הכספת (Secret Manager) https://console.cloud.google.com/security/secret-manager?project=mis26-dev
לוח המודעות (Pub/Sub) https://console.cloud.google.com/cloudpubsub/topic/list?project=mis26-dev
האגם (BigQuery) https://console.cloud.google.com/bigquery?project=mis26-dev
פס הייצור (Cloud Build, region me-west1) https://console.cloud.google.com/cloud-build/triggers;region=me-west1?project=mis26-dev
תמונות ודוחות (Cloud Storage) https://console.cloud.google.com/storage/browser?project=mis26-dev
כסף (Billing, תקציבים 50/80/100%) https://console.cloud.google.com/billing
לוגים https://console.cloud.google.com/logs/query?project=mis26-dev

סודות קיימים (שמות בלבד; הערכים בכספת, חלקם עוד ממתינים למילוי)

בשני הפרויקטים: ‏APPLE_SIWA_PRIVATE_KEY · ‏A_S_BINA_TOKEN · ‏DATABASE_URL · ‏SECURITY_CORE_DATABASE_URL · ‏DVR_CREDENTIALS · ‏DIGITAL_MATTER_CREDENTIALS · ‏GOOGLE_GEOCODING_API_KEY · ‏HTTPFUNCTIONSECURE (ה-auth הישן של הקצה) · ‏JOBS_SHARED_SECRET · ‏MONDAY_API_TOKEN · ‏ONESIGNAL_REST_KEY · ‏OPENAI_API_KEY · ‏ROUTIFIC_API_KEY · ‏SENDGRID_API_KEY (+ ב-dev בלבד: DEMO_USER_CREDENTIALS). ‏ITURAN/POINTER/HIKVISION אינם ברשימה בכוונה: פרטי ההתחברות שלהם יושבים בכספת פר-לקוח, מוצפנים ב-KMS (סעיף 9.4).


9. שיטות העבודה

9.1 שינוי קוד (הזרימה היומית)

git checkout dev && git pull            # מתחילים מ-dev מעודכן
git checkout -b feature/my-task         # ענף למשימה
# ... עובדים, קומיטים ...
git push -u origin feature/my-task
gh pr create --base dev                 # ‏PR + סריקות; מיזוג = פריסה אוטומטית ל-dev

קידום לפרוד: ‏PR מ-dev ל-main. הסריקות הן required checks; מיזוג מפעיל את טריגרי deploy-prod-*. אין דחיפה ישירה ל-main (חסום ב-ruleset). פירוט מלא למצטרף חדש: docs/onboarding-he.md + docs/conventions/.

9.2 הוספת לקוח חדש (אשף בקונסולה, בלי טרהפורם)

  1. אשף בקונסולת האדמין, חמישה צעדים: לקוח (שם, slug, סגמנט, מותג) ⇐ רשויות‑משנה ‏(Account) ⇐ מודולים שנקנו (TenantModule, כולל priceOverride) ⇐ הזמנת המשתמש הראשון (TenantInvite) ⇐ סיכום. הכל כותב ל‑mis-core.
  2. הדלת נפתחת מעצמה (4.8).<slug>.app.mis-26.com מכוסה ב‑wildcard cert, אין ‏DNS פר לקוח, ומאז מיזוג הדשבורדים ל‑apps/client-app גם אין כלל host פר לקוח: השירות הזה הוא ה‑default_service, ולכן כל label מגיע אליו והוא בוחר את סט המסכים לפי הסגמנט של הטננט. הסגמנט הוא מה שצריך להיות נכון — טננט בלי סגמנט מקבל 503 ולא מוצר מנוחש, והאשף אומר את זה במסך הסיכום.
  3. מוזמן שנכנס בכתובת שלו עם המייל שהוזמן נכנס ישר ACTIVE — ההזמנה היא האישור. תור האישורים (PENDING) נשאר למי שנרשם בלי הזמנה.
  4. דומיין vanity: alias + SAN בתעודה, שדה Tenant.customDomain — זה עדיין טרהפורם, פר דומיין ואניטי ולא פר לקוח. רשות שדורשת DB פיזי נפרד: ‏isolation=DEDICATED_DB + ‏databaseSecret.
  5. ההוראה המבצעית: פרק 26. המודל: נספח ב׳.

9.3 סודות — המדיניות

9.4 איפה יחיו קרדנצ'לס של לקוחות — הכספת הפר-לקוחית (משימת ריצת-הסיום #19)

סודות פלטפורמה (SendGrid, OpenAI...) הם גלובליים ויושבים ב-Secret Manager. אבל קרדנצ'לס של איתוראן/פוינטר הם של הלקוח, וסיסמת RTSP היא של מצלמה ספציפית. לאלה נבנית כספת credentials פר-tenant: טבלת TenantCredential (tenantId, provider, scope כמו truckId/cameraId, ciphertext) כשהערך מוצפן ב-KMS envelope encryption, נגיש רק לשירות הקונקטורים, עם audit log על כל קריאה. ברמת PII. מסך "הגדרות אינטגרציות" ללקוח מזין אותם דרך ה-admin. עד שהיא חיה — שום קרדנצ'ל לקוח לא נשמר בכלל.

9.5 כללי הזהב (AGENTS.md, מחייבים גם סוכני AI)

חוזה קודם (OpenAPI לפני קוד) · אין גישת DB גנרית · אימות בשער, הרשאות בשירות · סודות רק בכספת · כל שאילתה מסוננת tenant · ‏i18n מהיום הראשון (he/en/ar/es) · לא מוחקים כלום בלי אישור.


10. מה עוד לא סגור (בכנות)

הצעד הממשי הבא: לפתוח את הגרסה הגרפית מהדרייב מול המסמך הזה, לעבור מסך-מסך על holon.app-dev.mis-26.com, ולסמן כל אי-התאמה בין המסמך למציאות כ-issue בריפו.


נספח ב׳: טננטים, דומיינים ומוצרים

מקור: docs/tenancy-and-domains.md — משוכפל כאן במלואו כדי שהמסמך יעמוד בפני עצמו.

Tenancy, domains & products (Sentrix)

Decision (2026-07-22): separate WHO (tenant) from WHAT (products/modules). Products are never separate domains — they are modules toggled per customer.

Domains

Products = modules (not domains), each sold monthly

Every customer is a tenant

Each contractor and authority is its own tenant under the main app domain, with its own switches. mGroup is not special — it is simply a tenant that happens to hold several accounts (the authorities under it). security-core serves any tenant with the SECURITY module on, not only mGroup.

Brand (white-label / reseller)

Region

New-customer onboarding

Step-by-step, GUI-first: docs/HAND-IN-HAND-EN.md §10. What each step writes:

# Step Where Automatic?
1 Tenant row: name, slug, segment, brand, optional parent tenant wizard step 1 → POST /admin/tenants yes
2 Account rows (sub-authorities under a cluster) wizard step 2 → POST /admin/tenants/{slug}/accounts yes
3 TenantModule rows = what they bought wizard step 3, same transaction as step 1 yes
4 TenantInvite for the first administrator wizard step 4 → POST /admin/tenants/{slug}/invites the row, yes. Delivery to the customer, no — nothing emails it
5 The doorremoved 4.8.2026 nothing n/a

Step 5 used to be a load-balancer host rule in local.app_host_doors plus a terraform apply, and it was deliberately manual: a console that can apply terraform against the load balancer can take every customer offline by mistake. The step is gone because the question is gone — one client app on default_service means there is no per-customer rule for anyone to apply.

What the wizard reports instead: the slug is not a reserved label (one of the five product doors), mis-core resolves the host, and the tenant has a segment. All three are fixable from the console. Opening the URL is still not a check: a tenant with no segment answers 200 on /signin and has no screens, exactly as a label with no door used to (measured on prod, 28.7).

Accounts (sub-organizations inside a tenant)

Some tenants hold many sub-organizations. Example: the mGroup tenant has one subdomain (mgroup.app.mis-26.com), and each authority under mGroup is an Account inside that one tenant, with its own users and its own operational data. A tenant sold directly (e.g. Holon) uses a single default account.

Data isolation & dedicated databases (available today)

By default every tenant shares one database; each row carries tenantId (and accountId) and queries are always scoped, so no customer sees another's data. Some authorities require a physically separate database today — set Tenant.isolation = DEDICATED_DB and point Tenant.databaseSecret at that tenant's own DATABASE_URL secret. The app resolves the connection per tenant at request time; the schema is identical in every database. Same mechanism covers large overseas customers later.


נספח ג׳: תוכנית IAM — הרשאות מינימום

מקור: docs/IAM-PLAN.md — משוכפל כאן במלואו כדי שהמסמך יעמוד בפני עצמו.

עדכון 26.7.2026: ‏Talya צורפה — ‏talyap@mis.org.il (‏GitHub: ‏talyape) מחזיקה בדיוק את הסט של דניאל: ‏dev — ‏editor + ‏secretAccessor + ‏IAP tunnel + ‏OS Login; ‏prod — ‏viewer + ‏logging.viewer (‏terraform, ‏additive). ‏מורן מחזיקה ‏write בריפו ונמנית עם ממזגי main (שניר, מוטי, מורן); ה‑bypass הקבוע על main בוטל (bypass_actors ריק). הפירוט: ‏docs/IAM-PLAN.md ‏§9–§10 ‏+ ‏docs/evidence/2026-07-25/external-signup.md.

IAM Plan — least privilege (mapped 24.7.2026)

Who can touch what, across GCP (org 264541084239 / mis.org.il) and GitHub (MIS-Make-It-Simple/sentrix). Mapping was read-only. The target model ships in two disjoint halves:

Service-account bindings are context only — this run does not touch any SA.

2. Current state — humans

2.1 Org level (inherits into every project, including prod)

Principal Org role Notes
snir@mis.org.il roles/owner, roles/securitycenter.admin effective owner of dev+prod via inheritance
daniel@mis.org.il roles/owner effective owner of dev+prod via inheritance
MIS@smart26.org roles/owner, roles/resourcemanager.organizationAdmin shared/legacy account; also direct owner of mis26-platform
yehiamc@wix.com roles/orgpolicy.policyAdmin, roles/resourcemanager.projectCreator external (Wix-era)
domain:mis.org.il (everyone) roles/resourcemanager.projectCreator, roles/billing.creator any workspace user can create projects + billing accounts

2.2 Project level

Project Principal Role How
mis26-dev snir@mis.org.il roles/owner direct (console bootstrap)
mis26-prod admin@mis.org.il roles/owner direct — the only direct human on prod
mis26-platform (legacy, untouched) MIS@smart26.org roles/owner direct; project runs the ramzor Cloud SQL — do not touch

No other human appears in any project binding (all 23 org projects scanned).

2.3 Moran — exact current access (Snir asked explicitly)

2.4 GitHub (org MIS-Make-It-Simple, repo sentrix)

User Org role Repo role
SnirNisimMIS admin admin
Moti-MIS26 admin admin
Daniel-Gove member read
moranzeevi member write (עדכון 26.7)
talyape member read (‏GCP dev+prod הוענקו 26.7 — ראו העדכון בראש הנספח)

Branch rules today: protect-main = PR required, 1 approval, code-owner review, require_last_push_approval, checks (ci, gitleaks, semgrep, trivy), no standing bypass (bypass_actors: [], tightened 24.7). guard-dev = no delete / no force-push only — merging to dev needs no special role. Effective main-mergers today: Snir + Moti + Moran.

2.5 Service accounts (context — untouched by this run)

Per env: sa-cicd (run.admin, artifactregistry.writer, logging.logWriter) deploys via Cloud Build triggers; runtime SAs (sa-mis-core, sa-ai-agents, sa-mcp-wrapper) hold bigquery.jobUser; Google service agents as usual. Two flags for a later, separate pass (needs a runtime audit first):

3. Target model

Layer Who Gets Why
mis26-dev every developer editor + secretmanager.secretAccessor + iap.tunnelResourceAccessor + compute.osLogin "dev לכולם": full dev loop incl. secrets, bastion tunnel, DB
mis26-prod every developer viewer + logging.viewer — nothing else "prod צפייה בלבד"; humans read, sa-cicd writes
mis26-prod exactly one break-glass owner roles/owner today admin@mis.org.il; Snir picks which account stays (§5.4)
org snir@ only owner (+securitycenter.admin) shrink inherited-owner surface (§5)
GitHub main approved mergers only merge via PR + code-owner approval, no admin bypass §6 — Snir picks the names

snir@'s owner on dev and all SA bindings stay exactly as they are.

4. Applied in this PR (terraform, additive only)

infra/terraform/iam.tfgoogle_project_iam_member.human:

Apply recipe (what was run):

cd infra/terraform && terraform init
terraform plan  -var-file=dev.tfvars  -target=google_project_iam_member.human
terraform apply -var-file=dev.tfvars  -target=google_project_iam_member.human
terraform workspace select prod
terraform plan  -var-file=prod.tfvars -target=google_project_iam_member.human
terraform apply -var-file=prod.tfvars -target=google_project_iam_member.human
terraform workspace select default

שורות ה‑workspace select הן לא סדר — הן הגנה. ה‑tfvars קובע איזה פרויקט התוכנית מתארת, ה‑workspace קובע לאיזה state היא נכתבת, ואם הם לא תואמים טרהפורם ישכתב סביבה אחת לתוך השנייה. יש בדיקה בקוד — ‏infra/terraform/workspace-guard.tf מפיל את ה‑plan על צמד לא תואם, לפני שהוצעה הריסה אחת. אבל השורות למעלה מריצות -target, ותוכנית ממוקדת לא כוללת את המגן ולכן לא נבדקת. במתכון הזה ובכל -target אחר, שורת ה‑select היא עדיין הדבר היחיד שעומד בינך ובין השכתוב.

5. Removal / demotion proposals — DO NOT APPLY (Snir only, in this order)

Each step is independent and reversible (add-iam-policy-binding with the same arguments restores it). Run only after §4 grants are verified live.

5.1 Org hygiene — no dependency, safe first

# anyone-in-domain can create projects / billing accounts — close both
gcloud organizations remove-iam-policy-binding 264541084239 \
  --member='domain:mis.org.il' --role='roles/resourcemanager.projectCreator'
gcloud organizations remove-iam-policy-binding 264541084239 \
  --member='domain:mis.org.il' --role='roles/billing.creator'

5.2 External Wix account — after confirming the engagement is over

gcloud organizations remove-iam-policy-binding 264541084239 \
  --member='user:yehiamc@wix.com' --role='roles/orgpolicy.policyAdmin'
gcloud organizations remove-iam-policy-binding 264541084239 \
  --member='user:yehiamc@wix.com' --role='roles/resourcemanager.projectCreator'

5.3 Org-owner demotions — the step that actually enforces "prod view-only"

Org owners inherit owner on prod, bypassing everything. Only after daniel@'s §4 grants are verified (dev editor-grade + prod viewer):

gcloud organizations remove-iam-policy-binding 264541084239 \
  --member='user:daniel@mis.org.il' --role='roles/owner'

MIS@smart26.org (shared legacy account) — keep organizationAdmin as the org-level recovery identity, drop full owner; it stays direct owner of mis26-platform (ramzor) either way, so nothing there breaks:

gcloud organizations remove-iam-policy-binding 264541084239 \
  --member='user:MIS@smart26.org' --role='roles/owner'

5.4 Prod break-glass — Snir decides which single owner stays

Today prod's only direct owner is admin@mis.org.il (snir@ owns it via org inheritance). Option A (recommended): make snir@ the direct prod owner and retire admin@'s binding — one identity, MFA'd, auditable:

gcloud projects add-iam-policy-binding mis26-prod \
  --member='user:snir@mis.org.il' --role='roles/owner'
gcloud projects remove-iam-policy-binding mis26-prod \
  --member='user:admin@mis.org.il' --role='roles/owner'

Option B: keep admin@ as-is (documented break-glass). Do NOT run both halves of A separately — add first, verify login, then remove.

5.5 Cosmetic — purge the expired conditional binding (both envs)

for P in mis26-dev mis26-prod; do
  gcloud projects remove-iam-policy-binding $P \
    --member="serviceAccount:$(gcloud projects describe $P --format='value(projectNumber)' | xargs -I{} echo service-{}@gcp-sa-cloudbuild.iam.gserviceaccount.com)" \
    --role='roles/secretmanager.admin' \
    --condition='title=cloudbuild-connection-setup,expression=request.time < timestamp("2026-07-22T09:45:10.722Z")'
done
# prod's condition timestamp differs: 2026-07-22T22:17:03.176Z — adjust.
# Expired conditions are inert; this is hygiene, zero urgency.

Deferred (separate change, needs runtime audit): tighten the default compute SA's roles/editor on both envs. Explicitly out of scope — SA bindings frozen.

6. GitHub merge gate — proposal (Snir picks the names)

Goal: merging dev→main only by approved mergers, no self-merge, no silent admin bypass. Suggested roster — mergers: Snir, Moti, Moran; developers (push branches + PR, no main merge): Daniel, Tal.

  1. Write access so devs can actually work (read can't push a branch) — and without write, Moran's CODEOWNERS seat stays ignored:

    for U in moranzeevi Daniel-Gove talyape; do
      gh api -X PUT repos/MIS-Make-It-Simple/sentrix/collaborators/$U \
        -f permission=push; done
  2. Tighten protect-main (ruleset 19483445):

    • require_last_push_approval: true — whoever pushed last cannot be the approver, so nobody merges their own unreviewed work (GitHub already blocks approving your own PR; this closes the push-to-someone-else's-PR hole). Keep 1 required approval + code-owner review + the 4 checks.
    • bypass_actors: drop OrganizationAdmin/always (or downgrade to pull_request bypass only) — today Snir+Moti can push main directly.
    • Approved-mergers list = who holds write + CODEOWNERS approval; to pin it tighter, narrow CODEOWNERS to the merger roster (it already is).
  3. guard-dev stays as-is (direct push to dev is the intended dev flow).

Apply via: gh api -X PUT repos/MIS-Make-It-Simple/sentrix/rulesets/19483445 --input ruleset.json after Snir signs off the roster — not executed in this run (names are his call).

7. Migration order (nothing breaks)

  1. Done (this PR): additive terraform grants, both envs. SA bindings, snir@ owner, admin@ owner — untouched. CI/CD keeps deploying throughout.
  2. Snir confirms Moran's + Tal's @mis.org.il addresses → uncomment in iam.tf → apply both workspaces. Moran gains her first GCP access: dev-grade on mis26-dev, viewer on prod.
  3. GitHub write grants + protect-main tightening (§6) — after roster sign-off.
  4. Org/prod demotions (§5.1→5.4), one command at a time, verifying access after each. §5.3 only after step 2 is live for daniel@.
  5. Later, separate change: default compute SA editor tightening (§5.5 note).

Verification snapshot (24.7.2026, post-apply): see PR checks + gcloud projects get-iam-policy mis26-{dev,prod} — daniel@/moti@ hold exactly the §3 roles; no binding was removed anywhere (etag-diff = adds only).


נספח ד׳: בייסליין אבטחה

הבייסליין בקצרה: על כל PR רצות שלוש סריקות חוסמות — ‏Semgrep (SAST, כולל p/security-audit), ‏Trivy (תלויות + IaC ברמת CRITICAL/HIGH) ו‑gitleaks (סודות, על מלוא ההיסטוריה) — לצד בדיקות ה‑ci (פרק 5). ממצא חדש: ברירת המחדל היא תיקון; השתקה רק נקודתית (‏nosemgrep / ‏.trivyignore עם מזהה, נימוק ותאריך תפוגה) ומתועדת. מפתח חדש מתחיל מאפס — אין צורך להכיר את היסטוריית התיקונים.

היומן המלא — כל טריאז', כל פסיקה, החוב הפתוח ומיפוי בקרות ה‑ISO — חי ב‑docs/SECURITY-BASELINE.md. מדיניות ו‑SLA: ‏docs/SECURITY.md.


נספח ה׳: Runbook הגירה — dual-write → cutover

מקור: docs/MIGRATION-RUNBOOK.md — משוכפל כאן במלואו כדי שהמסמך יעמוד בפני עצמו.

MIGRATION RUNBOOK — dual-write → cutover

גרסה 1.0 · 20.7.2026. מלווה את שלב 6-7 ב-WORK-PLAN.md. מטרה: מעבר מ-Wix ל-GCP בלי סיכון לפרודקשן החי (300+ משאיות, מאות מצלמות), עם קריטריוני-יציאה פורמליים ו-rollback. לא "שלב מעבר" פתוח בזמן.

עקרון

המשאיות והעמודים בשטח ממשיכים לשדר בדיוק כמו היום (REST, אותו חוזה). מוסיפים כתיבה כפולה (Wix + GCP), מאמתים זהות נתונים לאורך חלון מוגדר, ורק אז מפנים את הקצה ל-GCP ומכבים את Wix.

שלב A — הכנה

שלב B — Dual-write (חלון מוגדר)

Exit criteria (כולם חייבים להתקיים לפני cutover)

  1. ≥14 יום רצופים של zero-delta על ≥99.9% מהרשומות במדגם (הפרשי עיגול/timestamp מוגדרים מראש כמותרים).
  2. כל 25 ה-endpoints מכוסים ונבדקו מול תעבורת קצה אמיתית.
  3. error rate של ה-endpoint החדש מתחת לסף מוסכם (למשל <0.1%).
  4. latency p95 של החדש ≤ של Wix.
  5. התראות (מנוע ההתראות) מפיקות את אותן התראות בשתי המערכות על אותם אירועים.
  6. sign-off של שניר + הקבלן.

שלב C — Cutover

שלב D — Rollback (אם נכשל)

שלב E — סגירה

מה לא עושים


נספח ו׳: DR Runbook — גיבויים ושחזור

מקור: docs/DR-RUNBOOK.md — משוכפל כאן במלואו כדי שהמסמך יעמוד בפני עצמו.

DR RUNBOOK — backups, restore, drills, worst cases

v1.0 · 24.7.2026. Owner: Snir. Covers both Sentrix databases (mis_core, security_core on Cloud SQL sentrix-pg, per env). Code: services/db-backup, infra/terraform/sql.tf + backup.tf. Restore requires the age private key that exists ONLY offline with Snir (password manager) — see §6.

1. The two layers

Layer What Where Survives
Internal Cloud SQL automated backups (7 kept, nightly 02:00 UTC) + PITR (7 days of WAL) GCP eu multi-region (not me-west1) zone loss, region loss, bad deploy / data mistake (PITR)
External Nightly pg_dump per DB → age-encryptedS3 (AWS) Snir's AWS account, suggested eu-central-1 losing the GCP project/account/org itself, GCP-wide incident

The external job runs 02:45 Asia/Jerusalem (Cloud Scheduler db-backup-nightly → Cloud Run job db-backup, per env). While AWS_BACKUP_CREDENTIALS is empty the run logs exactly backup target not configured and exits 0 — silence is intentional until the AWS side exists (§3).

2. Where backups live

3. First-time AWS setup (Snir, manual, one time)

Everything below is OUTSIDE GCP on purpose — separate provider, separate credentials, so no single account compromise reaches both copies.

  1. Account: use a dedicated AWS account (or a clean account in an AWS Organization) with MFA on the root user. Nothing else runs in it.

  2. Bucket: name suggestion mis26-sentrix-backups, region eu-central-1 (Frankfurt — deliberately NOT il-central-1: me-west1 is also Tel Aviv, a metro-level event must not take both copies). Keep all "block public access" settings ON. Enable versioning (an overwritten/deleted object keeps its previous version).

  3. Lifecycle (bucket → Management → Lifecycle rule, applies to all objects):

    • transition to Glacier Instant Retrieval after 30 days;
    • expire current versions after 365 days;
    • permanently delete noncurrent versions after 35 days. Retention/lifecycle is entirely S3-side — the job only ever PUTs; it cannot delete, and the GCP side keeps no S3 state.
  4. IAM user (not root): e.g. sentrix-backup-writer, programmatic access only, with this inline policy — PUT-only, single prefix, least privilege:

    {
      "Version": "2012-10-17",
      "Statement": [{
        "Sid": "SentrixBackupPutOnly",
        "Effect": "Allow",
        "Action": "s3:PutObject",
        "Resource": "arn:aws:s3:::mis26-sentrix-backups/sentrix/*"
      }]
    }

    No Get/List/Delete: a leaked key can add objects but can never read or destroy the backup history (versioning catches malicious overwrites).

  5. Wire the credentials into BOTH projects — the payload is one JSON object (all four fields required; prefix optional):

    {"accessKeyId":"<ACCESS_KEY_ID>","secretAccessKey":"<SECRET_ACCESS_KEY>","region":"eu-central-1","bucket":"mis26-sentrix-backups"}
    printf '%s' '{"accessKeyId":"...","secretAccessKey":"...","region":"eu-central-1","bucket":"mis26-sentrix-backups"}' \
      | gcloud secrets versions add AWS_BACKUP_CREDENTIALS --project=mis26-dev  --data-file=-
    # repeat with --project=mis26-prod
  6. Verify the first backup landed (no need to wait for the night):

    gcloud run jobs execute db-backup --region=me-west1 --project=mis26-dev --wait
    gcloud logging read 'resource.type="cloud_run_job" resource.labels.job_name="db-backup"' \
      --project=mis26-dev --limit=20 --format="value(textPayload)"

    Expect two uploaded s3://... lines + backup completed: 2 database(s), then the objects in the S3 console (Frankfurt). Repeat for prod. A malformed JSON payload FAILS the run on purpose (a typo must not look like "not configured").

4. Restore

Practice §5 before you ever need this section. Never restore over prod "to see if it works" — restore to a fresh instance and cut over.

4A. Internal path (bad deploy, data corruption, zone/region trouble)

4B. External path (S3 + age) — works with ZERO access to GCP

Needs: the S3 objects, the age private key (§6), any Postgres 18 host.

# 1. fetch (any machine with the AWS creds that can read — root/admin, not the PUT-only user)
aws s3 cp s3://mis26-sentrix-backups/sentrix/prod/mis_core/2026/07/mis_core-<stamp>.dump.age .

# 2. decrypt — paste the private key (AGE-SECRET-KEY-1...) into a file first
age -d -i age-backup.key -o mis_core.dump mis_core-<stamp>.dump.age

# 3. restore into an empty database
createdb mis_core
pg_restore --no-owner --no-acl -d mis_core mis_core.dump

Repeat per database. Then run Prisma migrations check + app smoke test.

5. Monthly restore drill (first business day, ~30 min, dev copy)

A backup that was never restored is a hope, not a backup.

  1. Take last night's mis_core object from S3 (dev prefix is fine).
  2. Decrypt with the offline private key (§4B) — this also proves the key itself is still retrievable from the password manager.
  3. pg_restore into a scratch DB (bastion or local Docker postgres:18).
  4. Sanity: SELECT count(*) FROM "WeighTicket"; + latest timestamp within the last day; one spot query on security_core too every quarter.
  5. Log the drill (date, object key, counts, minutes-to-restore) in docs/DECISIONS.md or the ops log; fix anything that dragged.

Also monthly: confirm S3 shows ~30 new objects/env/DB and the lifecycle is transitioning old ones (cost stays flat).

6. The age private key

7. Region-loss playbook (me-west1 down)

Impact: Cloud Run, the LB and the SQL PRIMARY are regional — down. The backups are not: eu multi-region (internal) + AWS (external).

  1. Declare: field edge keeps buffering/attempting (trucks resend; verify collector behavior); tenants notified per SLA.
  2. New instance in a surviving EU region: gcloud sql instances create sentrix-pg --region=europe-west1 ... (match sql.tf settings), then restore the latest eu backup into it (§4A works cross-region because the backup is multi-region).
  3. Stand up services there: terraform apply with a region override is the long path; the short path is gcloud run deploy of the existing images (Artifact Registry sentrix repo is regional — pull via me-west1-docker.pkg.dev if reachable, else rebuild from the repo, which is on GitHub, not in the region).
  4. Repoint DB secrets, redeploy, update DNS/LB. PITR window restarts on the new instance — expect up to ~24h data loss bounded by the last backup + whatever the edge re-sends from its buffers.
  5. When me-west1 returns: plan a controlled migration back (same §4A dance), never an automatic one.

8. Provider-exit playbook (leaving GCP entirely / GCP account lost)

The S3 dumps + the GitHub repo are the complete recovery kit. No GCP access is assumed at any step.

  1. Postgres 18 anywhere (AWS RDS, Azure, Hetzner, on-prem). Restore both DBs per §4B.
  2. Containers: every service builds from the repo (services/*/Dockerfile, plain Node 20 images) — docker build + run with env from §2 of each service README. The only GCP-isms to replace: Secret Manager reads (env vars), Pub/Sub (ingest bus), BigQuery lake, Identity Platform. That is a rebuild project measured in days, not a data-loss event: the businesses' weighing/detection history is in the dumps.
  3. Edge cut-over: trucks/poles POST to configurable hosts (see MIGRATION-RUNBOOK — same mechanism as the Wix→GCP move). Point them at the new ingest endpoint.
  4. Order: restore DBs → stand up mis-core + security-core + gateway → edge cut-over → dashboards → engines/lake later. The lake (BigQuery) is rebuildable from Cloud SQL history; it is NOT part of the critical path.

9. Ops notes


נספח ז׳: תוכנית גדילה (Scale Plan)

מקור: docs/SCALE-PLAN.md — משוכפל כאן במלואו כדי שהמסמך יעמוד בפני עצמו.

SCALE-PLAN — טבלאות האירועים במיליונים: partitioning, ‏retention, ארכוב

נכתב 24.7.2026. הרקע: בטבלאות המורשת (ramzor, אירועי עמודים) יש כבר עשרות מיליוני שורות. הטבלאות החדשות יגיעו לשם: ‏ScanEvent ‏~7M לשנה כבר בעומס של היום, ‏TruckEvent יעבור ‏100M לשנה ברגע שטלמטריה רציפה תחובר, ‏Detection בפריסת עמודים מלאה — עשרות אלפים ביום. המסמך הזה קובע איך ‏PostgreSQL נשאר מהיר וזול כשזה קורה, ומתי בדיוק פועלים.

1. העיקרון: חם קטן, קר זול, מחיקה = ניתוק פרטישן

2. מפת הטבלאות: חלון חם, ‏retention, נתיב ארכוב

טבלה עמודת הזמן חלון חם ב-SQL ארכוב הערות
TruckEvent occurredAt ‏90 יום כבר באגם דרך ה-bus לפרטש עכשיו, כשהיא קטנה — לפני חיבור הטלמטריה. הזולה ביותר לטפל בה היום
ScanEvent scannedAt ‏13 חודשים (השוואה שנה-על-שנה בדשבורד) כבר באגם הראשונה שתגיע ל-5M — לפרטש בסבב הזה
Detection ‏(security_core) detectedAt ‏90 יום ‏detections_history באגם המדיה ממילא ב-Cloud Storage — השורה קלה
SecurityEvent ‏(mis_core) startAt ‏90 יום כבר באגם אותו טיפול כמו Detection
WeighTicket weighedAt ‏25 חודשים ‏weigh_tickets_history באגם רשומה עסקית (חיוב/התאמות) — חלון נדיב יותר, נפח נמוך (‏0.5M לשנה)
ContainerEvent recordedAt ‏13 חודשים ג'וב לילי → אגם נפח קטן; פרטישן רק כשעוברים סף
StationVisit enteredAt ‏90 יום לשורות סגורות לא נדרש (נגזרת של tickets) שורות פתוחות לעולם לא נמחקות
Alert / CameraRtspError createdAt / occurredAt ‏13 חודשים / ‏90 יום ייצוא לילי אם יידרש ‏Alert נשאר נגיש לתחקור שנה
TruckDailySummary / ReportRow day ללא מחיקה (שורה ליום, זעיר) אלה הטבלאות שהדשבורד אמור לשאול במקום ה-raw

‏retention בפועל = החלטת עסק (חוזה לקוח + חוק הגנת הפרטיות, ‏D17). המספרים כאן הם ברירת המחדל הטכנית; שינוי = עדכון פרמטר בג'וב התחזוקה, לא שינוי קוד.

3. אסטרטגיית ה-partitioning (ואיך זה חי עם Prisma)

4. אסטרטגיית האינדקסים לטבלאות הגדולות

מה למה
‏btree ‏(tenantId, <time> DESC) — האינדקס המוביל בכל טבלת אירועים כל שאילתת דשבורד היא "הלקוח הזה, החלון הזה, מהחדש לישן"
‏btree ייעודי לנתיב החם השני: ‏(tenantId, truckId, occurredAt) ‏/ ‏(tenantId, cameraId, detectedAt) ‏/ ‏(tenantId, licensePlate) מסך "משאית בודדת" / "מצלמה בודדת" / חיפוש לוחית
BRIN על עמודת הזמן בטבלאות append-only ענקיות (TruckEvent) אינדקס של קילובייטים על מיליוני שורות; מצוין לסריקות טווח, כמעט חינם בכתיבה
‏partial index: ‏(tenantId, createdAt) WHERE status='OPEN' על Alert, ‏WHERE clearedAt IS NULL על StationVisit הפעמון ומסך "מי בפנים" קוראים רק את הפתוחות — אינדקס זעיר במקום סריקה
לא מוסיפים אינדקסים "ליתר ביטחון" על טבלאות ingest כל אינדקס מאט את כל ה-inserts; ‏65 אלף כתיבות ביום סופרים את זה
‏PK ‏UUIDv4 נשאר בינתיים הפיזור האקראי מנפח מעט את ה-btree; לא שווה שבירת תאימות עכשיו. ‏UUIDv7 = שיפור עתידי בקוד בלבד

5. דפוסי השאילתות שהדשבורדים מחויבים להם

  1. תמיד תחום: ‏tenantId + טווח זמן. שאילתה בלי טווח על טבלת אירועים = באג.
  2. יומי מהמוכן: מסכי "היום/אתמול/החודש" קוראים TruckDailySummary ו-ReportRow, לא סוכמים raw בזמן אמת. ה-raw הוא לתחקור ולפירוט, לא ל-KPI.
  3. עימוד keyset: המשך עמוד לפי ‏(time, id) < האחרון שהוצג — לא OFFSET, שנהיה איטי ליניארית עם העומק.
  4. היסטוריה רחוקה = אגם: מסך תחקור שחוזר יותר מהחלון החם שואל את marts/האגם דרך ה-API (אותה הרשאה), לא את SQL.

6. מתי פועלים (הטריגרים, משלים את טבלת הקיבולת ב-COSTS.md)

מדד סף פעולה
שורות בטבלת אירועים ‏> ‏5M או ‏> ‏10GB לפרטש (סעיף 7). ‏TruckEvent: לא מחכים — מפרטשים לפני חיבור הטלמטריה
‏p95 של שאילתת דשבורד ‏> ‏300ms מתמשך ‏EXPLAIN, לוודא pruning ואינדקס מוביל; לשקול cache (Redis, ‏D15)
‏Cloud SQL storage ‏> ‏60% מהדיסק לוודא שה-retention רץ; auto-grow דלוק, אבל דיסק רק גדל ב-GCP — retention הוא החיסכון האמיתי
‏CPU ‏> ‏70% מתמשך / ‏connections ‏> ‏80% לפי COSTS.md ‏tier למעלה / read replica / pooler. ‏db-g1-small של היום הוא נקודת פתיחה בלבד
‏INSERT latency ב-ingest עולה ‏p95 ‏> ‏100ms לבדוק אינדקסים עודפים, לעבור ל-batch insert בקונקטורים

7. סקיצות המיגרציה

7.1 ‏TruckEvent — לפרטש עכשיו, בזול (הטבלה עוד קטנה)

מיגרציית SQL אחת (expand-contract, בחלון שקט):

BEGIN;
ALTER TABLE "TruckEvent" RENAME TO "TruckEvent_old";
CREATE TABLE "TruckEvent" (
  LIKE "TruckEvent_old" INCLUDING DEFAULTS INCLUDING INDEXES,
  PRIMARY KEY ("id", "occurredAt")
) PARTITION BY RANGE ("occurredAt");
-- פרטישן פתיחה + הבא, ואז הג'וב החודשי ממשיך לבד
CREATE TABLE "TruckEvent_2026_07" PARTITION OF "TruckEvent"
  FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
CREATE TABLE "TruckEvent_2026_08" PARTITION OF "TruckEvent"
  FOR VALUES FROM ('2026-08-01') TO ('2026-09-01');
INSERT INTO "TruckEvent" SELECT * FROM "TruckEvent_old"; -- קטנה היום, שניות
DROP TABLE "TruckEvent_old"; -- אחרי אימות ספירות; או משאירים שבוע ליתר ביטחון
COMMIT;
CREATE INDEX ON "TruckEvent" USING brin ("occurredAt");

7.2 ‏ScanEvent כשתגיע ל-5M — פרטוש בלי להשבית (מתווה online)

כשהטבלה כבר גדולה, ‏INSERT...SELECT אחד נועל יותר מדי. עושים את זה בשלבים:

-- 1) יוצרים טבלה מפורטשת חדשה לצד הישנה (אותו מבנה, PK כולל scannedAt)
CREATE TABLE "ScanEvent_p" (LIKE "ScanEvent" INCLUDING DEFAULTS,
  PRIMARY KEY ("id","scannedAt")) PARTITION BY RANGE ("scannedAt");
-- + פרטישנים לכל חודש קיים ולחודשיים קדימה

-- 2) גב מילוי בנתחים חודשיים, מהישן לחדש (כל נתח בטרנזקציה קצרה משלו)
INSERT INTO "ScanEvent_p" SELECT * FROM "ScanEvent"
  WHERE "scannedAt" >= '2026-01-01' AND "scannedAt" < '2026-02-01';

-- 3) חלון קצר: משלימים את הזנב מאז תחילת הגב-מילוי, ומחליפים שמות
BEGIN;
LOCK TABLE "ScanEvent" IN EXCLUSIVE MODE;
INSERT INTO "ScanEvent_p" SELECT * FROM "ScanEvent" s
  WHERE NOT EXISTS (SELECT 1 FROM "ScanEvent_p" p
    WHERE p.id = s.id AND p."scannedAt" = s."scannedAt");
ALTER TABLE "ScanEvent" RENAME TO "ScanEvent_retired";
ALTER TABLE "ScanEvent_p" RENAME TO "ScanEvent";
COMMIT; -- שניות של נעילה, לא שעות

ה-ingest ממשיך לכתוב לאותו שם טבלה; Prisma לא מרגיש. ‏"ScanEvent_retired" נשארת שבוע לאימות (ספירות פר יום מול האגם) ואז DROP.

7.3 ג'וב התחזוקה החודשי (‏/jobs/partition-maintenance)

לכל טבלה מפורטשת, לפי טבלת קונפיג קטנה (table, timeColumn, hotMonths):

  1. ‏CREATE PARTITION לחודש הבא (אם חסר).
  2. ‏DETACH פרטישנים שמעבר לחלון החם → ‏אימות שהשורות קיימות באגם (ספירה מדגמית מול BigQuery באותו טווח) → ‏DROP. בלי אימות — לא מוחקים, מתריעים.
  3. כותב שורת Alert תפעולית עם הסיכום (כמה נותקו, כמה שורות, אימות עבר/נכשל).

7.4 טבלאות שלא על ה-bus (ContainerEvent, ‏Alert) — ייצוא לפני ניתוק

אין להן BigQuery subscription, אז לפני DROP: ‏ג'וב לילי מייצא את החודש הסגור לאגם (‏INSERT ל-BQ דרך ה-API בצ'אנקים, או ‏EXPORT ל-GCS ו-load). רק אחרי אישור ספירות — ניתוק. אותו קוד ג'וב, דגל exportFirst=true בקונפיג.

8. צד האגם — מה שכבר מגן עלינו ומה נשאר

9. סדר הביצוע המומלץ

  1. עכשיו (זול, מונע כאב): פרטוש TruckEvent (‏7.1) + ג'וב התחזוקה (‏7.3) + ‏BRIN.
  2. בסבב הקרוב: ‏partial indexes (‏Alert פתוחות, ‏StationVisit פתוחות); קיבוע ‏keyset pagination ב-api-client לפני שמסכי התחקור נבנים.
  3. כשה-ScanEvent חוצה 5M: מתווה 7.2 (יש התראת סף שתופסת את זה — סעיף 6).
  4. ברקע: החלטת retention עסקית/משפטית פר טבלה (D17) — עד אז ברירות המחדל כאן.

הצעד הממשי הבא: מיגרציית 7.1 (TruckEvent) + ‏endpoint ‏/jobs/partition-maintenance עם טבלת הקונפיג — ‏PR אחד, לפני שקונקטור הטלמטריה של איתוראן נדלק.


נספח ח׳: אגם הנתונים (BigQuery)

מקור: docs/DATA-LAKE.md — משוכפל כאן במלואו כדי שהמסמך יעמוד בפני עצמו.

אגם הנתונים (BigQuery)

סטטוס: חי בשתי הסביבות.sentrix_lake (כולל ingest_events, שמוזן ישירות מ-Pub/Sub ב-BigQuery subscription בשם lake-bq) ו-sentrix_marts (‏mart ראשון: weigh_monthly) פעילים ב-dev וב-prod, מנוהלים ב-Terraform. ראיות ופקודות אימות: docs/evidence/2026-07-25/infra-pack.md §2.

עדכון 26.7.2026 — שכבת המודלים חיה (infra/terraform/lake-models.tf, שתי הסביבות): שלושה scheduled queries יומיים בונים marts חדשים — ‏fuel_anomalies_daily (חשד לאובדן דלק: ירידות מפלס לא-מוסברות מול המדיאן של המשאית עצמה, סטטיסטיקה כנה בלי טענת ML), ‏fill_predictions (תחזית מילוי לפי קצב פינוי פר אזור) ו-fleet_utilization_daily (ניצולת צי). נוספה גם ‏truck_events_history. שורות סימולציה מסומנות ‏isDemo והדשבורד מציג עליהן תג "דמו". הג'וב ‏lake-anomalies-pull הופך אנומליה מה-mart להתראת FUEL_THEFT בפעמון. הראיות: docs/evidence/2026-07-26/lake-models.md.

הרעיון בשתי שורות

Cloud SQL מחזיק את ההווה — מה שהדשבורדים שואלים כל שנייה. BigQuery מחזיק את העבר — כל אירוע שאי פעם קרה, זול, ערוך לחיתוכים. מפרידים כדי שההיסטוריה לא תנפח את ה-DB התפעולי ולא תאט אותו (אותו עיקרון כמו truck_state מול truck_telemetry בשלב 1 של תוכנית העבודה).

שכבה מה גר בה מי קורא סדר גודל
Cloud SQL (חם) state נוכחי: משאיות, אתרים, טיקטים אחרונים, משתמשים דשבורדים, mis-core חודשים אחרונים
BigQuery — sentrix_lake היסטוריה גולמית append-only: שקילות, זיהויים jobs של marts בלבד שנים
BigQuery — sentrix_marts אגרגטים מוכנים: פר רשות/אתר/חודש דוחות, סוכני AI (דרך MCP) קטן

זרימת הנתונים (לפי שלב 5.2 — Pub/Sub כ-event bus)

קצה (משאיות/עמדות/מצלמות)
   │  REST כרגיל — הקצה לא משתנה
   ▼
ingestion (truck-ingest / pole-ingest)
   │  נרמול ופרסום
   ▼
Pub/Sub topic ──► subscription: mis-core        (עדכון state ב-Cloud SQL)
             ──► subscription: alerts-engine    (כללים והתראות)
             ──► BigQuery subscription          (append ל-sentrix_lake, בלי קוד)

ה-fan-out הוא הנקודה: אף שירות לא בנתיב הכתיבה של אחר. ההזנה לאגם היא subscription מסוג BigQuery — Pub/Sub כותב ישירות לטבלה, אין שירות ETL לתחזק. מדיה (תמונות/וידאו) לעולם לא עוברת בהודעות — Cloud Storage + reference בלבד (גבול קשיח מ-AGENTS.md). עד ששלב 5 יקום, אפשר להתחיל גם בלעדיו: job לילי ב-mis-core שמעתיק שורות שסגרו יום מ-Cloud SQL לאגם. שתי הדרכים כתובות בקובץ ה-Terraform.

הטבלאות באגם

שתי טבלאות פתיחה, סכמות נגזרות מ-Prisma (services/mis-core/prisma/schema.prisma):

weigh_tickets_history — מקור: מודל WeighTicket. partition יומי על weighed_at, clustering על tenant_id, site_number. דנורמליזציה אחת מכוונת: site_number נוסע עם השורה כדי שחיתוך פר-אתר לא ידרוש join.

detections_history — מקור: מודל SecurityEvent (זיהויי VCA/ANPR). partition יומי על start_at, clustering על tenant_id, event_type. בשלב 4.1 הבעלות עוברת ל-security-core; העמודות נשארות, רק הכותב מתחלף.

שתיהן עם require_partition_filter — שאילתה בלי טווח תאריכים נחסמת. זה שומר העלות קרוב לאפס גם כשמישהו טועה, ומחנך את כולם לשאול נכון מהיום הראשון. tenant_id חובה בכל שורה (חוק 5) — אין שורת אגם בלי שיוך רשות.

marts — שכבת החיתוכים

הסיפור של האגם הוא לא "לאחסן הכל", אלא לענות מהר וזול על החיתוכים שחוזרים בכל דוח ובכל שאלה לסוכן:

-- טונאז' חודשי: רשות ⨯ אתר ⨯ כיוון (הבסיס לדוח החודשי לרשות)
SELECT tenant_id, site_number,
       FORMAT_TIMESTAMP('%Y-%m', weighed_at, 'Asia/Jerusalem') AS month,
       direction, SUM(net_kg)/1000 AS tons, COUNT(*) AS tickets
FROM sentrix_lake.weigh_tickets_history
WHERE weighed_at BETWEEN @from AND @to
GROUP BY 1, 2, 3, 4;

-- השוואת ספקים לתחנה (הבסיס להתאמות compareMonth מהאתר הישן)
SELECT site_number, source, FORMAT_TIMESTAMP('%Y-%m', weighed_at) AS month,
       SUM(net_kg) AS net_kg
FROM sentrix_lake.weigh_tickets_history
WHERE weighed_at BETWEEN @from AND @to AND tenant_id = @tenant
GROUP BY 1, 2, 3;

-- עומס זיהויים פר מצלמה בחודש (תחקור ביטחון)
SELECT channel_name, event_type, COUNT(*) AS events
FROM sentrix_lake.detections_history
WHERE start_at BETWEEN @from AND @to AND tenant_id = @tenant
GROUP BY 1, 2 ORDER BY events DESC;

שאילתות כאלה רצות ב-scheduled query ומטריאליזציה שלהן נשמרת ב-sentrix_marts (דוגמה חתומה בקובץ ה-Terraform: weigh_monthly). מכאן:

מודל עלות — מספרים ריאליים

מחירון BigQuery (סדרי גודל, on-demand): אחסון פעיל ‎0.02/GBלחודש(יורדלחציאחרי90יוםבלישינוי), סריקה‎ 6.25/TB. חינם כל חודש: 10GB אחסון + 1TB סריקות.

הערכת נפח אצלנו (2KB לשורת שקילה כולל raw, ‎1.5KB לזיהוי):

זרם קצב משוער לחודש לשנה
שקילות (כל התחנות) ~1,500 ליום ~0.1GB ~1.1GB
זיהויים/סריקות (כל הרשויות) ~20,000 ליום ~0.9GB ~11GB
סה"כ ~1GB ~12GB

מסקנה: בקנה המידה הנוכחי האגם עולה בפועל 0 ש"ח, וגם בפי-10 נפח מדובר בעשרות שקלים בחודש. העלות האמיתית היחידה היא משמעת שאילתות — והיא נאכפת בקוד.

הפעלה

בוצע — האגם חי בשתי הסביבות: ‏datasets, טבלאות ההיסטוריה, ‏subscription ‏lake-bq (‏Pub/Sub כותב ישירות ל-BigQuery, בלי קוד ETL) וה-mart הראשון. הרחבת האגם מכאן = עוד טבלה או mart ב-Terraform, דרך PR רגיל.


נספח ט׳: עלות פר לקוח — המודל שהמפתח צריך

תמחור, ‏break-even והחלטות מכירה הם דיון הנהלה. מה שמופיע כאן הוא החלק שנוגע לקוד: איך המערכת מודדת צריכה, ואיזו אמת כל מספר מתיימר להיות. מי שנוגע במסכי הצריכה, במדידה או בחשבונאות — זה החוזה שלו.

הנוסחה

עלות_לקוח_בחודש = Σ(יחידות × עלות_משתנה_ליחידה)          [ישיר — מיוחס]
                + (יחידות_עומס_לקוח / יחידות_עומס_כלל) × F  [משותף — מוקצה]
                + AI(טוקנים בפועל × מחירון)                 [מיוחס]

F הוא העלות המשותפת: מה שרץ בין אם יש לקוח אחד או מאה (מסד, Redis, ‏LB, ‏bastion, ‏gateway). היא לא מיוחסת ללקוח אלא מוקצית לפי חלקו בעומס.

יחידת עומס (LU) — למה בכלל צריך מטבע

לקוח מחזיק משאיות, מצלמות, תחנות וחיישנים, ולכל אחד מהם עלות שונה. ‏LU מנרמל את כולם למטבע אחד כדי שיהיה אפשר לחלק את F. המשקלות יושבות ב‑rates.ts ‏(LU_MILLI_WEIGHTS):

נכס LU ההנמקה
משאית ‏1.0 הבסיס: אירועים, תמונות, טלמטריה
מצלמת עמוד ‏0.5 זיהויים רבים, תמונות קטנות, בלי טלמטריה רציפה
תחנת שקילה ‏2.0 תעודות + תמונות + ‏ingest רציף + התאמות
חיישן מכולה ‏0.05 דגימות בודדות ביום
משתמש דשבורד פעיל ‏0.2 קריאות API ו‑cache; ה‑AI מחויב בנפרד

המשקלות האלה הן מדיניות הקצאה, לא מדידה. הן אומרות שתחנת שקילה עולה לנו בערך פי שתיים ממשאית, וזה הכל. מי שמשנה אותן משנה את ההקצאה של כל הלקוחות ולכן הן מכוילות מול ייצוא החיוב, לא מנוחשות מחדש.

‏15 המונים, ואיפה כל אחד נכתב

טבלת UsageCounter היא בגרעין של (טננט × יום × מטריקה × feature), ולכן כתיבה חוזרת של אותו יום דורסת ולא מסכמת — זה מה שהופך את ה‑rollup לבטוח להרצה חוזרת. ‏MeterMetric מחזיק היום:

קבוצה מטריקות מי מגדיל
קליטה ותעבורה INGEST_EVENTS, API_REQUESTS, DB_ROWS_WRITTEN תפרים אוטומטיים: כתיבות Prisma, ו‑hook על כל תשובה
‏AI AI_TOKENS_IN, AI_TOKENS_OUT ‏rollup לילי מיומן AiUsage
‏gauges LOAD_UNITS, STORAGE_BYTES ‏rollup לילי (האחסון טרם מחובר — פרק 28)
פיצ'רים בתשלום REPORT_RENDERS, VIDEO_SESSIONS, ROUTE_OPTIMIZATIONS, WEIGH_CERTIFICATES הפיצ'ר עצמו, בזמן השימוש
חיוב TRUCK_MODULE_UNITS ‏rollup חודשי מ‑TruckModuleUsage — הגרעין שההצעות מתמחרות
וידאו VIDEO_EGRESS_BYTES בייטים שיוצאים מהכספת — הרגל של הווידאו שעולה כסף בפועל
פלטפורמה OPENAI_ORG_COST הג'וב שמושך את הוצאת OpenAI, על טננט הפלטפורמה

DB_ROWS_WRITTEN היא קירוב מוצהר, וזה כתוב במקום שבו היא נמדדת. מי שקורא את המסכים דרך ה‑API לא יכול לנפח את המספרים: כתיבה למונים האוטומטיים נדחית ב‑400.

כלל הפרובננס — למה כל מספר אומר מאיפה הוא

מספר בלי נייר עבודה הוא קישוט, ולכן תשובת הצריכה נושאת provenance עם ארבע עובדות נפרדות, כי אין להן אותה דרגת אמון:

העובדה ה‑source שלה למה
tokens measured הספק החזיר את הספירות, והן נשמרו פר קריאה
money list-price (ה‑worst-of של המודלים שחויבו) הספירות מדודות, אבל המחיר הוא מחירון פומבי. לא ראינו חשבונית, ולכן זה לעולם לא measured
usageCounters measured תפרים אוטומטיים
storage unpriced מד האחסון טרם מחובר, ולכן לא מוצג מספר במקום ניחוש

הסולם המלא: ‏measuredlist-priceinventoryconfigured ⟵ ‏unpriced, ובנוסף declared בפני עצמו במסך המנה של הטוקנים — "מספר שהכרזנו עליו", לא מדוד ולא מתומחר. יש טסט מראה שמכריח את טבלת התעריפים בקוד ואת ה‑tfvars להסכים (פרק 13).

F נפתרת בזמן ריצה בשלושה שלבים, בסדר הזה: measured — הסכום בפועל לחודש מייצוא החיוב; configured — מה שה‑ops הגדיר; inventory — הקבוע שבקוד. ייצוא החיוב טרם מופעל, ולכן בפועל אנחנו בשלב 2–3 וכל סכום כספי במסכים הוא הערכה מסומנת (פרק 28, ומשימה 7 בפרק 30).

שער המנה של ה‑AI, וכלל D18

הקצאת טוקנים מוקדמת (TokenAllocation) מול יומן AiUsage נותנת אחוז ורמה: ‏70% אזהרה, ‏90% התראה, ‏100% עצירה. ב‑100% ה‑AI נעצר — צ'אט מחזיר ‏429 ai_quota_exhausted עד טעינה מחדש. אין הקצאה ⇒ אין שער, ואף פעם לא עצירה.

הכלל שגובר על הכל הוא D18: המוצר עצמו לא נעצר. פיצ'ר שהוא פלט מחויב — תעודת שקילה, קודם כל — מציג צריכה אבל לעולם לא מרים באנר אזהרה ולא נחסם. תחנת שקילה שעברה את מספר התעודות שלה עדיין חייבת למסור לנהג תעודה. בחוזה זה השדה enforceable: false, והבאנר העליון מחושב מה‑worst-of של הפיצ'רים הניתנים לאכיפה בלבד.

מה שמפתח צריך תפעולית

עלויות, תקציבים וכלל אישור‑העלות (כלל 9 — אומדן ב‑me-west1 לפני יצירת כל דבר בתשלום) נמצאים בפרק 15, ומי מאשר — בפרק 29.


נספח י׳: מטריצת יכולות

מקור: docs/CAPABILITY-MATRIX.md — משוכפל כאן במלואו כדי שהמסמך יעמוד בפני עצמו.

מטריצת יכולות × סוגי משאיות — MIS26/Sentrix

הוכן 23.7.2026 לבקשת שניר: ריכוז כל היכולות שהמוצר מוכר/משווק, מיפוי לסוג משאית, הפרדה בין מה שרץ בענן (הדשבורד שלנו) למה שרץ בקצה (על המשאית), והצלבה מול מה שכבר בנוי בריפו. בסוף: (1) פערי ענן לשילוב בתוכנית העבודה; (2) "תוכנית מערכות המשאיות" — רשימת יכולות הקצה לריכוז.

עדכון סטטוס 26.7.2026 — הטבלאות למטה משקפות את מיפוי 23.7; מאז נסגרו בקוד ונפרסו ל-dev (שורה ליכולת, הראיות ב-docs/evidence/2026-07-25..26/): משפחת CARGO — ‏8 מודולים (דלת, משטחים, חבילות, ברקוד, ‏POD, צילום אספקה, פריקה, החזרות) עם מנייה פר-משאית · מודולי דלק — תיעוד פריקה, יומן שטח, אנליטיקת אובדן · ‏P2P עבודות עפר — צילום תעודה ⇐ ‏OCR אמיתי (Document AI) ⇐ שורת חיוב · מנוע מסלולים (תכנון מול ביצוע, אופטימיזציה, geometry) + מודול ‏NAVIGATION לכל סוגי הרכב, משולב במשימות ובר-אישור פר-משתמש · מנוע התראות כשירות (alerts-engine, ‏dev) עם כללי עמידה ועבודות-פרטיות מודעי-סוג-רכב · מרשם תחנות ארצי (298 תחנות, geofence אוטומטי, אירועי כניסה) · דוח רגולטורי ‏Proof-of-Service (ביוביות/הגנ"ס, ‏PDF ‏RTL) · הנהלת חשבונות (BillingLine, דפי חשבון) · וידאו — תחקור ארוך-טווח · מודלי אגם (אנומליות דלק, תחזית מילוי) ‏+ מסכי צריכה 70/90/100% · בורסות פסולת והובלה · קונקטור לידים מ-monday חי; ‏hikvision/pointer/digital-matter/svision מוכני-קרדנצ'לס · כל 11 הסגמנטים חיים עם טסט אפס-דליפה. הסטטוס החי: docs/WORK-PLAN.md.

מקורות

מקור מה נלקח ממנו
הצעת מחיר פיילוט מיכליות דלק (שמעון, 23.7.2026) מערכת בסיס + 15 מודולי AI למיכלית, כולל מחירים
הצעת מחיר פיילוט Cargo AI (יוסי, חברת הפצה, 22.7.2026) מערכת בסיס + 13 מודולי AI למשאית קרגו
דרייב Snir/mis_marketing_site/brochures-inbox/ (~40 קבצים) דפי מוצר פר סוג משאית: משאיות חכמות (פסולת), ביובית, רמסע, מנוף גזם, מנוף טמונים, טיאוט, עבודות עפר (Proof-to-Payment), TEMPO POD, שקילה, ביטחון (3 פתרונות), SuperVision
האתר השיווקי mis-26.com web_fetch מחזיר shell של JS (Next על Vercel) — נקרא מעותק הריפו בדרייב: mis_marketing_site/mis26-site/src/data/ (4 ורטיקלים, 32 עמודי מוצר)
ניתוחי legacy בריפו docs/legacy-mainmis.md, legacy-mgroupwix.md, legacy-mis-oldsite.md, legacy-missecurity.md — מה רץ היום בפועל
הריפו עצמו docs/WORK-PLAN.md, מודולים ב-services/, apps/, ingestion/, סכמות Prisma

סוגי המשאיות/הנכסים בעמודת "סוג": דחס · רמסע · מנוף גזם · מנוף טמונים/מונחים · טיאוט · ביובית · מיכלית דלק · הפצה/קרגו (POD) · עבודות עפר/צמ"ה · תחנת שקילה · עמוד/אתר ביטחון · מכולות וכלי אצירה · כולם = רוחבי לכל צי. באתר מפורסמים עוד 18 סוגים עתידיים (מערבלי בטון, מזון לבע"ח, כרייה, חקלאות, חומ"ס, מיכליות גז, אספלט, קירור, מכולות, פסולת רפואית, בע"ח, מובילי רכב, ציוד כבד, חבילות, מיכליות מים, גרירה, מיכליות חלב) — כולם ממופים לאותה ליבה + מודול ייעודי.

מקרא ריצה: ☁️ ענן (הדשבורד/שירותים שלנו) · 🚚 קצה (על המשאית/בתחנה/בעמוד) · 🔀 היברידי (קצה אוסף, ענן מעבד/מציג).


1. מערכת בסיס (Basic) — התשתית שכל המודולים נשענים עליה

יכולת סוג מקור ריצה כיסוי היום פער → שלב
4–8 מצלמות חכמות (מעטפת 360) כולם הצעות מחיר + דפי מוצר 🚚 חומרת קצה קיימת (DVR/NVR + Jetson); ענן: truckCollection.data.truckStatus (מיפוי legacy) ניהול מצלמות-רכב כ-entity בענן → שלב 3.1/5.3
וידאו LIVE + הקלטה כולם הצעות + דפי מוצר 🔀 קונקטור dvr/808gps: ingestion/connectors/src/dvr-808gps (jsession, סטטוס, URL וידאו חי) סשן LIVE בדשבורד → שלב 3.1; צינור CMS→Cloud Storage → 5.3
Playback / תחקור לאחור (3–6 חודשים) כולם דפי מוצר 🔀 legacy דרך CMS; טרם בדשבורד החדש מסך תחקור → שלב 4.2 (ביטחון) + 3 (משאיות)
GPS + מסלולי נסיעה כולם הצעות + דפי מוצר 🔀 ingest 35 נקודות ב-services/mis-core/src/modules/ingest; טלמטריה בסכמה מסך מפה חיה → שלב 3.4
קישוריות סלולרית + עדכוני תוכנה מרחוק כולם הצעות 🚚 רישום יחידות AnyDesk (‏legacy ‏jetsonOrinAnyDeskIds) מודול ניהול יחידות קצה בענן → חדש (ר' פערים #8)
פורטל ניהול + אפליקציה + אחסון ענן כולם הצעות ☁️ apps/client-app (מסכי קבלן, רשות, חדר בקרה ונהג באותו שירות) + Median wrapper (שלב 6.3) השלמת 9 מסכים → שלב 3.4
אבטחת מידע (Cyber) + הרשאות משתמשים כולם הצעות + דפי מוצר ☁️ Gateway+JWT+RBAC חיים; ‏Identity Platform עם MFA

2. תפעול ובקרה בזמן אמת

יכולת סוג מקור ריצה כיסוי היום פער → שלב
נתוני רכב: ק"מ יומי, שעות מנוע, זמן נסיעה/עמידה כולם שתי ההצעות (₪19.90) 🔀 שדות קיימים בסכמה (mileage, workTime במיפוי legacy) ווידג'ט בדשבורד → שלב 3.4
התראת עמידה מעל זמן מוגדר כולם שתי ההצעות (₪9.90) ☁️ אין מנוע התראות עדיין Alerts Engine → שלב 3.2
Geofence — יציאה מאזורים מאושרים כולם שתי ההצעות (₪19.90) ☁️ ‏legacy ‏updatePolygonExceptionAlerts (mgroup) ממופה; בריפו רק תכנון כלל פוליגון ראשון ב-Alerts Engine → שלב 3.2
התראה על עבודות פרטיות (שימוש מחוץ לשעות/מסלול) מיכלית דלק, מנוף גזם, כולם הצעת דלק + דף מנוף גזם ☁️ כלל שעות+מסלול → שלב 3.2
ניהול תקלות + סטטוס רכיבים (מצלמה/GPS/כיול/הקלטה) כולם דפי מוצר + legacy 🔀 truckStatus ממופה; ‏NVR watchdog ב-legacy מסך תקלות (תקלה חוזרת) → שלב 3.4
הרשאות/תצוגות פר תפקיד ולקוח כולם דפי מוצר + legacy ☁️ Tenancy חי (tenant_id בכל טבלה, subdomain פר לקוח) תצוגות פר-תפקיד → שלב 4.3

3. וידאו וזיהוי AI (Computer Vision)

יכולת סוג מקור ריצה כיסוי היום פער → שלב
AI/CV — ספירה וזיהוי לפי אפיון כולם שתי ההצעות (₪49.90) + דפי מוצר 🚚 הקצה מזהה; הענן קולט דרך ingest (truckDataCollection, ‏detections) — (קליטה קיימת)
ספירת פחים לפי צבע/גודל בדיוק מקסימלי דחס אתר + legacy‏ (ramzor: color, pah*) 🚚 ‏ingest + סכמה קיימים דשבורד כמויות → שלב 3.4
זיהוי חד-ערכי של כלי אצירה (ARUCO) דחס, רמסע, מכולות אתר + legacy‏ (isAruco, aruco2Container) 🚚 ‏endpoint קיים ב-ingest
זיהוי זיהום מיחזור (Contamination) דחס אתר (products.ts) 🚚 קליטת סוג אירוע חדש → שלב 5.1
זיהוי העמסת יתר וגלישה (Overload) דחס, מנוף אתר 🚚 כנ"ל
ספירת מכולות + תמונה לפני/אחרי פריקה רמסע דף מוצר (רמסע) 🚚 חלקית ב-ramzor שדות אירוע פריקה → שלב 5.1
זיהוי אוטומטי של כניסה לכל תחנה בארץ רמסע, ביובית דפי מוצר 🔀 ‏geofence על מאגר תחנות מאגר תחנות ארצי → חדש (פערים #6)
צילום אוטומטי מ-2 צידי המנוף + טמון באוויר מנוף טמונים דף מוצר 🚚 קליטת אירוע ייעודי → 5.1
צילום ארגז לווידוא סוג פסולת + חריגת טון/קוב מנוף גזם דף מוצר 🔀 שקילות קיימות; אין הצלבת נפח כלל טון/קוב ב-Alerts → שלב 4
טיאוט: מעקב מברשות/גרניק, אדם מחוץ לרכב, אפוד זוהר, נתוני רכב (מים/סלד) טיאוט דף מוצר + מודול mgroup 🚚 ‏supervisorWorkPlan ממופה (legacy) מסכי טיאוט → שלב 4.3
LPR — זיהוי לוחית רישוי תחנה, ביטחון, כולם דפי מוצר + legacy 🚚 ‏security-core: ‏Detection.licensePlate + חוקי LPR ✅
זיהוי ברקודים (שיוך מטען למסמכים) הפצה/קרגו הצעת קרגו (₪49.90) 🚚 מודול קרגו → חדש (פערים #2)
ספירת משטחים / חבילות וארגזים הפצה/קרגו הצעת קרגו (₪59.90/₪69.90) + POD 🚚 כנ"ל
אינדיקציה+תיעוד פתיחת דלת לפי מיקום (כולל תמונה) הפצה/קרגו הצעת קרגו (₪29.90/₪39.90) 🚚 כנ"ל + סוג אירוע ב-ingest

4. שקילה ותחנות

יכולת סוג מקור ריצה כיסוי היום פער → שלב
שקילה אוטומטית במאזני גשר: LPR + מחסומים + צג נהג תחנת שקילה דף מוצר שקילה + אתר 🚚 תוכנת התחנה קיימת (legacy, חולון 270270270); ענן: ‏ingest ‏Prioriti* ✅ ניהול תחנה בדשבורד → שלב 3.4 (מסך שקילה)
תעודות שקילה, ברוטו/טרה/נטו, in/out תחנת שקילה legacy‏ (priority) ☁️ סכמה + ingest ✅; ‏reconciliation ב-modules/reconciliation
ייבוא אקסל ספקים + השוואה חודשית תחנת שקילה legacy oldsite ☁️ modules/imports (5 פרסרים) ✅ מסך פערים → שלב 3.4
ראש משקל KWS (alibi, fgross/ftare/fnet) משאיות שקילה legacy 🚚 ‏token ב-legacy; ‏ingest ממופה אימות צרכן KWS → שלב 6
ממשק ERP (Priority) + דיווח למשרד להגנת הסביבה תחנת שקילה דף מוצר + legacy ☁️ ‏constERP ingest ✅; דיווח רגולטורי אין דוח רגולטורי → report-engine שלב 4.5
משאיות בפנים >2ש' + תחנה אונליין/אופליין תחנת שקילה legacy ☁️ ‏ingest ✅; ‏jobs ב-modules/jobs התראה במסך → שלב 3.2
OCR על תעודת שקילה (צילום → קריאה אוטומטית) עבודות עפר דף Proof-to-Payment 🔀 מודול P2P → חדש (פערים #3)

5. הוכחות, אספקה וחיוב

יכולת סוג מקור ריצה כיסוי היום פער → שלב
הוכחת אספקה (POD) — מיקום/שעה/וידאו/דוח מיכלית דלק, הפצה הצעות (₪39.90) + TEMPO POD 🔀 מודול POD → חדש (פערים #2)
Proof of Service לכל מחזור שאיבה-פריקה ביובית דף ביוביות 🔀 וריאנט POD לביוביות
הוכחת ביצוע לכל פריקה לאורך מסלול עבודות עפר דף P2P 🔀 מודול P2P
חיוב לקוח בזמן אמת (מהשטח לחיוב) עבודות עפר, הפצה דף P2P ☁️ ‏P2P + לוגיקת חיוב (מתחבר למסך הצריכה מ"תוספות 22.7")
בקרת החזרות ומשטחים ריקים הפצה TEMPO POD 🔀 מודול קרגו
תיעוד תדלוקי שטח (מיקום/זמן/צילום) מיכלית דלק הצעת דלק (₪39.90) 🔀 מודול דלק → חדש (פערים #4)
צילום אספקה בשטח ("ראיה לכל ליטר") מיכלית דלק הצעת דלק (₪29.90) 🚚 מודול דלק

6. דלק ומניעת אובדן

יכולת סוג מקור ריצה כיסוי היום פער → שלב
זיהוי חשד לאובדן/גניבת דלק (מפלס מול נסיעה) כולם (בדגש קרגו/דלק) שתי ההצעות (₪49.90) + אתר 🔀 אנליטיקת דלק בענן → פערים #4
בקרה חכמה סביב נקודות פריקה + מניעת מיקס סוגי דלק מיכלית דלק הצעת דלק (₪49.90) 🔀 מודול דלק
התראה על פריקה במיקום לא מאושר מיכלית דלק, הפצה הצעות (₪39.90) ☁️ כלל ב-Alerts Engine על אירועי פריקה/דלת

7. תכנון, מסלולים ומשימות

יכולת סוג מקור ריצה כיסוי היום פער → שלב
סידור עבודה + תכנון מול ביצוע כולם הצעות (₪39.90) + דפי מוצר ☁️ ‏legacy‏ missions ממופה; מסך "ניהול משימות" בתכנון שלב 3.4 (מסך משימות)
בניית מסלולים חכמה (נקודות, חלונות זמן, קיבולת) הפצה הצעת קרגו (₪69.90) ☁️ ‏legacy: ‏routific מנוע מסלולים → פערים #5
אופטימיזציית מסלולים (קיצור ק"מ/זמן) כולם הצעות (₪99.90) + דפי מוצר ☁️ כנ"ל; פיצ'ר מתומחר → מנייה פר-שימוש
My Service — פניות/תלונות → משימה → נהג קרוב + ניווט דחס, מנוף, רשויות אתר (my-service) ☁️ ‏legacy‏ userRequests+Monday ממופה אפליקציית משימות → שלב 4.3
קריאות מוקד עירוני (מוקד בינה) אצל הנהג רשויות (mgroup) legacy ☁️ קונקטור מתוכנן (5.3, ‏A-S-Bina) שלב 5.3
אוטומציות ותובנות תפעוליות כולם הצעות (₪29.90) ☁️ ‏Alerts Engine + אוטומציות → שלב 3.2/4.5

8. דשבורדים, דוחות והתראות (הענן שלנו)

יכולת סוג מקור ריצה כיסוי היום פער → שלב
דשבורד תפעולי + דוחות (אירוע: מיקום/שעה/תמונה) כולם הצעות (₪29.90) ☁️ דשבורד קבלנים חי (3/12 מסכים) השלמה → שלב 3.4
מחולל דוחות מתקדם (חיתוכים, התאמה אישית) כולם הצעת קרגו (₪29.90) ☁️ ‏report-engine: ‏PDF RTL‏ 4 שפות + Excel ✅ (samples ב-docs/samples) חיבור לסוכן AI → שלב 4.5
דוחות רגולטוריים + Audit Log + API ביובית דף ביוביות ☁️ תבנית רגולטורית → שלב 4.5
ניתוח פעילות צי + זיהוי אנומליות ביובית, כולם דף ביוביות ☁️ האגם חי (‏raw+marts, שתי הסביבות) מודל אנומליות → שלב 4.5
מנוע התראות: כללים (אזור/תג/צבע/לוחית/כמות/שעות/ימים) כולם legacy security+mgroup ☁️ ‏security-core ‏rules.ts ✅ (labels/cameras/schedule/dedupe); ‏alerts-engine ריק הרחבת תנאים + שירות ייעודי → שלב 3.2
ערוצי התראה: פופאפ, פעמון, מייל, סאונד + acknowledgments כולם legacy ☁️ סכמת Alert+Acknowledgment ✅; אין דיספאץ' ‏notification dispatch → שלב 3.2
דשבורד רשויות פר-תפקיד (ראש עיר/גזבר/שפ"ע) + SuperVision רשויות brochures supervision + legacy ☁️ ‏apps/client-app ‏src/products/municipal שלב 4.3
מסך צריכה/מנייה פר מודול ומשתמש כולם תוספות 22.7 ל-WORK-PLAN ☁️ ‏metering → שלבים 3-4 (כבר מאושר)
Copilot / סוכני AI / שאילתות בשפה חופשית כולם WORK-PLAN + אתר ☁️ ‏mcp-wrapper ✅ (כולל lake-tools); ‏ai-agents שלד שלב 4.5

9. ביטחון — עמודים, אתרים וחמ"לים

יכולת סוג מקור ריצה כיסוי היום פער → שלב
צינור זיהוי: PC קצה (YOLO) → אירוע+תמונה → חוקים → התראה פר-משתמש עמוד/אתר legacy security 🔀 ‏security-core מלא ✅ (‏Detection, ‏recordId, ‏tags/colors, ‏classCount) אימות payload מול תוכנת הקצה → לפני קאט-אובר
מסכות יום/לילה (YOLO labeler) + ספי PC עמוד/אתר legacy 🔀 ‏CameraMask + ‏EdgePc.settings ✅ מסך labeler → שלב 4.2
וידאו התראות (480p/HLS/הורדה) עמוד/אתר legacy 🔀 ‏Detection.media ✅ נגן במסך → שלב 4.2
עמדת תצפית חכמה Plug&Play (סולארי+סלולרי/RF) אתר מבודד חוברת ביטחון 🚚 — (מוצר קצה) ניהול העמדה בענן = אותו security-core
מערכת RealTime לחמ"ל — ‏On-Prem, מצלמה תרמית, בידוד רעשים חמ"ל חוברת ביטחון + אתר 🚚 — (מוצר On-Prem, בלי ענן בכוונה) מחוץ לענן; לתעד ב"תוכנית מערכות" בלבד
מערכת תחקור Web — ציר זמן, קשרים בין ישויות, שליפה בדקות אתר/רשות חוברת ביטחון + אתר ☁️ חלקית: ‏detections + media בסכמה מסך תחקור/אירועים + XLSX/PDF → שלב 4.2
עמדת אנליטיקה נתיקה/מתקדמת עמוד דף מוצר (דפי מוצר/עמדת אנליטיקה נתיקה) 🚚 וריאנט של עמדת תצפית; קליטה זהה
התראות עמודים (כיום רק חוף אשקלון) עמוד WORK-PLAN דרישה #6 ☁️ ‏pole-ingest קיים ב-ingestion/pole-ingest העברה ל-Security → שלב 4.2

10. חיישנים, מכולות וכלי אצירה

יכולת סוג מקור ריצה כיסוי היום פער → שלב
מיקום+סוללה למכולות (Digital Matter) + geocoding מכולות legacy + אתר (bin-sensors) 🔀 קונקטור מתוכנן (5.3); סכמה ממופה שלב 5.3
חיישני מילוי לטמונים/מונחים + תחזית מילוי טמונים אתר (bin-sensors) 🔀 ‏ingest חיישנים + תחזית → שלב 5 + 4.5
דחסנים/דחסניות (חיישנים, ורידיס) דחסנים הצעות מחיר בדרייב + אפיון אלון 🔀 ‏DahasDatas ב-legacy ממופה סוג נכס בסכמה → שלב 4
שליטה על מכולות / מכולות בחוץ מכולות legacy oldsite ☁️ ‏ingest ✅ מסך מכולות → שלב 4.3

רשימה 1 — פערי ענן

הרשימה קופלה לתוכנית העבודה והיא מנוהלת שם: ‏docs/WORK-PLAN.md — פריטים ‏4.6–4.10 (מיכליות דלק, קרגו/הפצה, ‏Proof-to-Payment עפר, מנוע מסלולים, ניהול יחידות קצה), והיתר מופו לשלבים קיימים (‏3.2, ‏4.2, ‏4.5). חלק מהפריטים כבר בביצוע; הסטטוס החי — בתוכנית העבודה, לא כאן.

רשימה 2 — "תוכנית מערכות המשאיות" (יכולות הקצה לריכוז במסמך אחד)

מה שרץ על המשאית/בתחנה/בעמוד — הרשימה ששניר ביקש לרכז. 22 פריטים, לפי נכס:

על כל משאית (ליבה):

  1. ‏DVR/NVR + 4-8 מצלמות (מעטפת 360, LIVE+הקלטה מקומית)
  2. מחשב AI בקצה (Jetson Orin) — ‏CV: ספירה/זיהוי לפי אפיון
  3. ‏GPS/טלמטריה (מיקום, מהירות, שעות מנוע, ACC)
  4. קישוריות סלולרית + דחיפת אירועים ל-ingest (טוקן פר-מכשיר)
  5. עדכוני תוכנה מרחוק + גישת AnyDesk
  6. ‏watchdog מקומי (RTSP, חיווי תקלות רכיבים)

פר סוג משאית (תוספות קצה): 7. דחס: זיהוי פחים צבע/גודל, ‏ARUCO, ‏contamination, ‏overload, ‏lid_match 8. רמסע: זיהוי מכולות + תמונה לפני/אחרי, זיהוי כניסה לתחנות 9. מנוף טמונים: צילום דו-צדי של המנוף, תמונת כלי באוויר, ספירת כלים 10. מנוף גזם: צילום ארגז, זיהוי עבודות פרטיות 11. טיאוט: חיווי מברשות/גרניק, אדם מחוץ לרכב + אפוד זוהר, ממשק נתוני רכב (מים/סלד) 12. ביובית: תיעוד מחזורי שאיבה/פריקה (Proof of Service) 13. מיכלית דלק: חיישן/אינדיקציית פריקה, מניעת מיקס, צילום אספקה, מפלס דלק 14. הפצה/קרגו: חיישן דלת + תמונה, ספירת משטחים/חבילות, קריאת ברקודים 15. עבודות עפר: צילום תעודת שקילה (ל-OCR), סימון פריקה ע"י נהג 16. שקילה על המשאית: ראש משקל KWS (‏alibi, ‏gross/tare/net)

בתחנות שקילה: 17. ‏LPR בכניסה/יציאה + מחסומים + צג נהג + תיעוד מצולם (תוכנת התחנה)

בעמודים/אתרי ביטחון: 18. ‏PC קצה עם YOLO (מסכות יום/לילה, ספים לפי שעות), דחיפת אירוע+תמונה+וידאו 19. עמדת תצפית Plug&Play: סולארי, סלולרי/RF, מצלמות, ‏AI מובנה 20. עמדת אנליטיקה נתיקה/מתקדמת (וריאנט נייד) 21. מערכת RealTime לחמ"ל — ‏On-Prem מלא (תרמית, בידוד רעשים, ‏UI מקומי; בלי ענן בכוונה)

כלי אצירה: 22. חיישני Digital Matter (מיקום/סוללה) + חיישני מילוי לטמונים

הערת ריכוז: הפריטים 1-6 הם "מערכת הבסיס" שבהצעות המחיר (₪5,775 + ₪1,750 התקנה + ₪260/חודש); 7-16 הם המודולים שנדלקים פר-משאית. מסמך "תוכנית מערכות המשאיות" המלא צריך להוסיף לכל פריט: חומרה מדויקת, ספק, גרסת תוכנת קצה, ‏payload מול ה-ingest, ותלות ברישיון.


הצעד הבא: לאשר את שלבים 4.6-4.10 המוצעים מול שניר ולקבוע איזה משני הפיילוטים (דלק/קרגו) נכנס ראשון לתוכנית — שניהם על אותה תבנית "אירוע פריקה מתועד", אז הראשון סולל את השני.


נספח י״א: כיסוי — איפה מפתח מוצא "מה קיים ומה לא"

סריקת הכיסוי המלאה, כלומר כל מה שתוכנן אי פעם מול מה שקיים, היא סריקה שיווקית והנהלתית: היא מודדת את המוצר מול מה שהובטח בשוק, ולכן היא מסמך נפרד ומתוחזק בנפרד (docs/COVERAGE-SWEEP-24-7.md, וגרסת HTML מופקת ממנו).

לצורכי פיתוח אין צורך בה, ולא בגלל שהיא לא חשובה — אלא כי מפתח שואל שאלה אחרת. שלוש השאלות שמפתח באמת שואל, וכל אחת נענית בתוך המסמך הזה:

השאלה איפה התשובה
מה המערכת לא עושה, ומה לא מגלה לי לבד? פרק 28 — המגבלות
מה פתוח וראוי לספרינט הראשון? פרק 30 — משימות פתיחה
איזו יכולת קיימת לאיזה סוג רכב ולאיזה סגמנט? נספח י׳ — מטריצת היכולות, והמקור הבר‑הרצה הוא packages/catalog עם טסט המטריצה שמוכיח אפס דליפה בין סגמנטים

הכלל שמאחורי החלוקה הזו: הקוד הוא מקור האמת לגבי מה קיים.packages/catalog מצהיר על 37 המודולים, ‏32 סוגי הרכב ו‑11 הסגמנטים, ולכל מראה שלו יש טסט שמכריח אותה להסכים איתו (פרק 13). מסמך שיווקי לא יכול להיות אדום ב‑CI; הקטלוג יכול, ולכן עליו סומכים.


נספח י״ב: פרונטאנד במקום עורך ה‑Wix (how-to-frontend)

מקור: docs/conventions/how-to-frontend.md — משוכפל כאן במלואו כדי שהמסמך יעמוד בפני עצמו. ההקשר המלא של המעבר מ‑Wix: פרק 19; מערכת העיצוב: פרק 12.

How to build frontend (the Wix-editor replacement)

In Wix you dragged visual components in an editor and changed their settings. In real code development the equivalent is a component library + compose in code + live preview. You keep the speed, gain flexibility and version control.

The three pieces

  1. Component library (packages/ui-kit) — build once the reusable UI pieces: buttons, tables, cards, map, charts, forms, the alerts screen, layout shells. Built in React + Tailwind + a component kit (shadcn/ui). This is your "visual components" catalog.
  2. Compose a screen in code — a dashboard screen = assembling ui-kit components in JSX and setting their props (props = the old "settings" panel). Layout/resize = responsive CSS (grid/flex), not pixel dragging. Claude generates most of this from the spec/mockup.
  3. Live preview — run npm run dev; the browser hot-reloads on every save. You edit code (or Claude does) and see it instantly — that's the "drag/resize and see it" feel, just faster and reversible.

How your current strength transfers

Daily frontend flow

  1. git checkout -b feat/<screen> off dev.
  2. Compose the screen from ui-kit (Claude drafts JSX from the mockup/spec).
  3. npm run dev → tweak with hot-reload until it looks right; responsive on all breakpoints (incl. mobile / Median shell).
  4. i18n: no hardcoded strings — keys in shared-i18n (he/en/ar/es, RTL/LTR).
  5. Data via the typed API client (from the OpenAPI contract), through the gateway.
  6. PR → deploy to dev → validate → PR to main → prod.

Rule

A new screen reuses ui-kit components; you don't rebuild a button or a table per dashboard. New look = new props/variant in ui-kit, once.