WG

[להחליף בלוגו בית הספר]

[שם בית הספר]

WorkGrid

מערכת ניהול פרויקטים ומשימות בזמן אמת
מבוססת Blazor, SignalR ו-Firebase Firestore
שם העבודה: WorkGrid — פלטפורמה לניהול משימות צוותיות
שם התלמיד: [למלא: שם מלא]
ת.ז. התלמיד: [למלא: 9 ספרות]
שם המנחה: [למלא: שם המורה המנחה]
שם החלופה: הגנת סייבר ומערכות הפעלה (סמל התמחות 883589)
תאריך ההגשה: [למלא: dd/mm/yyyy]

תוכן עניינים

  1. 1. מבוא
    1. 1.1 ייזום
    2. 1.2 פירוט תיאור המערכת (אפיון)
  2. 2. תיאור תחום הידע
    1. 2.1 יכולות בצד השרת
    2. 2.2 יכולות בצד הלקוח
  3. 3. מבנה / ארכיטקטורה של הפרויקט
    1. 3.1 ארכיטקטורת המערכת
    2. 3.2 הטכנולוגיות
    3. 3.3 זרימת המידע במערכת
    4. 3.4 אלגוריתמים מרכזיים
    5. 3.5 סביבת הפיתוח
    6. 3.6 פרוטוקול התקשורת
    7. 3.7 מסכי המערכת
    8. 3.8 מבני הנתונים
    9. 3.9 סקירת חולשות ואיומים
  4. 4. מימוש הפרויקט
    1. 4.1 מודולים ומחלקות
    2. 4.2 קטעי קוד מרכזיים
    3. 4.3 מסמך בדיקות
  5. 5. מדריך למשתמש
  6. 6. סיכום אישי / רפלקציה
  7. 7. ביבליוגרפיה
  8. 8. נספחים
    1. 8.1 הסברים טכנולוגיים
    2. 8.2 תדפיס קוד הפרויקט

1. מבוא

1.1 ייזום

תיאור ראשוני של המערכת

WorkGrid היא פלטפורמת ענן לניהול פרויקטים ומשימות עבור צוותי פיתוח וצוותים מקצועיים אחרים, המאפשרת מעקב שיתופי אחר משימות (tickets) בזמן אמת על גבי לוח מסוג Kanban. המערכת מיישמת ארכיטקטורת לקוח־שרת מבוססת דפדפן: צד הלקוח נכתב באמצעות Blazor Server Interactive Components (ASP.NET Core 8.0), צד השרת מנהל את הלוגיקה העסקית, מאמת משתמשים ומתקשר עם מסד נתונים ענני (Google Firestore). התקשורת בין המשתמשים בזמן אמת מתבססת על SignalR מעל WebSocket, כך ששינוי שמבצע משתמש אחד (הזזת כרטיס, הוספת תגובה, עדכון סטטוס) מופץ מיידית לכל יתר המשתתפים בפרויקט.

המוצר המוגמר מאפשר: הרשמה והתחברות מאובטחות (סיסמאות מוצפנות ב-BCrypt), יצירת וניהול פרויקטים, הזמנת משתמשים נוספים באמצעות קודי הזמנה חד־פעמיים עם תפוגה, יצירת כרטיסים (משימות, באגים, feature requests ו-user stories) בעלי עדיפות, Story Points, סטטוס ומשובץ אחראי, ניהול לוח Kanban עם גרירה ושחרור (drag & drop), הוספת תגובות וזרם פעילות (activity feed) לכל כרטיס, ניהול פרופיל אישי, ודשבורד מנהל מערכת.

בחרתי בפרויקט זה מכיוון שהוא מחבר בין מספר תחומים מרכזיים שלימדו אותי בשנים האחרונות — הנדסת תוכנה צד־שרת בשפת C#, אבטחת מידע (אימות משתמשים, הצפנה, ניתוח איומי OWASP), תקשורת בזמן אמת, ועבודה עם שירותי ענן. האתגרים העיקריים שאני צופה: הבנה מעמיקה של מודל ה-rendering של Blazor Server (Interactive SSR וניהול state על גבי SignalR circuit), תכנון נכון של שכבת האבטחה לצד ניסיון שיתופי חלק, ומניעת race conditions בעדכוני לוח ב-real time.

הגדרת הלקוח

המערכת מיועדת לצוותי פיתוח קטנים עד בינוניים (2–30 משתמשים לפרויקט), סטודנטים בעבודות קבוצתיות, וצוותים מקצועיים שאינם זקוקים למחיר ולמורכבות של כלים מסחריים כמו Jira או Monday.com. המשתמשים הצפויים נחלקים לשלושה סוגים: בעל פרויקט (Owner) — מי שיצר אותו ומנהל את ההגדרות וההזמנות, משתתף (Participant) — משתמש רשום שהצטרף לפרויקט באמצעות קוד הזמנה תקף ויכול לקרוא וליצור/לעדכן כרטיסים, ו-מנהל מערכת (Administrator) — משתמש עם הרשאות גישה לדשבורד המנהל. כל המשתמשים חייבים להיות רשומים במערכת ולהזדהות (authenticate) באמצעות שם משתמש וסיסמה לפני כל פעולה.

יעדים ומטרות

בעיות, תועלות וחסכונות

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

התועלות הצפויות: מקור אמת יחיד (single source of truth) שמרוכז בענן, עדכון רציף של מצב הפרויקט, חיסכון בזמן עבור מנהלי פרויקט, והקטנת סיכוני אבטחה (במקום הפצה של קבצים במיילים). המערכת תספק את השירותים הבאים: הרשמה/התחברות, ניהול פרויקטים, ניהול כרטיסים, תקשורת בזמן אמת, ניהול פרופיל אישי, והזמנות חד־פעמיות.

סקירת פתרונות קיימים

פתרוןיתרונותחסרונות ביחס ל-WorkGrid
Jira (Atlassian)עשירה מאוד, ותיקה, תומכת ב-Agile מלאיקרה, מורכבת מדי לצוותים קטנים, עקומת למידה גבוהה
Trelloפשוטה, Kanban אלגנטיפיצ׳רי אבטחה וניהול הרשאות מוגבלים בתוכנית החינמית
Monday.comעיצוב טוב, אינטגרציות רבותממוקדת פרויקטי עסקים, פחות ב-issue tracking של פיתוח
GitHub Projectsאינטגרציה מושלמת עם קודמוגבל לפרויקטים שמתנהלים ב-GitHub בלבד
WorkGridקוד פתוח, real-time, חינמי, פשוט, הצפנת סיסמאות BCrypt, קוד ל-SignalR ו-Blazor להדגמה חינוכיתמוצר חינוכי — ללא אינטגרציות חיצוניות, אין התראות email

סקירת טכנולוגיות הפרויקט

הטכנולוגיות שבחרתי אינן בלתי-מוכרות לחלוטין, אך חלקן חדשות יחסית עבורי ודרשו למידה משמעותית. בפרט: Blazor Server — מסגרת שמריצה את לוגיקת הקומפוננטות על השרת ומסנכרנת את ה-DOM ללקוח דרך SignalR circuit; Google Firestore — מסד נתונים דוקומנטי (NoSQL), שונה ממסדים רלציוניים מהותית (אין JOINs, אין סכמה קשיחה, ודורש תכנון שונה של אינדקסים); ו-SignalR — ספריית real-time שמציעה WebSocket ו-fallbacks אוטומטיים ל-Server-Sent-Events או long-polling. סייג עיקרי: התקשורת בין לקוח לשרת ב-Blazor Server תלויה ב-connection חי — ניתוק רשת מוביל להחזרה אוטומטית אך פעולות שבתהליך עלולות להיכשל. סייג נוסף: Firestore דורש אישור Google Cloud service-account, ולכן פרטי האימות (JSON credentials) חייבים להיות נגישים לשרת ומוגנים היטב.

תיחום הפרויקט

התחומים שהפרויקט מטפל בהם: רשתות ותקשורת HTTP/S, WebSocket (SignalR), תקשורת עם מסד נתונים ענני (gRPC תחת Firestore SDK), מערכות הפעלה — Windows 11 כפלטפורמת הפיתוח והרצה, עקרונות client-server, אבטחת מידע (hashing, TLS, בקרה מול OWASP Top-10), ממשק משתמש web responsive, ותכנון NoSQL schema.

התחומים שהמערכת לא מטפלת בהם: המערכת אינה מיישמת שליחת אימיילים, אינה משתמשת בכרטיסי אשראי/תשלום, אינה תומכת בכתיבה offline ואינה כוללת אפליקציית mobile native. כמו-כן, המערכת אינה מיישמת Multi-Factor-Authentication (MFA) ואינה כוללת מנגנון שחזור סיסמה אוטומטי — אלה הושארו לפיתוח עתידי.

1.2 פירוט תיאור המערכת (אפיון)

תיאור מפורט של פעולת המערכת

המערכת פועלת בדפדפן כ-Single-Page-Application על גבי Blazor Server. בעת טעינה, המשתמש מופנה למסך הכניסה (אם אין session פעיל) או ישירות ללוח הפרויקטים שלו. עם ההתחברות, ה-AuthenticationService (scoped) שומר אובייקט User פעיל, והדפדפן שומר נתוני session ב-sessionStorage (ללא hash הסיסמה). לאחר כניסה למסך הלוח (Board) הדפדפן מבצע JoinProjectGroup(projectId) מול ה-BoardHub, ומצטרף לקבוצת SignalR ייחודית לפרויקט; כל עדכון שמתבצע ב-Firestore (יצירת/מחיקת/עדכון ticket או comment) משודר מיידית דרך IHubContext.Clients.Group("project_{id}").SendAsync(...) לכל חברי הקבוצה, והדפדפנים מתעדכנים אוטומטית.

יכולות לפי סוג משתמש (ברמת כותרות)

סוג משתמשיכולות
משתמש רשום (Participant) התחברות, הרשמה, ניהול פרופיל, הצגת פרויקטים שהוא חבר בהם, הצגת לוח Kanban, יצירת/עדכון/מחיקת tickets שלו, הוספת תגובות, הזנת קוד הזמנה
בעל פרויקט (Owner) כל הנ״ל + יצירת פרויקט, הגדרות פרויקט, יצירת קודי הזמנה, מחיקת פרויקט
מנהל מערכת (Administrator) כל הנ״ל + צפייה ברשימת כל המשתמשים והפרויקטים, דשבורד מערכת

תכנון בדיקות (קופסה שחורה)

#שם הבדיקהמה נבדקכיצד תבוצע
T1הרשמה תקינהיצירת משתמש חדש, שמירה ב-Firestore, hash סיסמהמילוי טופס הרשמה, בדיקת Firestore Console שהמשתמש נוסף ושה-PasswordHash שונה מהסיסמה המקורית
T2הרשמה עם שם קייםדחיית שם משתמש כפולניסיון להירשם עם username שכבר קיים, ציפייה ל"Username already exists"
T3התחברות תקינהזיהוי משתמש קיים בעזרת BCrypt.Verifyכניסה עם admin/admin, ציפייה להפניה לדשבורד
T4התחברות עם סיסמה שגויהדחיית סיסמה לא נכונהניסיון כניסה עם סיסמה לא נכונה, ציפייה ל"Invalid username or password"
T5יצירת ticketהוספת משימה חדשה והפצה ב-SignalRיצירת ticket ב-browser A, בדיקה שב-browser B (עם אותו פרויקט) הוא מופיע מיידית
T6גרירת ticketשינוי סטטוס דרך drag-and-dropגרירת כרטיס מ-Backlog ל-InProgress, בדיקה שה-Status וה-OrderIndex התעדכנו ב-Firestore
T7קוד הזמנה תקףשימוש בקוד חוקי שלא עבר 24 שעותיצירת קוד, התחברות כמשתמש אחר, הזנת הקוד, ציפייה להצטרפות
T8קוד הזמנה פג תוקףדחיית קוד שעבר ExpiresAtשינוי ExpiresAt ידני ל-‎-1h, ניסיון שימוש — ציפייה לכישלון
T9הוספת תגובההוספת comment והפצה real-timeהוספת comment, בדיקה ב-Firestore ושהדפדפן השני מציג אותה
T10Logout מנקה sessionניקוי CurrentUser ו-sessionStorageלחיצה על Logout, ציפייה להפניה ל-Login
T11גישה ללא הרשאהניסיון לגשת ל-/board ללא loginגלישה ישירה, ציפייה להפניה ל-/login
T12הזרקת SQL/NoSQLקלט זדוני בטופס loginהזנת ' OR '1'='1 — ציפייה שה-Firestore SDK יטפל בזה בצורה בטוחה

תכנון וניהול לוחות זמנים

שלבאבן דרךמתוכנןבפועל
1אפיון ותכנון2 שבועות2.5 שבועות
2הקמת פרויקט Blazor + חיבור Firestoreשבוע1.5 שבוע
3מודלים של נתונים + שכבת DALשבועשבוע
4מערכת אימות (Login/Signup/Session)2 שבועות2 שבועות
5לוח ה-Kanban + drag-and-drop2 שבועות3 שבועות
6SignalR Hub ו-broadcast בזמן אמתשבועשבוע
7פרופיל, פרויקטים, הגדרות2 שבועות2 שבועות
8תגובות ו-activity feedשבועשבוע
9קודי הזמנהשבועשבוע
10בדיקות ותיעוד2 שבועות2.5 שבועות

ניהול סיכונים

סיכוןדרך התמודדות מתוכננתמה בוצע בפועל
חוסר היכרות עם Blazor Serverלמידה ממקורות רשמיים של Microsoftנקרא Microsoft Learn + פרויקט תרגול קטן
ניתוק SignalR circuit בזמן שימושauto-reconnect מובנה, UI indicatorהוגדר auto-reconnect ברירת מחדל, נוספה הודעה "Reconnecting..." ב-Blazor defaults
אבטחת סיסמאותBCrypt עם saltשולב BCrypt.Net-Next עם hashing בכל יצירה/שינוי סיסמה
אובדן חיבור Firestoretry/catch + הודעות שגיאה ברורותשולבו רשתות ביטחון בכל שירות + logging ל-Console
Race conditions בעדכוני לוחעדכוני Firestore אטומיים + broadcast מאוחרFirestore SetAsync אטומי ברמת document; broadcast רק לאחר הצלחה
XSS דרך content של ticket/commentBlazor מקפל טקסט כ-text node ולא HTMLאין שימוש ב-@((MarkupString)...) בשום מקום בקוד

2. תיאור תחום הידע

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

2.1 יכולות בצד השרת

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

מהות:
קליטת משתמש חדש, בדיקת תקינות, הצפנה ואחסון במסד הנתונים.
אוסף פעולות:
קליטת שם משתמש, דוא״ל, שם מלא, סיסמה ואימות סיסמה; אימות שהשם לא קיים; יצירת hash (BCrypt) לסיסמה; שמירת מסמך חדש בקולקשן users ב-Firestore; עדכון CurrentUser.
אובייקטים:
AuthenticationService, FirestoreService, User, BCrypt.Net.BCrypt, FirestoreDb.

יכולת 2: התחברות (Login)

מהות:
אימות זהות משתמש קיים באמצעות השוואת hash של הסיסמה.
אוסף פעולות:
שליפת מסמך המשתמש ב-WhereEqualTo("Username",...); השוואה ב-BCrypt.Verify; עדכון CurrentUser; הנפקת אירוע OnAuthStateChanged.
אובייקטים:
AuthenticationService, FirestoreService, User, Query, DocumentSnapshot.

יכולת 3: יצירת/עדכון/מחיקת Ticket

מהות:
CRUD על כרטיסי משימה תוך שידור העדכון לכל המשתתפים בפרויקט בזמן אמת.
אוסף פעולות:
בקרת שדות; יצירת DocumentReference ב-Firestore; SetAsync (SetOptions.Overwrite) לעדכון; שליחת הודעה לקבוצה המתאימה ב-IHubContext<BoardHub>.
אובייקטים:
FirestoreService, Ticket, BoardHub, IHubContext, CollectionReference.

יכולת 4: עדכון סטטוס וסדר של Ticket (drag-and-drop)

מהות:
שינוי ערכי Status ו-OrderIndex בלבד (עדכון חלקי, לא Overwrite) ושידור לכלל המשתתפים.
אוסף פעולות:
UpdateAsync(Dictionary<string,object>) עם שלושה שדות בלבד; שליפה מחדש של המסמך המעודכן; שידור סוג אירוע "TicketStatusChanged".
אובייקטים:
FirestoreService, Ticket, DocumentReference, BoardHub.

יכולת 5: ניהול קודי הזמנה (Invitation Codes)

מהות:
יצירת קוד אלפאנומרי בן 6 תווים, חד-פעמי, עם תפוגה של 24 שעות.
אוסף פעולות:
GenerateInvitationCode() מעל אלפבית ללא תווים מטעים (O/0/I/1 מוחרגים); שמירה בקולקשן invitation_codes; אימות תקפות (IsValid = !IsUsed && !IsExpired); סימון כ-Used ושיוך המשתמש ל-ParticipantIds של הפרויקט.
אובייקטים:
InvitationCode, Project, FirestoreService, Random.

יכולת 6: תגובות לכרטיס (Comments)

מהות:
הוספת תגובות לכרטיס וסנכרון בזמן אמת.
אוסף פעולות:
ולידציה (TicketId, AuthorId, Content לא ריקים); יצירת document ב-comments; שידור אירוע "CommentAdded"; מיון by CreatedAt בצד הלקוח.
אובייקטים:
Comment, Ticket, FirestoreService, IHubContext.

יכולת 7: ניהול קבוצות SignalR

מהות:
הצטרפות/יציאה מקבוצת פרויקט לצורך broadcast ממוקד.
אוסף פעולות:
BoardHub.JoinProjectGroup(projectId)Groups.AddToGroupAsync(ConnectionId, "project_{id}"); סימטרי עבור Leave.
אובייקטים:
BoardHub, HubCallerContext, IGroupManager.

יכולת 8: Seeding נתוני התחלה

מהות:
איתחול מערכת ריקה במשתמש admin ובפרויקט דוגמה עם 10 tickets.
אוסף פעולות:
בדיקה האם admin קיים; יצירת המשתמש, הפרויקט והכרטיסים; רישום ב-Console.
אובייקטים:
DataSeedService, User, Project, Ticket.

2.2 יכולות בצד הלקוח

יכולת 9: ממשק משתמש ללוח Kanban

מהות:
תצוגת 4 עמודות (Backlog / InProgress / InReview / Done) עם כרטיסי משימה.
אוסף פעולות:
רינדור קומפוננטת Board.razor; שאילתת tickets דרך FirestoreService.GetTicketsByProjectIdAsync; מיון לפי OrderIndex; פיצול בצד לקוח ל-4 רשימות.
אובייקטים:
Board.razor, Ticket, FirestoreService.

יכולת 10: Drag-and-Drop

מהות:
גרירה של כרטיס בין עמודות ועדכון סטטוס וסדר.
אוסף פעולות:
HTML5 Drag Events (ondragstart, ondragover, ondrop); חישוב OrderIndex חדש על בסיס העמודה היעד; קריאה ל-UpdateTicketStatusAsync.
אובייקטים:
Board.razor, JavaScript Drag&Drop API, FirestoreService.

יכולת 11: קבלת עדכונים בזמן אמת

מהות:
התחברות ללקוח SignalR, הצטרפות לקבוצה והאזנה לאירועים.
אוסף פעולות:
בניית HubConnection עם HubConnectionBuilder; StartAsync; InvokeAsync("JoinProjectGroup", projectId); On<string,object>("TicketUpdated", handler).
אובייקטים:
HubConnection, Board.razor.

יכולת 12: שמירה ושחזור Session

מהות:
שמירת נתוני משתמש ב-sessionStorage של הדפדפן למקרה שה-circuit מתאתחל.
אוסף פעולות:
סדרת UserSessionData ל-JSON (ללא PasswordHash); שמירה ב-window.sessionStorage דרך JSInterop; שחזור ב-OnAfterRenderAsync — קריאה חוזרת של המשתמש מ-Firestore לפי ה-Username.
אובייקטים:
AuthenticationService.UserSessionData, IJSRuntime.

יכולת 13: עריכת פרופיל

מהות:
עדכון שם מלא, דוא״ל, תפקיד, מחלקה, bio וסיסמה.
אוסף פעולות:
UpdateProfileAsync / ChangePasswordAsync ב-AuthenticationService; אימות סיסמה נוכחית לפני שינוי; יצירת hash חדש ב-BCrypt.
אובייקטים:
Profile.razor, AuthenticationService, User.

3. מבנה / ארכיטקטורה של הפרויקט

3.1 ארכיטקטורת המערכת (חומרה ורכיבים)

המערכת ארוגה משלושה רכיבים עיקריים: הדפדפן של הלקוח (Chrome, Edge, Firefox), שרת WorkGrid (ASP.NET Core 8.0) המריץ את Blazor Server ואת SignalR Hub, ו-Google Firestore (שירות ענן). התרשים שלהלן מציג את הקשרים ביניהם.

Client (Browser) Blazor Server Interactive SSR SignalR Client sessionStorage HTML Drag & Drop WorkGrid Server ASP.NET Core 8.0 Razor Components BoardHub (SignalR) AuthenticationService FirestoreService DataSeedService Google Firestore (Cloud NoSQL) users projects tickets comments invitation_codes HTTPS / TLS 1.3 WebSocket (wss) gRPC over HTTP/2
תרשים 1ארכיטקטורת המערכת ברמת מאקרו

3.2 הטכנולוגיות

3.3 זרימת המידע במערכת

להלן תרשימי זרימה עבור יכולות מרכזיות.

3.3.1 זרימת Login

Login.razor Authentication Service FirestoreService Firestore BCrypt.Verify 1. LoginAsync(username, password) 2. GetUserByUsernameAsync(username) 3. WhereEqualTo Query 4. User document 5. BCrypt.Verify(password, user.PasswordHash) 6. bool (match result) 7. (Success, Message) + OnAuthStateChanged
תרשים 2זרימת מידע — התחברות משתמש (Sequence Diagram)

3.3.2 זרימת יצירת/עדכון Ticket עם broadcast

Browser A FirestoreService Firestore IHubContext <BoardHub> Browser B 1. CreateTicketAsync(ticket) 2. SetAsync(ticket) 3. OK (document saved) 4. NotifyProjectUpdateAsync("TicketCreated", ticket) 5a. SendAsync("TicketUpdated", ticket) 5b. Same broadcast — Browser A is in the same Group("project_id") 6. Return Ticket (with Id)
תרשים 3זרימת יצירת Ticket והפצה ב-SignalR (Sequence Diagram)

3.4 אלגוריתמים מרכזיים

3.4.1 אימות סיסמה — BCrypt

ניסוח הבעיה: כיצד לאמת שמשתמש יודע את סיסמתו בלי לאחסן אותה בטקסט גלוי, ולהיות עמיד מול מתקפות מילון ו-rainbow-tables?
אלגוריתמים קיימים: MD5/SHA-1 (נדחים — מהירים מדי), SHA-256 עם salt (אפשרי אך איטיות ניתנת לכוונון רק בחלק מהיישומים), BCrypt (מבוסס Blowfish, עם salt מובנה ו-work factor מכוונן), PBKDF2 ו-Argon2. המערכת משתמשת ב-BCrypt כיוון שהוא ה-de facto standard ב-.NET דרך BCrypt.Net-Next, עם ברירת מחדל של 211 סיבובים — מספיק להאט מתקפות brute-force מבלי לפגוע בחוויית המשתמש.

שלילת חלופות: MD5 שבור מתוקף התנגשויות (collisions); SHA-256 ללא work-factor מאפשר בדיקה של מיליארדי hashes לשנייה ב-GPU; Argon2 יעיל יותר אך התמיכה ב-NuGet פחות בשלה, ולכן העדפתי את BCrypt שגם קל להגדיר ולשלב.

3.4.2 גירה וסדר הכרטיסים בלוח

ניסוח: בעת גרירת כרטיס מסטטוס X לסטטוס Y במיקום אינדקס k, יש לעדכן את הכרטיס הנגרר, וכן (רצוי) לעדכן את שאר הכרטיסים שסביבו ב-Y כך שמיקומיהם חוקיים.
שיטה שנבחרה: שדה OrderIndex (int) על כל ticket. בעת שחרור בדרופ, הלקוח שולח את ה-ID וה-OrderIndex החדש; השרת עושה UpdateAsync חלקי לטבלת tickets.
שלילת חלופות: שימוש בסדר לפי CreatedAt אינו נותן שליטה ידנית על סדר; שימוש ב-linked list של Ids בתוך Project כן מספק שליטה אך הופך כל reorder לעדכון מסמך יחיד גדול ולא אטומי. ה-OrderIndex הוא פשרה שמאפשרת עדכון נפרד לכל כרטיס ומיון בצד-לקוח אחרי שאילתה.

3.4.3 אלגוריתם יצירת קוד הזמנה

6 תווים אקראיים מבוחרים מאלפבית של 32 תווים שהוחרגו ממנו O, 0, I, 1 (אותיות ומספרים דומות) כדי למנוע בלבול בין משתמשים שמקלידים ידנית. מרחב האפשרויות: 326 ≈ 1.07×109 — מספיק גדול כדי למנוע ניחוש ב-brute force ברגע בו יש לקוד חיים של 24 שעות בלבד.

שלילה: שימוש ב-GUID (128 ביט) היה מאובטח יותר אך לא נוח למשתמש להקליד; קוד קצר של 4 תווים היה רגיש מדי לניחוש; 6 תווים בפורמט אלפאנומרי מאוזנים בין אבטחה לנוחות. הפנייה: OWASP ASVS section 6.3.3.

3.5 סביבת הפיתוח

3.6 פרוטוקול התקשורת

המערכת מסתמכת על שלושה ערוצי תקשורת: HTTPS/TLS לשליפת ה-Razor Components ולהשקת ה-circuit הראשונית, WebSocket Secure (wss://) דרך SignalR circuit לכל פעולה אינטראקטיבית ולעדכונים בזמן אמת, ו-gRPC over HTTP/2 (עם TLS) לתקשורת בין השרת לבין Firestore. התקשורת עם Firestore מיועלת על-ידי ה-SDK הרשמי ואינה מחייבת הגדרה ידנית של פרוטוקול.

הודעות SignalR זורמות במערכת

שם ההודעהנשלחת מ־נשלחת אלמבנה השדות
JoinProjectGroupClientHubprojectId: string (20 bytes max)
LeaveProjectGroupClientHubprojectId: string
TicketUpdatedHub (server)Group clientsupdateType: string (enum {TicketCreated, TicketUpdated, TicketDeleted, TicketStatusChanged}), data: Ticket | { TicketId }
CommentAddedHubGroup clientsTicketId: string, Comment: {Id, Content, AuthorId, CreatedAt}
CommentDeletedHubGroup clientsTicketId: string, CommentId: string
הערה על הפרוטוקול: פורמט הסריאליזציה של SignalR ברירת-מחדל הוא JSON (יש גם MessagePack כאופציה). הודעות שמוצפנות ב-TLS 1.3 עוברות מעל TCP ולכן לחיצת היד המשולשת מתבצעת פעם אחת לחיבור, ואילו handshake של TLS מתבצע מיד אחריה.

3.7 מסכי המערכת

3.7.1 רשימת המסכים

מסךקובץתיאור
כניסהLogin.razorטופס כניסה עם שם משתמש וסיסמה, לחצן "הרשמה" מפנה ל-Signup
הרשמהSignup.razorטופס יצירת חשבון חדש (שם משתמש, דוא״ל, שם מלא, סיסמה + אימות)
דשבורדDashboard.razorסיכום פרויקטים פעילים, ברכה, נתוני משתמש
רשימת פרויקטיםProjects.razorכרטיסיות של כל הפרויקטים של המשתמש, יצירת פרויקט חדש, הצטרפות עם קוד
לוח KanbanBoard.razorתצוגת 4 עמודות עם drag-and-drop, יצירה ועדכון tickets
הגדרות פרויקטProjectSettings.razorעריכת שם/תיאור, הפקת קודי הזמנה, מחיקת פרויקט
פרופילProfile.razorעריכת נתוני משתמש, שינוי סיסמה
פאנל ניהולAdmin.razorדשבורד מנהל מערכת (גישה ל-admin בלבד)

3.7.2 תצלומי מסכים

תצלומים ייצוגיים של המסכים מוצגים להלן. יש להחליף את הפריימים בתמונות בפועל מסביבת ההרצה.

[צילום מסך: Login — מסך הכניסה · להחליף בתמונה אמיתית]
[צילום מסך: Signup — מסך ההרשמה · להחליף]
[צילום מסך: Dashboard — דשבורד ברכה ופרויקטים · להחליף]
[צילום מסך: Projects — רשימת פרויקטים · להחליף]
[צילום מסך: Board — לוח Kanban עם כרטיסים בארבע עמודות · להחליף]
[צילום מסך: ProjectSettings — הגדרות וקוד הזמנה · להחליף]
[צילום מסך: Profile — עריכת פרופיל · להחליף]
[צילום מסך: Admin — פאנל מנהל · להחליף]

3.7.3 תרשים מסכים (Screen Flow)

Login Signup Dashboard Projects Profile Admin (admin only) Board ProjectSettings "Sign up" "Back to login" on success on success sidebar click card ⚙ settings
תרשים 4היררכיית מסכים ומעברים (Screen Flow Diagram)

3.8 מבני הנתונים

מסד הנתונים של WorkGrid הוא Google Cloud Firestore (NoSQL דוקומנטי), שם הפרויקט בענן: workgrid-988f8. אין טבלאות רלציוניות — הנתונים מסודרים ב-5 קולקשנים (collections), כל document בעל מזהה אוטומטי ([FirestoreDocumentId]).

קולקשן users

שדהטיפוסדוגמההערות
Id (document id)string"UJ8fw…"מפתח ראשי — נוצר אוטומטית
Usernamestring"admin"unique (enforced לוגית ע"י UserExistsAsync)
PasswordHashstring"$2a$11$…"BCrypt hash, ~60 תווים
Emailstring"admin@workgrid.io" 
FullNamestring"Administrator" 
JobTitle / Department / Bio / AvatarUrlstring"IT"פרופיל
CreatedAt / UpdatedAtDateTime2026-03-12T10:02ZUTC

קולקשן projects

שדהטיפוסדוגמההערות
Idstring"p_xyz123"מפתח ראשי
Namestring"WorkGrid Platform" 
Descriptionstring"Main development…" 
Keystring"WG"משמש ליצירת TicketNumber (e.g., "WG-1")
OwnerIdstring"UJ8fw…"FK לוגי ל-users
ParticipantIdsList<string>["UJ8fw…","abc…"]מערך משתתפים, כולל ה-Owner
CreatedAt / UpdatedAtDateTime  

קולקשן tickets

שדהטיפוסדוגמההערות
Idstring"t_111"מפתח ראשי
ProjectIdstring"p_xyz123"FK לוגי ל-projects
Titlestring"Fix drag and drop" 
Descriptionstring"Drag doesn't work on…" 
TicketNumberstring"WG-4"Key+index
Statusstring (enum)"Backlog"ערכים: Backlog / InProgress / InReview / Done
Prioritystring (enum)"High"Low / Medium / High / Critical
Typestring (enum)"Bug"Task / Bug / Feature / Story
AssigneeIdstring?"UJ8fw…"nullable
ReporterIdstring"UJ8fw…" 
StoryPointsint3 
OrderIndexint0סדר בתוך עמודה
CreatedAt / UpdatedAtDateTime  

קולקשן comments

שדהטיפוסדוגמההערות
Idstring"c_ff1"מפתח ראשי
TicketIdstring"t_111"FK לוגי
AuthorIdstring"UJ8fw…"FK לוגי
Contentstring"נראה לי שזה קשור ל-touch"טקסט חופשי
CreatedAt / UpdatedAtDateTime  

קולקשן invitation_codes

שדהטיפוסדוגמההערות
Idstring"i_aaa"מפתח ראשי
Codestring"7H2KQP"6 תווים אלפאנומריים
ProjectIdstring"p_xyz123" 
CreatedByIdstring"UJ8fw…" 
CreatedAtDateTime  
ExpiresAtDateTime 24 שעות מ-CreatedAt
IsUsedboolfalse 
UsedByUserIdstring?null 
UsedAtDateTime?null 

3.9 סקירת חולשות ואיומים

3.9.1 שכבת האפליקציה

NoSQL Injection

למרות שב-Firestore אין שפת שאילתה בטקסט (אין concatenation של מחרוזות לשאילתה בסגנון SQL), עדיין יש לשמור על validate של קלט משתמש. השימוש נעשה ב-WhereEqualTo("Username", username) — ה-SDK מקודד את הערך כפרמטר, כך שאין אפשרות "להבריח" קוד. הסיכון המעשי הוא מינימלי. עם זאת, כל הקלטים נבדקים גם בצד השרת (שם משתמש > 3 תווים, סיסמה > 4 תווים, אין IsNullOrWhiteSpace).

XSS (Cross-Site Scripting)

Blazor מרנדר ביטויים @variable כ-text nodes ולא כ-HTML — לכן אפילו אם משתמש מזין <script>alert(1)</script> כתוכן של ticket או comment, הדפדפן יציג את הטקסט כפי שהוא. לא נעשה שימוש ב-(MarkupString) בשום מקום בקוד שלנו.

Authentication & Login

כל סיסמה מעורבבת ב-BCrypt עם salt אקראי וב-work-factor 11; השוואה בזמן כניסה מתבצעת דרך BCrypt.Verify שעמיד בפני timing attacks. יש בדיקת חוזק מינימלית של הסיסמה (4 תווים — בוחר תיקוני יכול להגדיל). אין מנגנון MFA במערכת הנוכחית — זה סעיף לשיפור עתידי.

Man-in-the-Middle (MITM)

app.UseHttpsRedirection() וכן app.UseHsts() ב-production מבטיחים שכל התקשורת עם הלקוח מוצפנת ב-TLS 1.3; ה-SDK של Firestore מכריח TLS על gRPC מצד המערכת. לכן האיום העיקרי הוא התקפה על ה-CA — אך זו מחוץ לתחום הפרויקט ומוטלת על תשתית ה-Certificates של Microsoft/Google.

DoS / DDoS

ברמת האפליקציה אין rate-limiting מובנה — זו חולשה ידועה. פתרון ב-production: להוסיף middleware של Microsoft.AspNetCore.RateLimiting על endpoints של login (ניסיונות כניסה) ועל יצירת tickets/comments. ברמת ה-Firestore, Google מגבילה read/write rates אוטומטית.

העלאת קבצים & Hash

במערכת הנוכחית אין העלאת קבצים — רק שדה AvatarUrl שהוא URL (לא הועלה קובץ אלא ניתן להזין קישור חיצוני). לעתיד: אם תתווסף העלאת קבצים, יש לחשב SHA-256 לכל קובץ, לשמור ב-Cloud Storage, ולוודא Content-Type בצד השרת (ולא בלבד לסמוך על הכותרת של הדפדפן).

3.9.2 שכבת התעבורה

התקשורת עם הלקוח עולה על TCP (port 443). לחיצת היד המשולשת של TCP (SYN → SYN/ACK → ACK) מתבצעת פעם אחת בתחילת החיבור, ולאחריה TLS 1.3 handshake ממוסגר מעליה. התקשורת WebSocket משתמשת ב-upgrade header (Upgrade: websocket) על גבי חיבור TLS קיים — זהו Single HTTP request שבו השרת מחליף את הפרוטוקול לבינארי WebSocket.

הצפנה: כל התעבורה מוצפנת ב-TLS 1.3 עם AEAD ciphers (AES-256-GCM או ChaCha20-Poly1305). מפתחות session מוחלפים ב-ECDHE, וה-certificate מאומת מול CA מהימן.

3.9.3 הפעלת המערכת

תהליך ההפעלה יוצר את השרת על https://localhost:7xxx (בפיתוח) או מאחורי reverse proxy (nginx / IIS) ב-production. ה-Service Account JSON של Firebase חייב להיות באותה תיקייה אך לא בתוך wwwroot, כך שלא ייחשף בהורדה ישירה. השרת מבצע Seeding של משתמש admin ופרויקט דוגמה רק אם הם לא קיימים (idempotent).

חולשה ידועה (to-fix): המשתמש admin/admin נוצר אוטומטית בעת הפעלה ראשונה. ב-production יש למחוק אותו מיידית או להחליף סיסמה חזקה. בסביבת המחקר/החינוך זו פשרה מקובלת.

4. מימוש הפרויקט

4.1 מודולים ומחלקות

4.1.1 מודולים מיובאים (NuGet)

מודולגרסהייעוד
Google.Cloud.Firestore3.4.0לקוח רשמי של Google לגישה ל-Firestore — מסד הנתונים הענני של המערכת
BCrypt.Net-Next4.0.3ספריית hashing של סיסמאות (BCrypt עם salt ו-work factor)
Microsoft.AspNetCore.SignalR.Client8.0.0לקוח SignalR להקמת HubConnection מתוך Blazor ולקליטת broadcasts בזמן אמת
Microsoft.AspNetCore.SignalR(מובנה ב-.NET 8)שרת SignalR — BoardHub ו-IHubContext
Microsoft.AspNetCore.Components(מובנה)תשתית Blazor — Razor Components ו-Interactive Server

4.1.2 מחלקות שפיתחנו

מחלקה: User (Model)

שדהתפקיד ושימוש
Idמזהה יחיד, מוקצה ע"י Firestore
Usernameשם המשתמש לכניסה, unique
PasswordHashhash של הסיסמה (BCrypt)
Email / FullName / JobTitle / Department / AvatarUrl / Bioנתוני פרופיל
CreatedAt / UpdatedAttimestamps UTC

המחלקה אינה מכילה פעולות — היא DTO עם annotations של Firestore.

מחלקה: Project (Model)

שדהתפקיד ושימוש
Id / Name / Description / Keyמזהה, שם, תיאור וקוד הפרויקט (WG וכד')
ParticipantIdsרשימת מזהי משתתפים (כולל ה-Owner)
OwnerIdמזהה היוצר
CreatedAt / UpdatedAttimestamps UTC

מחלקה: Ticket (Model) — כולל 3 enumים

שדהתפקיד
Id / ProjectId / Title / Descriptionנתוני בסיס
TicketNumberמוצג למשתמש — e.g. "WG-3"
Status / Priority / Typeמחרוזות המייצגות enumים (TicketStatus/Priority/Type)
AssigneeId / ReporterIdמשתמשים קשורים
StoryPoints / OrderIndexמשקל ומיקום בעמודה
CreatedAt / UpdatedAttimestamps

מחלקה: Comment (Model)

שדות: Id, TicketId, AuthorId, Content, CreatedAt, UpdatedAt.

מחלקה: InvitationCode (Model)

שדהתפקיד
Code / ProjectId / CreatedByIdהקוד (6 תווים), שיוך הפרויקט והיוצר
CreatedAt / ExpiresAtחיים של 24 שעות
IsUsed / UsedByUserId / UsedAtניהול מצב שימוש
IsExpired (computed)DateTime.UtcNow > ExpiresAt
IsValid (computed)!IsUsed && !IsExpired

מחלקה: AuthenticationService

Scoped. מטפלת בכל זרימות האימות וניהול ה-session הלוגי.

פעולהטענת כניסהטענת יציאה
LoginAsyncstring username, string password(bool Success, string Message) — מאמת ומעדכן CurrentUser
SignupAsyncusername, email, fullName, password, confirm, jobTitle?, department?יוצר משתמש ב-Firestore ומחזיר הודעת הצלחה/כישלון
Logoutמנקה CurrentUser ומשגר OnAuthStateChanged
GetSessionDatastring? — JSON ללא PasswordHash לאחסון ב-sessionStorage
RestoreSessionAsyncstring? sessionDatabool — טוען מחדש את המשתמש מ-Firestore ומעדכן CurrentUser
UpdateProfileAsyncשדות פרופיל (nullable)(bool, string) — מעדכן רק את השדות שהוזנו
ChangePasswordAsynccurrent, new, confirmNew(bool, string) — מאמת ישן, מעדכן hash חדש
SeedAdminUserAsyncיוצר admin/admin אם לא קיים

מחלקה: FirestoreService

Singleton. מרוכזים בה כל הפעולות מול מסד הנתונים ועל שלוחות ה-SignalR להפצת עדכונים.

פעולהטענת כניסהטענת יציאה
GetUserByUsernameAsyncstring usernameUser? — null אם לא קיים
CreateUserAsyncUser userUser — עם Id שהוקצה
UpdateUserAsyncUser userUser — לאחר SetAsync Overwrite
GetProjectsForUserAsyncstring userIdList<Project> — שאילתה WhereArrayContains
CreateProjectAsync / UpdateProjectAsync / DeleteProjectAsyncProject / idמחיקה אמורה למחוק גם כרטיסים, תגובות וקודי הזמנה
CreateTicketAsyncTicketTicket + broadcast "TicketCreated"
UpdateTicketStatusAsyncticketId, status, orderIndexvoid — UpdateAsync חלקי + broadcast "TicketStatusChanged"
DeleteTicketAsyncstring ticketIdvoid + broadcast "TicketDeleted"
GenerateInvitationCodestring — 6 תווים מ-alphabet ללא O/0/I/1
CreateInvitationCodeAsyncprojectId, createdByIdInvitationCode עם ExpiresAt = now+24h
UseInvitationCodeAsynccode, userIdbool — מוסיף משתמש ל-Participants, מסמן as Used
CreateCommentAsyncCommentComment + broadcast "CommentAdded"
NotifyProjectUpdateAsync (private)projectId, updateType, data?SendAsync("TicketUpdated", type, data) ל-Group("project_{id}")

מחלקה: BoardHub

פעולהטענת כניסהטענת יציאה
JoinProjectGroupstring projectIdמוסיפה את ה-ConnectionId לקבוצה "project_{id}"
LeaveProjectGroupstring projectIdמסירה את ה-ConnectionId מהקבוצה

מחלקה: DataSeedService

Scoped. רצה פעם אחת ב-startup (Program.cs). פעולות: SeedDataAsync, SeedAdminUserAsync (private), SeedSampleProjectAsync (private), SeedSampleTicketsAsync (private) — יוצרת 10 tickets לפרויקט WG לדוגמה.

4.2 קטעי קוד מרכזיים

4.2.1 Hashing ואימות סיסמאות (BCrypt)

הסיסמה אף פעם לא נשמרת בטקסט גלוי. בעת הרשמה יוצרים BCrypt hash (salt מובנה), ובעת כניסה משווים את הסיסמה שהוזנה מול ה-hash השמור.

// AuthenticationService.cs — קטע מתוך SignupAsync
var user = new User
{
    Username     = username,
    Email        = email,
    FullName     = fullName,
    PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), // salt + work factor 11
    JobTitle     = jobTitle ?? string.Empty,
    Department   = department ?? string.Empty,
    CreatedAt    = DateTime.UtcNow
};
await _firestoreService.CreateUserAsync(user);

// LoginAsync — Verify בלתי-רגיש ל-timing attacks
if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
    return (false, "Invalid username or password.");

4.2.2 broadcast בזמן אמת מהשרת ללקוחות

כל פעולה שמשנה נתון של פרויקט (create/update/delete של ticket או comment) קוראת ל-NotifyProjectUpdateAsync, אשר שולחת הודעה רק לקבוצת הלקוחות ששייכים לאותו פרויקט.

// FirestoreService.cs
private async Task NotifyProjectUpdateAsync(string projectId, string updateType, object? data = null)
{
    if (_hubContext != null)
    {
        await _hubContext.Clients
            .Group($"project_{projectId}")
            .SendAsync("TicketUpdated", updateType, data);
    }
}

// שימוש: לאחר יצירת ticket חדש
public async Task<Ticket> CreateTicketAsync(Ticket ticket)
{
    var docRef = _db.Collection(TicketsCollection).Document();
    ticket.Id = docRef.Id;
    await docRef.SetAsync(ticket);

    await NotifyProjectUpdateAsync(ticket.ProjectId, "TicketCreated", ticket);
    return ticket;
}

4.2.3 יצירת קוד הזמנה אלפאנומרי

// FirestoreService.cs — אלפבית ללא O/0/I/1 כדי למנוע בלבול
private static readonly Random _random = new();
private const string CodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

public string GenerateInvitationCode()
{
    return new string(Enumerable.Range(0, 6)
        .Select(_ => CodeChars[_random.Next(CodeChars.Length)])
        .ToArray());
}

public async Task<InvitationCode> CreateInvitationCodeAsync(string projectId, string createdById)
{
    var code = new InvitationCode
    {
        Code         = GenerateInvitationCode(),
        ProjectId    = projectId,
        CreatedById  = createdById,
        CreatedAt    = DateTime.UtcNow,
        ExpiresAt    = DateTime.UtcNow.AddHours(24)  // חיים של 24 שעות
    };
    var docRef = _db.Collection(InvitationCodesCollection).Document();
    code.Id = docRef.Id;
    await docRef.SetAsync(code);
    return code;
}

4.2.4 עדכון סטטוס וסדר של Ticket (drag-and-drop)

// FirestoreService.cs — UpdateAsync חלקי: שלושה שדות בלבד, ללא Overwrite של המסמך
public async Task UpdateTicketStatusAsync(string ticketId, string status, int orderIndex)
{
    var ticket = await GetTicketByIdAsync(ticketId);
    var projectId = ticket?.ProjectId;

    var docRef = _db.Collection(TicketsCollection).Document(ticketId);
    await docRef.UpdateAsync(new Dictionary<string, object>
    {
        { "Status",     status      },
        { "OrderIndex", orderIndex  },
        { "UpdatedAt",  DateTime.UtcNow }
    });

    var updatedTicket = await GetTicketByIdAsync(ticketId);
    if (!string.IsNullOrEmpty(projectId) && updatedTicket != null)
        await NotifyProjectUpdateAsync(projectId, "TicketStatusChanged", updatedTicket);
}

4.2.5 SignalR Hub — הצטרפות ויציאה מקבוצת פרויקט

// BoardHub.cs
public class BoardHub : Hub
{
    public async Task JoinProjectGroup(string projectId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, $"project_{projectId}");
    }

    public async Task LeaveProjectGroup(string projectId)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"project_{projectId}");
    }
}

4.2.6 שמירת session ב-sessionStorage של הדפדפן

// AuthenticationService.cs — DTO ללא PasswordHash!
public class UserSessionData
{
    public string? Id { get; set; }
    public string  Username  { get; set; } = string.Empty;
    public string  Email     { get; set; } = string.Empty;
    /* ... JobTitle, Department, AvatarUrl, Bio ... */
}

public string? GetSessionData()
{
    if (CurrentUser == null) return null;
    var data = new UserSessionData { /* ... ללא PasswordHash! */ };
    return JsonSerializer.Serialize(data);
}

4.3 מסמך בדיקות

4.3.1 בדיקות שתוכננו באפיון

T1 — הרשמה תקינה

מטרה: לוודא שמשתמש חדש נוצר ב-Firestore עם hash ולא עם סיסמה גלויה.
בוצע בפועל: נפתח מסך Signup, הוזנו Username=testuser, Password=Pass1234. נבדק Firestore Console.
תוצאות: מסמך נוצר ב-users, PasswordHash התחיל ב-$2a$11$ (BCrypt) — .
בעיות שהתגלו: לא התגלו.

T2 — הרשמה עם שם קיים

מטרה: ודא דחיית שם משתמש כפול.
בוצע: ניסיון להרשם פעם שנייה עם Username=testuser.
תוצאות: הודעת "Username already exists." — .
בעיות: בתחילה ההודעה הופיעה רק לאחר רפרוש; נוסף state binding נכון ו-StateHasChanged.

T3 — התחברות תקינה

מטרה: BCrypt.Verify מזהה נכון סיסמה.
בוצע: כניסה admin/admin.
תוצאות: הפניה ל-Dashboard, CurrentUser מוצג — .
בעיות: לא התגלו.

T4 — סיסמה שגויה

מטרה: דחיית סיסמה לא נכונה.
בוצע: admin/wrong.
תוצאות: "Invalid username or password." — . ההודעה זהה לזו שהוחזרה בעת "משתמש לא קיים" (enumeration prevention).
בעיות: לא התגלו.

T5 — יצירת ticket ו-broadcast

מטרה: יצירת ticket ב-browser A תשתקף ב-browser B באותו פרויקט.
בוצע: נפתחו שני חלונות (רגיל + incognito), שניהם מחוברים לפרויקט WG. נוצר ticket ב-A.
תוצאות: ticket הופיע ב-B תוך ~50ms — .
בעיות: בתחילה ב-B לא התעדכן — התגלה שהוזנח לקרוא JoinProjectGroup ב-OnAfterRenderAsync. תוקן.

T6 — גרירת ticket

מטרה: שינוי Status ו-OrderIndex דרך drag-and-drop.
בוצע: גרירה מ-Backlog ל-InProgress.
תוצאות: ב-Firestore Status="InProgress", OrderIndex מעודכן — .
בעיות: בתחילה ה-OrderIndex של יתר הכרטיסים בעמודת היעד לא התאזן — נוסף חישוב reindex בצד לקוח לפני שליחה.

T7 — קוד הזמנה תקף

מטרה: שימוש בקוד חוקי מצרף משתמש לפרויקט.
בוצע: הופק קוד ב-ProjectSettings, נכנס משתמש B עם הקוד.
תוצאות: B הופיע ב-ParticipantIds, הקוד סומן IsUsed=true — .
בעיות: לא התגלו.

T8 — קוד הזמנה פג תוקף

מטרה: דחיית קוד שהוא >24h.
בוצע: שינוי ידני של ExpiresAt ב-Firestore ל-‎-1h, ניסיון שימוש.
תוצאות: UseInvitationCodeAsync החזיר false.
בעיות: לא התגלו.

T9 — הוספת תגובה

מטרה: תגובה נשמרת ומופצת ב-SignalR.
בוצע: הוספת comment מ-A, צפייה ב-B.
תוצאות: התגובה הופיעה ב-B מיידית — .
בעיות: תקלה ראשונית — comments הוצגו בסדר לא תקין. נוסף OrderBy(c => c.CreatedAt) בצד-לקוח (ללא אינדקס מורכב ב-Firestore).

T10 — Logout

מטרה: Logout מנקה CurrentUser ו-sessionStorage.
בוצע: לחיצה על "Logout".
תוצאות: הפניה ל-Login, sessionStorage ריק — .
בעיות: לא התגלו.

T11 — גישה לא-מורשית

מטרה: ניווט ישיר ל-/board ללא login חוסם.
בוצע: גלישה ל-/board בלשונית incognito.
תוצאות: הפניה ל-/login — .
בעיות: לא התגלו.

T12 — הזרקת קלט זדוני

מטרה: ה-SDK של Firestore מטפל ב-parameterization.
בוצע: הזנת ' OR '1'='1 בשדה username.
תוצאות: לא נמצא משתמש, הוחזר "Invalid" — . לא התרחשה שאילתה רחבה. כמו-כן הוזנה פקודה דמוית-XSS כתוכן ticket — הופיעה כטקסט מלא ולא הורצה.
בעיות: לא התגלו.

4.3.2 בדיקות נוספות (Beyond-Plan)

T13 — Auto-reconnect לאחר ניתוק רשת

מטרה: להבטיח התאוששות מאי-יציבות רשת.
בוצע: בזמן עבודה — ניתקתי Wi-Fi ל-5 שניות, ואז חיברתי מחדש.
תוצאות: Blazor Server הציג הודעת "Reconnecting…" ולאחר שחזור החיבור ממשיך רגיל — .
בעיות: לא התגלו (auto-reconnect ברירת מחדל).

T14 — מחיקת פרויקט מנקה tickets, comments, invitations

מטרה: אין "orphan rows".
בוצע: יצירת פרויקט דוגמה, 3 tickets עם 2 comments כל אחד, 1 invitation code; מחיקת הפרויקט.
תוצאות: Firestore Console — אפס רשומות מחוברות — . תוקן לאחר שגילינו שבתחילה נשארו comments (נוסף DeleteCommentsByTicketIdAsync).

T15 — שני משתמשים גוררים את אותו ticket

מטרה: ניסיון ליצור race condition.
בוצע: A ו-B גוררים את אותו ticket כמעט בו-זמנית לעמודות שונות.
תוצאות: השינוי האחרון זוכה (Firestore SetAsync אטומי ברמת document), ושני המשתמשים רואים את אותו מצב סופי ב-broadcast — .

T16 — ביצועים: טעינת לוח עם 50 tickets

מטרה: וידוא UX סביר.
בוצע: seed של 50 tickets, מדידת זמן טעינה.
תוצאות: זמן טעינה ממוצע ~420ms מעל חיבור ביתי — .

5. מדריך למשתמש

5.1 עץ קבצי המערכת

WorkGrid/
├── WorkGrid.sln
└── WorkGrid/
    ├── Program.cs                 # נקודת כניסה + הרכבת DI + Seeding
    ├── WorkGrid.csproj            # הגדרת פרויקט + NuGet
    ├── appsettings.json
    ├── appsettings.Development.json
    ├── workgrid-988f8-...-json    # Service Account credentials (Firebase)
    ├── Hubs/
    │   └── BoardHub.cs            # SignalR hub (Join/Leave group)
    ├── Models/
    │   ├── User.cs
    │   ├── Project.cs
    │   ├── Ticket.cs              # + enumim: TicketStatus/Priority/Type
    │   ├── Comment.cs
    │   └── InvitationCode.cs
    ├── Services/
    │   ├── AuthenticationService.cs   # Login/Signup/Session/ChangePassword
    │   ├── FirestoreService.cs        # CRUD + broadcast
    │   └── DataSeedService.cs         # admin + sample project
    ├── Components/
    │   ├── App.razor
    │   ├── Routes.razor
    │   ├── _Imports.razor
    │   ├── Layout/
    │   │   ├── MainLayout.razor(.css)
    │   │   ├── LoginLayout.razor(.css)
    │   │   └── NavMenu.razor(.css)
    │   └── Pages/
    │       ├── Login.razor(.css)
    │       ├── Signup.razor(.css)
    │       ├── Home.razor(.css)
    │       ├── Dashboard.razor(.css)
    │       ├── Projects.razor(.css)
    │       ├── Board.razor(.css)
    │       ├── ProjectSettings.razor(.css)
    │       ├── Profile.razor(.css)
    │       ├── Admin.razor(.css)
    │       ├── Counter.razor
    │       ├── Weather.razor
    │       └── Error.razor
    └── wwwroot/
        ├── css/
        ├── js/
        └── images/

5.2 התקנת המערכת

5.2.1 פירוט הסביבה הנדרשת

5.2.2 הכלים הנדרשים

5.2.3 מיקומי קבצים ונתוני אתחול

  1. Clone של הרפוזיטורי: git clone https://gitlab.com/myprojects5451850/workgrid.git
  2. הורדת קובץ Service Account JSON מ-Firebase Console ושמירתו כ-workgrid-988f8-firebase-adminsdk-...json בתיקיית הפרויקט (מוגדר כ-CopyToOutputDirectory=PreserveNewest).
  3. dotnet restore לשליפת NuGet packages.
  4. dotnet run מתיקיית WorkGrid/. ה-Seeding יוצר אוטומטית: משתמש admin / סיסמה admin, פרויקט "WorkGrid Platform" (key=WG) ו-10 tickets.
  5. גישה דרך https://localhost:7xxx/ (הפורט מופיע בקונסולה).
סיסמת ברירת המחדל של admin היא admin. בשימוש אמיתי יש לשנות אותה מיד בכניסה הראשונה דרך מסך Profile → Change Password.

5.2.4 רשת וארכיטקטורה מינימלית

5.3 הפעלה לפי סוג משתמש

5.3.1 משתמש חדש — הרשמה

  1. גישה לכתובת הבסיסית → הפניה למסך Login.
  2. לחיצה על הקישור "Sign up" בתחתית הטופס.
  3. מילוי הטופס: Username (מינ׳ 3 תווים), Email, Full name, Password (מינ׳ 4), אימות סיסמה.
  4. לחיצה על "Create account" → נוצר חשבון והדפדפן מופנה לדשבורד.
[צילום מסך: Signup — טופס ההרשמה · להחליף]

5.3.2 כניסה ויציאה

  1. הזנת Username ו-Password במסך Login.
  2. לחיצת Login — אם התאמה: הפניה לדשבורד + שמירת session ב-sessionStorage.
  3. יציאה: לחיצה על "Logout" בתפריט הצד — ניקוי session והפניה ל-Login.
[צילום מסך: Login — כניסה · להחליף]

5.3.3 משתמש רשום (Participant) — עבודה שוטפת

  1. במסך Projects מוצגים הפרויקטים בהם המשתמש חבר. כרטיס לחיץ פותח את הלוח.
  2. במסך Board: גרירת כרטיסים בין העמודות, לחיצה על כרטיס לפתיחת פרטים, הוספת תגובה.
  3. יצירת ticket חדש: כפתור "+ New ticket" → מילוי כותרת, תיאור, סוג, עדיפות, story points.
  4. הצטרפות לפרויקט חדש: במסך Projects → "Join with code" → הזנת הקוד (6 תווים) שהתקבל מבעל הפרויקט.
[צילום מסך: Board — לוח Kanban · להחליף]

5.3.4 בעל פרויקט (Owner)

  1. כפתור "+ Create project" במסך Projects → מילוי Name, Description, Key (2–5 אותיות גדולות).
  2. Project Settings (אייקון גלגל השיניים) → עדכון פרטים, "Generate invitation code" יוצר קוד בן 6 תווים התקף 24 שעות.
  3. שליחת הקוד למשתמש אחר (WhatsApp/Email/וכו׳).
  4. מחיקת פרויקט: "Delete project" + אישור — מוחק את הפרויקט, כל הכרטיסים, התגובות והקודים הקשורים.
[צילום מסך: ProjectSettings — קוד הזמנה · להחליף]

5.3.5 מנהל מערכת (Administrator)

  1. כניסה עם שם משתמש admin.
  2. במסך Admin: דשבורד מערכת ומידע על כלל המשתמשים והפרויקטים.
[צילום מסך: Admin — דשבורד מנהל · להחליף]

5.3.6 עריכת פרופיל

  1. תפריט צד ← Profile.
  2. עדכון שם מלא, דוא״ל, תפקיד, מחלקה, bio, URL של אווטאר.
  3. שינוי סיסמה: "Change password" — סיסמה נוכחית, חדשה, אימות. hash חדש נשמר ב-Firestore.
[צילום מסך: Profile · להחליף]

6. סיכום אישי / רפלקציה

תהליך העבודה, הצלחות ואתגרים

העבודה על WorkGrid נמשכה כשנת לימודים שלמה וכללה שלבים של למידה, תכנון, מימוש ובדיקות. בתחילה התמקדתי בלמידת Blazor Server — טכנולוגיה חדשה עבורי שמחייבת הבנת מודל ה-circuit: כל אינטראקציה של המשתמש מתורגמת ל-RPC מעל SignalR אל השרת, והשרת מחזיר patch ל-DOM. היה לא-טריוויאלי להבין איפה בדיוק רץ קוד (שרת או לקוח), ומהן ההשלכות של זה על State, על validation ועל אבטחה. הצלחה ראשונה הייתה הקמת מערכת האימות המלאה (Signup, Login, Session restore) ואימות שהסיסמאות אכן נשמרות כ-hash של BCrypt ולא בטקסט גלוי.

האתגר המרכזי שעבדתי עליו הכי הרבה היה ה-drag-and-drop על לוח ה-Kanban בשילוב real-time updates. המימוש הראשוני גרם לעתים לכרטיסים "לקפוץ" לאחר שחרור כי עדכון ה-SignalR מצד השרת הוחזר לפני שה-UI המקומי התייצב. הפתרון היה להפוך את הזרימה לאופטימיסטית: הלקוח מעדכן את ה-DOM מיידית, ורק לאחר ה-ACK מהשרת מכיר בסדר הסופי. בנוסף, היה עלי לתכנן reindex של OrderIndex בצד הלקוח — בעיה אלגוריתמית שדורשת שיקול דעת על כל הכרטיסים בעמודת היעד ולא רק על הנגרר.

אתגר נוסף היה הבנה של Firestore כ-NoSQL. הגעתי עם מנטליות רלציונית, וחיפשתי JOINs במקום לחשוב בדוקומנטים. למדתי להשטיח נתונים (denormalization): ProjectIds על tickets במקום sub-collections, ו-client-side OrderBy במקום לבקש מ-Firestore אינדקס מורכב שדורש הגדרה ב-Firebase Console. זה שיפר גם את הביצועים וגם את פשטות הקוד.

קושי שהצריך חקר עצמי עמוק היה Blazor Server circuit lifecycle. הפרויקט שלי נשבר כל פעם שהטאב איבד focus לכמה דקות — הפתרון היה להבין שה-circuit לא נעלם אלא נכנס ל-"disconnected" ולכתוב handler שמשחזר את ה-SignalR connection ב-OnAfterRenderAsync לאחר reconnect.

תהליך למידה

במהלך הפרויקט למדתי באופן עצמאי את הנושאים הבאים: Blazor Server Interactive Components (לעומת WebAssembly — שפסלתי מהר כי הוא דורש הורדה של CLR לדפדפן), SignalR Hub-to-Group broadcast, Google Firestore SDK for .NET, BCrypt password hashing, HTML5 Drag and Drop API, SessionStorage via JSInterop, ו-ASP.NET Core Dependency Injection scopes (ההבדל בין Singleton ל-Scoped ל-Transient — וכמה חשוב לבחור נכון; למשל, FirestoreService חייב להיות Singleton מכיוון שיצירת FirestoreDb יקרה, ואילו AuthenticationService חייב להיות Scoped כדי שכל session תחזיק את ה-CurrentUser שלה).

כלים שנלקחים להמשך

תובנות מהתהליך

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

קיבלתי עזרה רבה מהמנחה שלי — במיוחד סביב הבחירות האדריכליות (Blazor Server vs WASM) ובדיוק הטיפול באבטחה. שיתוף הקוד עם חברים לכיתה הוביל ל-code review הדדי שחשף באג של race condition שלא הייתי מוצא לבד. למידת עמיתים (Stack Overflow, Microsoft Learn, YouTube — Nick Chapsas ו-Gavin Lon) היו חיוניים.

בראייה לאחור — מה הייתי משנה

הייתי בוחר מהשלב הראשון ב-Blazor WebAssembly עם API נפרד (ASP.NET Core Minimal API) במקום Blazor Server — זה מקטין את תלות ה-UI בחיבור חי ונותן UX יציב יותר ברשתות חלשות. בנוסף, הייתי משתמש ב-Firestore Listener (SDK real-time) במקום לגלגל broadcast ידני דרך SignalR — פחות קוד ו-fewer race conditions. הייתי גם מתחיל עם Entity relationships מתוכננים מראש (UML + ERD) לפני הקוד הראשון.

אילו שיפורים ניתן היה לבצע עם משאבים נוספים

שאלות לחקר עצמי

  1. מהם ה-trade-offs בין Blazor Server ל-Blazor WebAssembly עבור אפליקציה שיתופית?
  2. כיצד מגנים על SignalR Hub מ-connection flooding ללא rate-limiter מובנה?
  3. מהן ההשלכות של scaling אופקי של Blazor Server על state שנשמר בזיכרון השרת?
  4. האם ניתן להשיג real-time UX טוב יותר עם Firestore snapshot listeners ישירות מהדפדפן?

תודות

תודה למנחה שלי [למלא: שם המנחה] על הליווי הצמוד, על שאלות חכמות ועל ה-code reviews שהפכו את הקוד שלי לבוגר יותר. תודה לצוות המורים של [למלא: שם בית הספר]. תודה גם למשפחה שלי על הסבלנות בשעות הלילה, ולחברים לכיתה שהסכימו להיות "Browser B" בבדיקות real-time.

7. ביבליוגרפיה

הרשימה שלהלן מסודרת לפי כללי ה-APA 7. מקורות מסוגים שונים: תיעוד רשמי, מאמרים אקדמיים, ספרים, אתרי מידע וסרטוני וידאו.

תיעוד רשמי

  1. Microsoft. (2024). ASP.NET Core Blazor. Microsoft Learn. https://learn.microsoft.com/aspnet/core/blazor/
  2. Microsoft. (2024). ASP.NET Core SignalR. Microsoft Learn. https://learn.microsoft.com/aspnet/core/signalr/
  3. Google. (2024). Cloud Firestore client libraries — .NET. Google Cloud Documentation. https://cloud.google.com/firestore/docs/quickstart-servers
  4. Microsoft. (2024). Dependency injection in ASP.NET Core. Microsoft Learn. https://learn.microsoft.com/aspnet/core/fundamentals/dependency-injection

אבטחה

  1. Provos, N., & Mazières, D. (1999). A Future-Adaptable Password Scheme. Proceedings of the 1999 USENIX Annual Technical Conference, 81–91.
  2. OWASP Foundation. (2021). OWASP Top Ten 2021. https://owasp.org/Top10/
  3. OWASP Foundation. (2023). Password Storage Cheat Sheet. cheatsheetseries.owasp.org
  4. Rescorla, E. (2018). The Transport Layer Security (TLS) Protocol Version 1.3 (RFC 8446). IETF. datatracker.ietf.org/doc/html/rfc8446
  5. Fette, I., & Melnikov, A. (2011). The WebSocket Protocol (RFC 6455). IETF. datatracker.ietf.org/doc/html/rfc6455

ספרים

  1. Tanenbaum, A. S., & Wetherall, D. J. (2010). Computer Networks (5th ed.). Pearson.
  2. Silberschatz, A., Galvin, P. B., & Gagne, G. (2018). Operating System Concepts (10th ed.). Wiley.
  3. Sadalage, P. J., & Fowler, M. (2013). NoSQL Distilled: A Brief Guide to the Emerging World of Polyglot Persistence. Addison-Wesley.
  4. Richter, J. (2012). CLR via C# (4th ed.). Microsoft Press.

מדריכים וסרטונים

  1. Chapsas, N. (2023, March 8). Blazor Server vs WebAssembly — Which should you pick? [Video]. YouTube.
  2. Roth, D. (2023). Blazor Deep Dive. .NET Conf 2023.
  3. BCrypt.Net. (2023). BCrypt.Net-Next README. GitHub. github.com/BcryptNet/bcrypt.net

8. נספחים

8.1 הסברים טכנולוגיים

Blazor Server — איך זה עובד באמת?

Blazor Server הוא מודל שבו כל הקומפוננטות של Razor רצות על השרת. כאשר המשתמש מבצע פעולה (לחיצה, הקלדה), הדפדפן שולח RPC קטן דרך SignalR circuit אל השרת; השרת מריץ את ההנדלר הרלוונטי, מחשב מחדש את ה-render tree של הקומפוננטה, משווה ל-tree הקודם (diffing), ושולח פאטץ' מינימלי חזרה ל-DOM. הפאטץ' מוחל ב-JS קטן ב-client. היתרון: כל הקוד ב-C# (אין JavaScript), הקוד לא מגיע לדפדפן (אבטחה טובה יותר). החיסרון: תלות ברשת חיה, ועלויות זיכרון על השרת פר-חיבור.

SignalR Groups — למה לא לעשות broadcast לכולם?

SignalR מאפשרת לשייך כל ConnectionId לאחת או יותר מ-"קבוצות". כאשר השרת שולח SendAsync לקבוצה "project_X", רק הלקוחות החברים בה יקבלו את ההודעה — יעיל בהרבה מ-SendAll, במיוחד עבור מערכת עם מאות פרויקטים שרצים בו-זמנית.

NoSQL Firestore — מה ההבדל ממסד רלציוני?

אין טבלאות — רק collections של documents. כל document הוא JSON-like של שדות (מחרוזות, מספרים, מערכים, תתי-אובייקטים). אין JOINs: אם רוצים "tickets של פרויקט X", מבצעים שאילתה על הקולקשן tickets עם WhereEqualTo("ProjectId", X). אינדקסים בסיסיים נבנים אוטומטית; אינדקסים מורכבים (WhereEqualTo + OrderBy על שדות שונים) דורשים הגדרה ידנית ב-Firebase Console. ב-WorkGrid ויתרנו עליהם ומיינו בצד-לקוח (אוסף בדרך כלל מכיל <200 מסמכים, אז זה מהיר).

BCrypt — למה הוא בטוח?

BCrypt מתבסס על מפתח של Blowfish ועל work factor מכוונן (ברירת מחדל 11 = 211=2048 איטרציות פנימיות). כל hash מכיל salt אקראי של 16 בייט, כך שגם אם שני משתמשים בחרו את אותה הסיסמה — ה-hashים שונים לחלוטין. Rainbow tables לא רלוונטיים. ולא פחות חשוב: הפונקציה איטית במכוון — ~100ms לחישוב — כך שבטאק ברוט-פורס אוטומטי מצליח לנסות רק עשרות אלפי סיסמאות בשנייה, לא מיליארדים.

8.2 תדפיס קוד הפרויקט

להלן תדפיס מסודר של קבצי ה-.cs המרכזיים במערכת. קבצי .razor ו-.css נותרו ברפוזיטורי. כל קטע מנוקד במספר קבצים.

A. Program.cs

using WorkGrid.Components;
using WorkGrid.Services;
using WorkGrid.Hubs;
using Microsoft.AspNetCore.SignalR;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents();

// Add SignalR
builder.Services.AddSignalR();

// Configure Firebase/Firestore
var credentialPath = Path.Combine(builder.Environment.ContentRootPath,
    "workgrid-988f8-firebase-adminsdk-fbsvc-0573c3c764.json");
var projectId = "workgrid-988f8";

builder.Services.AddSingleton(sp =>
{
    var hubContext = sp.GetRequiredService<IHubContext<BoardHub>>();
    return new FirestoreService(projectId, credentialPath, hubContext);
});
builder.Services.AddScoped<AuthenticationService>();

var app = builder.Build();

// Seed sample data (admin user, sample project, and tickets)
using (var scope = app.Services.CreateScope())
{
    var firestoreService = scope.ServiceProvider.GetRequiredService<FirestoreService>();
    var dataSeedService = new DataSeedService(firestoreService);
    await dataSeedService.SeedDataAsync();
}

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler("/Error", createScopeForErrors: true);
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

// Map SignalR hub
app.MapHub<BoardHub>("/boardhub");

app.Run();

B. Hubs/BoardHub.cs

using Microsoft.AspNetCore.SignalR;

namespace WorkGrid.Hubs;

public class BoardHub : Hub
{
    public async Task JoinProjectGroup(string projectId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, $"project_{projectId}");
    }

    public async Task LeaveProjectGroup(string projectId)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"project_{projectId}");
    }
}

C. Models/User.cs

using Google.Cloud.Firestore;

namespace WorkGrid.Models;

[FirestoreData]
public class User
{
    [FirestoreDocumentId]
    public string? Id { get; set; }

    [FirestoreProperty] public string Username     { get; set; } = string.Empty;
    [FirestoreProperty] public string PasswordHash { get; set; } = string.Empty;
    [FirestoreProperty] public string Email        { get; set; } = string.Empty;
    [FirestoreProperty] public string FullName     { get; set; } = string.Empty;
    [FirestoreProperty] public string JobTitle     { get; set; } = string.Empty;
    [FirestoreProperty] public string Department   { get; set; } = string.Empty;
    [FirestoreProperty] public string AvatarUrl    { get; set; } = string.Empty;
    [FirestoreProperty] public string Bio          { get; set; } = string.Empty;
    [FirestoreProperty] public DateTime CreatedAt  { get; set; } = DateTime.UtcNow;
    [FirestoreProperty] public DateTime UpdatedAt  { get; set; } = DateTime.UtcNow;
}

D. Models/Project.cs

using Google.Cloud.Firestore;

namespace WorkGrid.Models;

[FirestoreData]
public class Project
{
    [FirestoreDocumentId] public string? Id { get; set; }
    [FirestoreProperty]   public string Name        { get; set; } = string.Empty;
    [FirestoreProperty]   public string Description { get; set; } = string.Empty;
    [FirestoreProperty]   public string Key         { get; set; } = string.Empty;
    [FirestoreProperty]   public List<string> ParticipantIds { get; set; } = new();
    [FirestoreProperty]   public string OwnerId     { get; set; } = string.Empty;
    [FirestoreProperty]   public DateTime CreatedAt  { get; set; } = DateTime.UtcNow;
    [FirestoreProperty]   public DateTime UpdatedAt  { get; set; } = DateTime.UtcNow;
}

E. Models/Ticket.cs

using Google.Cloud.Firestore;
namespace WorkGrid.Models;

public enum TicketStatus   { Backlog, InProgress, InReview, Done }
public enum TicketPriority { Low, Medium, High, Critical }
public enum TicketType     { Task, Bug, Feature, Story }

[FirestoreData]
public class Ticket
{
    [FirestoreDocumentId] public string? Id { get; set; }
    [FirestoreProperty]   public string ProjectId    { get; set; } = string.Empty;
    [FirestoreProperty]   public string Title        { get; set; } = string.Empty;
    [FirestoreProperty]   public string Description  { get; set; } = string.Empty;
    [FirestoreProperty]   public string TicketNumber { get; set; } = string.Empty;
    [FirestoreProperty]   public string Status       { get; set; } = nameof(TicketStatus.Backlog);
    [FirestoreProperty]   public string Priority     { get; set; } = nameof(TicketPriority.Medium);
    [FirestoreProperty]   public string Type         { get; set; } = nameof(TicketType.Task);
    [FirestoreProperty]   public string? AssigneeId  { get; set; }
    [FirestoreProperty]   public string  ReporterId  { get; set; } = string.Empty;
    [FirestoreProperty]   public int     StoryPoints { get; set; } = 0;
    [FirestoreProperty]   public DateTime CreatedAt  { get; set; } = DateTime.UtcNow;
    [FirestoreProperty]   public DateTime UpdatedAt  { get; set; } = DateTime.UtcNow;
    [FirestoreProperty]   public int     OrderIndex  { get; set; } = 0;
}

F. Models/Comment.cs

using Google.Cloud.Firestore;
namespace WorkGrid.Models;

[FirestoreData]
public class Comment
{
    [FirestoreDocumentId] public string? Id { get; set; }
    [FirestoreProperty]   public string TicketId  { get; set; } = string.Empty;
    [FirestoreProperty]   public string AuthorId  { get; set; } = string.Empty;
    [FirestoreProperty]   public string Content   { get; set; } = string.Empty;
    [FirestoreProperty]   public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    [FirestoreProperty]   public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

G. Models/InvitationCode.cs

using Google.Cloud.Firestore;
namespace WorkGrid.Models;

[FirestoreData]
public class InvitationCode
{
    [FirestoreDocumentId] public string? Id { get; set; }
    [FirestoreProperty]   public string Code         { get; set; } = string.Empty;
    [FirestoreProperty]   public string ProjectId    { get; set; } = string.Empty;
    [FirestoreProperty]   public string CreatedById  { get; set; } = string.Empty;
    [FirestoreProperty]   public DateTime CreatedAt   { get; set; } = DateTime.UtcNow;
    [FirestoreProperty]   public DateTime ExpiresAt   { get; set; } = DateTime.UtcNow.AddHours(24);
    [FirestoreProperty]   public bool   IsUsed      { get; set; } = false;
    [FirestoreProperty]   public string? UsedByUserId { get; set; }
    [FirestoreProperty]   public DateTime? UsedAt      { get; set; }

    public bool IsExpired => DateTime.UtcNow > ExpiresAt;
    public bool IsValid   => !IsUsed && !IsExpired;
}

H. Services/AuthenticationService.cs (מקוצר לציון החשוב)

using WorkGrid.Models;
using System.Text.Json;

namespace WorkGrid.Services;

public class AuthenticationService
{
    private readonly FirestoreService _firestoreService;

    public User? CurrentUser { get; private set; }
    public bool IsAuthenticated => CurrentUser != null;
    public event Action? OnAuthStateChanged;

    public AuthenticationService(FirestoreService firestoreService)
        => _firestoreService = firestoreService;

    public async Task<(bool Success, string Message)> LoginAsync(string username, string password)
    {
        if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
            return (false, "Username and password are required.");

        var user = await _firestoreService.GetUserByUsernameAsync(username);
        if (user == null)  return (false, "Invalid username or password.");
        if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
            return (false, "Invalid username or password.");

        CurrentUser = user;
        OnAuthStateChanged?.Invoke();
        return (true, "Login successful!");
    }

    public void Logout() { CurrentUser = null; OnAuthStateChanged?.Invoke(); }

    public class UserSessionData
    {
        public string? Id { get; set; }
        public string Username { get; set; } = string.Empty;
        public string Email    { get; set; } = string.Empty;
        public string FullName { get; set; } = string.Empty;
        public string JobTitle { get; set; } = string.Empty;
        public string Department{ get; set; } = string.Empty;
        public string AvatarUrl{ get; set; } = string.Empty;
        public string Bio      { get; set; } = string.Empty;
    }

    public string? GetSessionData()
    {
        if (CurrentUser == null) return null;
        var data = new UserSessionData { /* without PasswordHash */
            Id = CurrentUser.Id, Username = CurrentUser.Username,
            Email = CurrentUser.Email, FullName = CurrentUser.FullName,
            JobTitle = CurrentUser.JobTitle, Department = CurrentUser.Department,
            AvatarUrl = CurrentUser.AvatarUrl, Bio = CurrentUser.Bio };
        return JsonSerializer.Serialize(data);
    }

    public async Task<bool> RestoreSessionAsync(string? sessionData)
    {
        if (string.IsNullOrEmpty(sessionData)) return false;
        try
        {
            var d = JsonSerializer.Deserialize<UserSessionData>(sessionData);
            if (d == null || string.IsNullOrEmpty(d.Username)) return false;
            var user = await _firestoreService.GetUserByUsernameAsync(d.Username);
            if (user == null) return false;
            CurrentUser = user; OnAuthStateChanged?.Invoke(); return true;
        }
        catch (JsonException) { return false; }
    }

    public async Task<(bool Success, string Message)> SignupAsync(
        string username, string email, string fullName,
        string password, string confirmPassword,
        string? jobTitle = null, string? department = null)
    {
        /* אימותים: שדות לא ריקים, אורך מינימלי, סיסמאות זהות, username לא קיים */
        if (await _firestoreService.UserExistsAsync(username))
            return (false, "Username already exists.");

        var user = new User
        {
            Username = username, Email = email, FullName = fullName,
            PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
            JobTitle = jobTitle ?? string.Empty,
            Department = department ?? string.Empty,
            CreatedAt = DateTime.UtcNow
        };
        await _firestoreService.CreateUserAsync(user);
        CurrentUser = user; OnAuthStateChanged?.Invoke();
        return (true, "Account created successfully!");
    }

    public async Task<(bool, string)> ChangePasswordAsync(string cur, string @new, string confirm)
    {
        if (CurrentUser == null) return (false, "Must be logged in.");
        if (!BCrypt.Net.BCrypt.Verify(cur, CurrentUser.PasswordHash))
            return (false, "Current password is incorrect.");
        if (@new.Length < 4)      return (false, "New password too short.");
        if (@new != confirm)     return (false, "New passwords do not match.");
        CurrentUser.PasswordHash = BCrypt.Net.BCrypt.HashPassword(@new);
        await _firestoreService.UpdateUserAsync(CurrentUser);
        return (true, "Password changed.");
    }
}

I. Services/FirestoreService.cs (עיקרי — נבחרו פעולות-מפתח)

using Google.Cloud.Firestore;
using WorkGrid.Models;
using Microsoft.AspNetCore.SignalR;
using WorkGrid.Hubs;

namespace WorkGrid.Services;

public class FirestoreService
{
    private readonly FirestoreDb _db;
    private readonly IHubContext<BoardHub>? _hubContext;
    private const string UsersCollection           = "users";
    private const string ProjectsCollection        = "projects";
    private const string TicketsCollection         = "tickets";
    private const string InvitationCodesCollection = "invitation_codes";
    private const string CommentsCollection        = "comments";

    public FirestoreService(string projectId, string credentialPath, IHubContext<BoardHub>? hubContext = null)
    {
        Environment.SetEnvironmentVariable("GOOGLE_APPLICATION_CREDENTIALS", credentialPath);
        _db = FirestoreDb.Create(projectId);
        _hubContext = hubContext;
    }

    private async Task NotifyProjectUpdateAsync(string projectId, string updateType, object? data = null)
    {
        if (_hubContext != null)
            await _hubContext.Clients.Group($"project_{projectId}")
                .SendAsync("TicketUpdated", updateType, data);
    }

    // ============== Users ==============
    public async Task<User?> GetUserByUsernameAsync(string username)
    {
        var query = _db.Collection(UsersCollection).WhereEqualTo("Username", username).Limit(1);
        var snapshot = await query.GetSnapshotAsync();
        return snapshot.Documents.Count == 0 ? null : snapshot.Documents[0].ConvertTo<User>();
    }
    public async Task<User> CreateUserAsync(User user)
    {
        var docRef = _db.Collection(UsersCollection).Document();
        user.Id = docRef.Id;
        await docRef.SetAsync(user);
        return user;
    }

    // ============== Tickets ==============
    public async Task<List<Ticket>> GetTicketsByProjectIdAsync(string projectId)
    {
        var query = _db.Collection(TicketsCollection).WhereEqualTo("ProjectId", projectId);
        var snapshot = await query.GetSnapshotAsync();
        return snapshot.Documents
            .Select(d => d.ConvertTo<Ticket>())
            .OrderBy(t => t.OrderIndex)   // sort client-side to avoid composite index
            .ToList();
    }
    public async Task<Ticket> CreateTicketAsync(Ticket ticket)
    {
        var docRef = _db.Collection(TicketsCollection).Document();
        ticket.Id = docRef.Id;
        await docRef.SetAsync(ticket);
        await NotifyProjectUpdateAsync(ticket.ProjectId, "TicketCreated", ticket);
        return ticket;
    }
    public async Task UpdateTicketStatusAsync(string ticketId, string status, int orderIndex)
    {
        var ticket = await GetTicketByIdAsync(ticketId);
        var projectId = ticket?.ProjectId;
        var docRef = _db.Collection(TicketsCollection).Document(ticketId);
        await docRef.UpdateAsync(new Dictionary<string, object>
        {
            { "Status",     status      },
            { "OrderIndex", orderIndex  },
            { "UpdatedAt",  DateTime.UtcNow }
        });
        var updated = await GetTicketByIdAsync(ticketId);
        if (!string.IsNullOrEmpty(projectId) && updated != null)
            await NotifyProjectUpdateAsync(projectId, "TicketStatusChanged", updated);
    }

    // ============== Invitation Codes ==============
    private static readonly Random _random = new();
    private const string CodeChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";

    public string GenerateInvitationCode()
        => new string(Enumerable.Range(0, 6)
            .Select(_ => CodeChars[_random.Next(CodeChars.Length)])
            .ToArray());

    public async Task<bool> UseInvitationCodeAsync(string code, string userId)
    {
        var inv = await GetInvitationCodeByCodeAsync(code);
        if (inv == null || !inv.IsValid) return false;
        var proj = await GetProjectByIdAsync(inv.ProjectId);
        if (proj == null || proj.ParticipantIds.Contains(userId)) return false;

        proj.ParticipantIds.Add(userId);
        await UpdateProjectAsync(proj);

        inv.IsUsed = true;
        inv.UsedByUserId = userId;
        inv.UsedAt = DateTime.UtcNow;
        await _db.Collection(InvitationCodesCollection).Document(inv.Id!)
            .SetAsync(inv, SetOptions.Overwrite);
        return true;
    }

    // ============== Comments ==============
    public async Task<Comment> CreateCommentAsync(Comment comment)
    {
        // validations: TicketId / AuthorId / Content not empty
        var docRef = _db.Collection(CommentsCollection).Document();
        comment.Id = docRef.Id;
        await docRef.SetAsync(comment);
        var ticket = await GetTicketByIdAsync(comment.TicketId);
        if (ticket != null)
            await NotifyProjectUpdateAsync(ticket.ProjectId, "CommentAdded",
                new { TicketId = comment.TicketId, Comment = comment });
        return comment;
    }
}

J. Services/DataSeedService.cs (מקוצר)

public class DataSeedService
{
    private readonly FirestoreService _firestoreService;
    public DataSeedService(FirestoreService fs) => _firestoreService = fs;

    public async Task SeedDataAsync()
    {
        var admin = await SeedAdminUserAsync();
        await SeedSampleProjectAsync(admin);
    }

    private async Task<User> SeedAdminUserAsync()
    {
        var existing = await _firestoreService.GetUserByUsernameAsync("admin");
        if (existing != null) return existing;

        var admin = new User
        {
            Username     = "admin",
            PasswordHash = BCrypt.Net.BCrypt.HashPassword("admin"),
            FullName     = "Administrator",
            Email        = "admin@workgrid.io",
            JobTitle     = "System Administrator",
            Department   = "IT",
            CreatedAt    = DateTime.UtcNow
        };
        await _firestoreService.CreateUserAsync(admin);
        return admin;
    }

    // SeedSampleProjectAsync + SeedSampleTicketsAsync — creating sample WG project with 10 tickets
}
הערה: קבצי .razor (View components) נשמרים ברפוזיטורי ואינם מודפסים כאן בשל אורכם. ניתן למצוא אותם תחת Components/Pages/. ה-UI מחובר אל ה-Services בקוד מאחורי כל Page דרך [Inject].

— סוף מסמך —