[להחליף בלוגו בית הספר]
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) ואינה כוללת מנגנון שחזור סיסמה אוטומטי — אלה הושארו לפיתוח עתידי.
המערכת פועלת בדפדפן כ-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 ושהדפדפן השני מציג אותה |
| T10 | Logout מנקה 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-drop | 2 שבועות | 3 שבועות |
| 6 | SignalR 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 בכל יצירה/שינוי סיסמה |
| אובדן חיבור Firestore | try/catch + הודעות שגיאה ברורות | שולבו רשתות ביטחון בכל שירות + logging ל-Console |
| Race conditions בעדכוני לוח | עדכוני Firestore אטומיים + broadcast מאוחר | Firestore SetAsync אטומי ברמת document; broadcast רק לאחר הצלחה |
| XSS דרך content של ticket/comment | Blazor מקפל טקסט כ-text node ולא HTML | אין שימוש ב-@((MarkupString)...) בשום מקום בקוד |
פרק זה מפרט את היכולות העיקריות של המערכת בפילוח לצד שרת וצד לקוח. עבור כל יכולת ניתן שמה, מהותה, אוסף תת-הפעולות הנדרשות למימושה, והאובייקטים המעורבים בפעולתה. פירוט זה מתבצע ברמה פונקציונלית ומהווה בסיס לפרק הארכיטקטורה הבא.
users ב-Firestore; עדכון CurrentUser.AuthenticationService, FirestoreService, User, BCrypt.Net.BCrypt, FirestoreDb.WhereEqualTo("Username",...); השוואה ב-BCrypt.Verify; עדכון CurrentUser; הנפקת אירוע OnAuthStateChanged.AuthenticationService, FirestoreService, User, Query, DocumentSnapshot.IHubContext<BoardHub>.FirestoreService, Ticket, BoardHub, IHubContext, CollectionReference.Status ו-OrderIndex בלבד (עדכון חלקי, לא Overwrite) ושידור לכלל המשתתפים.UpdateAsync(Dictionary<string,object>) עם שלושה שדות בלבד; שליפה מחדש של המסמך המעודכן; שידור סוג אירוע "TicketStatusChanged".FirestoreService, Ticket, DocumentReference, BoardHub.GenerateInvitationCode() מעל אלפבית ללא תווים מטעים (O/0/I/1 מוחרגים); שמירה בקולקשן invitation_codes; אימות תקפות (IsValid = !IsUsed && !IsExpired); סימון כ-Used ושיוך המשתמש ל-ParticipantIds של הפרויקט.InvitationCode, Project, FirestoreService, Random.comments; שידור אירוע "CommentAdded"; מיון by CreatedAt בצד הלקוח.Comment, Ticket, FirestoreService, IHubContext.BoardHub.JoinProjectGroup(projectId) → Groups.AddToGroupAsync(ConnectionId, "project_{id}"); סימטרי עבור Leave.BoardHub, HubCallerContext, IGroupManager.DataSeedService, User, Project, Ticket.Board.razor; שאילתת tickets דרך FirestoreService.GetTicketsByProjectIdAsync; מיון לפי OrderIndex; פיצול בצד לקוח ל-4 רשימות.Board.razor, Ticket, FirestoreService.ondragstart, ondragover, ondrop); חישוב OrderIndex חדש על בסיס העמודה היעד; קריאה ל-UpdateTicketStatusAsync.Board.razor, JavaScript Drag&Drop API, FirestoreService.HubConnection עם HubConnectionBuilder; StartAsync; InvokeAsync("JoinProjectGroup", projectId); On<string,object>("TicketUpdated", handler).HubConnection, Board.razor.sessionStorage של הדפדפן למקרה שה-circuit מתאתחל.window.sessionStorage דרך JSInterop; שחזור ב-OnAfterRenderAsync — קריאה חוזרת של המשתמש מ-Firestore לפי ה-Username.AuthenticationService.UserSessionData, IJSRuntime.UpdateProfileAsync / ChangePasswordAsync ב-AuthenticationService; אימות סיסמה נוכחית לפני שינוי; יצירת hash חדש ב-BCrypt.Profile.razor, AuthenticationService, User.המערכת ארוגה משלושה רכיבים עיקריים: הדפדפן של הלקוח (Chrome, Edge, Firefox), שרת WorkGrid (ASP.NET Core 8.0) המריץ את Blazor Server ואת SignalR Hub, ו-Google Firestore (שירות ענן). התרשים שלהלן מציג את הקשרים ביניהם.
להלן תרשימי זרימה עבור יכולות מרכזיות.
ניסוח הבעיה: כיצד לאמת שמשתמש יודע את סיסמתו בלי לאחסן אותה בטקסט גלוי, ולהיות עמיד מול מתקפות
מילון ו-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 שגם קל להגדיר ולשלב.
ניסוח: בעת גרירת כרטיס מסטטוס X לסטטוס Y במיקום אינדקס k, יש לעדכן את הכרטיס הנגרר, וכן
(רצוי) לעדכן את שאר הכרטיסים שסביבו ב-Y כך שמיקומיהם חוקיים.
שיטה שנבחרה: שדה OrderIndex (int) על כל ticket. בעת שחרור בדרופ,
הלקוח שולח את ה-ID וה-OrderIndex החדש; השרת עושה UpdateAsync חלקי לטבלת tickets.
שלילת חלופות: שימוש בסדר לפי CreatedAt אינו נותן שליטה ידנית
על סדר; שימוש ב-linked list של Ids בתוך Project כן מספק שליטה אך הופך כל reorder לעדכון מסמך יחיד גדול ולא
אטומי. ה-OrderIndex הוא פשרה שמאפשרת עדכון נפרד לכל כרטיס ומיון בצד-לקוח אחרי שאילתה.
6 תווים אקראיים מבוחרים מאלפבית של 32 תווים שהוחרגו ממנו O, 0, I, 1 (אותיות ומספרים
דומות) כדי למנוע בלבול בין משתמשים שמקלידים ידנית. מרחב האפשרויות: 326 ≈ 1.07×109
— מספיק גדול כדי למנוע ניחוש ב-brute force ברגע בו יש לקוד חיים של 24 שעות בלבד.
שלילה: שימוש ב-GUID (128 ביט) היה מאובטח יותר אך לא נוח למשתמש להקליד; קוד קצר של 4 תווים היה רגיש מדי לניחוש; 6 תווים בפורמט אלפאנומרי מאוזנים בין אבטחה לנוחות. הפנייה: OWASP ASVS section 6.3.3.
Google.Cloud.Firestore 3.4.0, BCrypt.Net-Next 4.0.3, Microsoft.AspNetCore.SignalR.Client 8.0.0המערכת מסתמכת על שלושה ערוצי תקשורת: HTTPS/TLS לשליפת ה-Razor Components ולהשקת ה-circuit הראשונית, WebSocket Secure (wss://) דרך SignalR circuit לכל פעולה אינטראקטיבית ולעדכונים בזמן אמת, ו-gRPC over HTTP/2 (עם TLS) לתקשורת בין השרת לבין Firestore. התקשורת עם Firestore מיועלת על-ידי ה-SDK הרשמי ואינה מחייבת הגדרה ידנית של פרוטוקול.
| שם ההודעה | נשלחת מ־ | נשלחת אל | מבנה השדות |
|---|---|---|---|
JoinProjectGroup | Client | Hub | projectId: string (20 bytes max) |
LeaveProjectGroup | Client | Hub | projectId: string |
TicketUpdated | Hub (server) | Group clients | updateType: string (enum {TicketCreated, TicketUpdated, TicketDeleted, TicketStatusChanged}), data: Ticket | { TicketId } |
CommentAdded | Hub | Group clients | TicketId: string, Comment: {Id, Content, AuthorId, CreatedAt} |
CommentDeleted | Hub | Group clients | TicketId: string, CommentId: string |
| מסך | קובץ | תיאור |
|---|---|---|
| כניסה | Login.razor | טופס כניסה עם שם משתמש וסיסמה, לחצן "הרשמה" מפנה ל-Signup |
| הרשמה | Signup.razor | טופס יצירת חשבון חדש (שם משתמש, דוא״ל, שם מלא, סיסמה + אימות) |
| דשבורד | Dashboard.razor | סיכום פרויקטים פעילים, ברכה, נתוני משתמש |
| רשימת פרויקטים | Projects.razor | כרטיסיות של כל הפרויקטים של המשתמש, יצירת פרויקט חדש, הצטרפות עם קוד |
| לוח Kanban | Board.razor | תצוגת 4 עמודות עם drag-and-drop, יצירה ועדכון tickets |
| הגדרות פרויקט | ProjectSettings.razor | עריכת שם/תיאור, הפקת קודי הזמנה, מחיקת פרויקט |
| פרופיל | Profile.razor | עריכת נתוני משתמש, שינוי סיסמה |
| פאנל ניהול | Admin.razor | דשבורד מנהל מערכת (גישה ל-admin בלבד) |
תצלומים ייצוגיים של המסכים מוצגים להלן. יש להחליף את הפריימים בתמונות בפועל מסביבת ההרצה.
מסד הנתונים של WorkGrid הוא Google Cloud Firestore (NoSQL דוקומנטי), שם הפרויקט בענן:
workgrid-988f8. אין טבלאות רלציוניות — הנתונים מסודרים ב-5 קולקשנים (collections),
כל document בעל מזהה אוטומטי ([FirestoreDocumentId]).
| שדה | טיפוס | דוגמה | הערות |
|---|---|---|---|
| Id (document id) | string | "UJ8fw…" | מפתח ראשי — נוצר אוטומטית |
| Username | string | "admin" | unique (enforced לוגית ע"י UserExistsAsync) |
| PasswordHash | string | "$2a$11$…" | BCrypt hash, ~60 תווים |
| string | "admin@workgrid.io" | ||
| FullName | string | "Administrator" | |
| JobTitle / Department / Bio / AvatarUrl | string | "IT" | פרופיל |
| CreatedAt / UpdatedAt | DateTime | 2026-03-12T10:02Z | UTC |
| שדה | טיפוס | דוגמה | הערות |
|---|---|---|---|
| Id | string | "p_xyz123" | מפתח ראשי |
| Name | string | "WorkGrid Platform" | |
| Description | string | "Main development…" | |
| Key | string | "WG" | משמש ליצירת TicketNumber (e.g., "WG-1") |
| OwnerId | string | "UJ8fw…" | FK לוגי ל-users |
| ParticipantIds | List<string> | ["UJ8fw…","abc…"] | מערך משתתפים, כולל ה-Owner |
| CreatedAt / UpdatedAt | DateTime |
| שדה | טיפוס | דוגמה | הערות |
|---|---|---|---|
| Id | string | "t_111" | מפתח ראשי |
| ProjectId | string | "p_xyz123" | FK לוגי ל-projects |
| Title | string | "Fix drag and drop" | |
| Description | string | "Drag doesn't work on…" | |
| TicketNumber | string | "WG-4" | Key+index |
| Status | string (enum) | "Backlog" | ערכים: Backlog / InProgress / InReview / Done |
| Priority | string (enum) | "High" | Low / Medium / High / Critical |
| Type | string (enum) | "Bug" | Task / Bug / Feature / Story |
| AssigneeId | string? | "UJ8fw…" | nullable |
| ReporterId | string | "UJ8fw…" | |
| StoryPoints | int | 3 | |
| OrderIndex | int | 0 | סדר בתוך עמודה |
| CreatedAt / UpdatedAt | DateTime |
| שדה | טיפוס | דוגמה | הערות |
|---|---|---|---|
| Id | string | "c_ff1" | מפתח ראשי |
| TicketId | string | "t_111" | FK לוגי |
| AuthorId | string | "UJ8fw…" | FK לוגי |
| Content | string | "נראה לי שזה קשור ל-touch" | טקסט חופשי |
| CreatedAt / UpdatedAt | DateTime |
| שדה | טיפוס | דוגמה | הערות |
|---|---|---|---|
| Id | string | "i_aaa" | מפתח ראשי |
| Code | string | "7H2KQP" | 6 תווים אלפאנומריים |
| ProjectId | string | "p_xyz123" | |
| CreatedById | string | "UJ8fw…" | |
| CreatedAt | DateTime | ||
| ExpiresAt | DateTime | 24 שעות מ-CreatedAt | |
| IsUsed | bool | false | |
| UsedByUserId | string? | null | |
| UsedAt | DateTime? | null |
למרות שב-Firestore אין שפת שאילתה בטקסט (אין concatenation של מחרוזות לשאילתה בסגנון SQL), עדיין יש לשמור על
validate של קלט משתמש. השימוש נעשה ב-WhereEqualTo("Username", username) — ה-SDK מקודד
את הערך כפרמטר, כך שאין אפשרות "להבריח" קוד. הסיכון המעשי הוא מינימלי. עם זאת, כל הקלטים
נבדקים גם בצד השרת (שם משתמש > 3 תווים, סיסמה > 4 תווים, אין IsNullOrWhiteSpace).
Blazor מרנדר ביטויים @variable כ-text nodes ולא כ-HTML — לכן אפילו אם משתמש מזין
<script>alert(1)</script> כתוכן של ticket או comment, הדפדפן יציג את
הטקסט כפי שהוא. לא נעשה שימוש ב-(MarkupString) בשום מקום בקוד שלנו.
כל סיסמה מעורבבת ב-BCrypt עם salt אקראי וב-work-factor 11; השוואה בזמן כניסה מתבצעת דרך
BCrypt.Verify שעמיד בפני timing attacks. יש בדיקת חוזק מינימלית של הסיסמה (4 תווים —
בוחר תיקוני יכול להגדיל). אין מנגנון MFA במערכת הנוכחית — זה סעיף לשיפור עתידי.
app.UseHttpsRedirection() וכן app.UseHsts() ב-production
מבטיחים שכל התקשורת עם הלקוח מוצפנת ב-TLS 1.3; ה-SDK של Firestore מכריח TLS על gRPC מצד המערכת. לכן האיום
העיקרי הוא התקפה על ה-CA — אך זו מחוץ לתחום הפרויקט ומוטלת על תשתית ה-Certificates של Microsoft/Google.
ברמת האפליקציה אין rate-limiting מובנה — זו חולשה ידועה. פתרון ב-production: להוסיף middleware של
Microsoft.AspNetCore.RateLimiting על endpoints של login (ניסיונות כניסה) ועל יצירת
tickets/comments. ברמת ה-Firestore, Google מגבילה read/write rates אוטומטית.
במערכת הנוכחית אין העלאת קבצים — רק שדה AvatarUrl שהוא URL (לא הועלה קובץ
אלא ניתן להזין קישור חיצוני). לעתיד: אם תתווסף העלאת קבצים, יש לחשב SHA-256 לכל קובץ, לשמור ב-Cloud Storage,
ולוודא Content-Type בצד השרת (ולא בלבד לסמוך על הכותרת של הדפדפן).
התקשורת עם הלקוח עולה על 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 מהימן.
תהליך ההפעלה יוצר את השרת על https://localhost:7xxx (בפיתוח) או מאחורי reverse proxy (nginx / IIS) ב-production.
ה-Service Account JSON של Firebase חייב להיות באותה תיקייה אך לא בתוך
wwwroot, כך שלא ייחשף בהורדה ישירה. השרת מבצע Seeding של משתמש admin ופרויקט דוגמה
רק אם הם לא קיימים (idempotent).
| מודול | גרסה | ייעוד |
|---|---|---|
Google.Cloud.Firestore | 3.4.0 | לקוח רשמי של Google לגישה ל-Firestore — מסד הנתונים הענני של המערכת |
BCrypt.Net-Next | 4.0.3 | ספריית hashing של סיסמאות (BCrypt עם salt ו-work factor) |
Microsoft.AspNetCore.SignalR.Client | 8.0.0 | לקוח SignalR להקמת HubConnection מתוך Blazor ולקליטת broadcasts בזמן אמת |
Microsoft.AspNetCore.SignalR | (מובנה ב-.NET 8) | שרת SignalR — BoardHub ו-IHubContext |
Microsoft.AspNetCore.Components | (מובנה) | תשתית Blazor — Razor Components ו-Interactive Server |
| שדה | תפקיד ושימוש |
|---|---|
| Id | מזהה יחיד, מוקצה ע"י Firestore |
| Username | שם המשתמש לכניסה, unique |
| PasswordHash | hash של הסיסמה (BCrypt) |
| Email / FullName / JobTitle / Department / AvatarUrl / Bio | נתוני פרופיל |
| CreatedAt / UpdatedAt | timestamps UTC |
המחלקה אינה מכילה פעולות — היא DTO עם annotations של Firestore.
| שדה | תפקיד ושימוש |
|---|---|
| Id / Name / Description / Key | מזהה, שם, תיאור וקוד הפרויקט (WG וכד') |
| ParticipantIds | רשימת מזהי משתתפים (כולל ה-Owner) |
| OwnerId | מזהה היוצר |
| CreatedAt / UpdatedAt | timestamps UTC |
| שדה | תפקיד |
|---|---|
| Id / ProjectId / Title / Description | נתוני בסיס |
| TicketNumber | מוצג למשתמש — e.g. "WG-3" |
| Status / Priority / Type | מחרוזות המייצגות enumים (TicketStatus/Priority/Type) |
| AssigneeId / ReporterId | משתמשים קשורים |
| StoryPoints / OrderIndex | משקל ומיקום בעמודה |
| CreatedAt / UpdatedAt | timestamps |
שדות: Id, TicketId, AuthorId, Content, CreatedAt, UpdatedAt.
| שדה | תפקיד |
|---|---|
| Code / ProjectId / CreatedById | הקוד (6 תווים), שיוך הפרויקט והיוצר |
| CreatedAt / ExpiresAt | חיים של 24 שעות |
| IsUsed / UsedByUserId / UsedAt | ניהול מצב שימוש |
| IsExpired (computed) | DateTime.UtcNow > ExpiresAt |
| IsValid (computed) | !IsUsed && !IsExpired |
Scoped. מטפלת בכל זרימות האימות וניהול ה-session הלוגי.
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
LoginAsync | string username, string password | (bool Success, string Message) — מאמת ומעדכן CurrentUser |
SignupAsync | username, email, fullName, password, confirm, jobTitle?, department? | יוצר משתמש ב-Firestore ומחזיר הודעת הצלחה/כישלון |
Logout | — | מנקה CurrentUser ומשגר OnAuthStateChanged |
GetSessionData | — | string? — JSON ללא PasswordHash לאחסון ב-sessionStorage |
RestoreSessionAsync | string? sessionData | bool — טוען מחדש את המשתמש מ-Firestore ומעדכן CurrentUser |
UpdateProfileAsync | שדות פרופיל (nullable) | (bool, string) — מעדכן רק את השדות שהוזנו |
ChangePasswordAsync | current, new, confirmNew | (bool, string) — מאמת ישן, מעדכן hash חדש |
SeedAdminUserAsync | — | יוצר admin/admin אם לא קיים |
Singleton. מרוכזים בה כל הפעולות מול מסד הנתונים ועל שלוחות ה-SignalR להפצת עדכונים.
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
GetUserByUsernameAsync | string username | User? — null אם לא קיים |
CreateUserAsync | User user | User — עם Id שהוקצה |
UpdateUserAsync | User user | User — לאחר SetAsync Overwrite |
GetProjectsForUserAsync | string userId | List<Project> — שאילתה WhereArrayContains |
CreateProjectAsync / UpdateProjectAsync / DeleteProjectAsync | Project / id | מחיקה אמורה למחוק גם כרטיסים, תגובות וקודי הזמנה |
CreateTicketAsync | Ticket | Ticket + broadcast "TicketCreated" |
UpdateTicketStatusAsync | ticketId, status, orderIndex | void — UpdateAsync חלקי + broadcast "TicketStatusChanged" |
DeleteTicketAsync | string ticketId | void + broadcast "TicketDeleted" |
GenerateInvitationCode | — | string — 6 תווים מ-alphabet ללא O/0/I/1 |
CreateInvitationCodeAsync | projectId, createdById | InvitationCode עם ExpiresAt = now+24h |
UseInvitationCodeAsync | code, userId | bool — מוסיף משתמש ל-Participants, מסמן as Used |
CreateCommentAsync | Comment | Comment + broadcast "CommentAdded" |
NotifyProjectUpdateAsync (private) | projectId, updateType, data? | SendAsync("TicketUpdated", type, data) ל-Group("project_{id}") |
| פעולה | טענת כניסה | טענת יציאה |
|---|---|---|
JoinProjectGroup | string projectId | מוסיפה את ה-ConnectionId לקבוצה "project_{id}" |
LeaveProjectGroup | string projectId | מסירה את ה-ConnectionId מהקבוצה |
Scoped. רצה פעם אחת ב-startup (Program.cs). פעולות: SeedDataAsync, SeedAdminUserAsync (private), SeedSampleProjectAsync (private), SeedSampleTicketsAsync (private) — יוצרת 10 tickets לפרויקט WG לדוגמה.
הסיסמה אף פעם לא נשמרת בטקסט גלוי. בעת הרשמה יוצרים 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.");
כל פעולה שמשנה נתון של פרויקט (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; }
// 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; }
// 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); }
// 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}"); } }
// 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); }
מטרה: לוודא שמשתמש חדש נוצר ב-Firestore עם hash ולא עם סיסמה גלויה.
בוצע בפועל: נפתח מסך Signup, הוזנו Username=testuser, Password=Pass1234. נבדק Firestore Console.
תוצאות: מסמך נוצר ב-users, PasswordHash התחיל ב-$2a$11$ (BCrypt) — ✔.
בעיות שהתגלו: לא התגלו.
מטרה: ודא דחיית שם משתמש כפול.
בוצע: ניסיון להרשם פעם שנייה עם Username=testuser.
תוצאות: הודעת "Username already exists." — ✔.
בעיות: בתחילה ההודעה הופיעה רק לאחר רפרוש; נוסף state binding נכון ו-StateHasChanged.
מטרה: BCrypt.Verify מזהה נכון סיסמה.
בוצע: כניסה admin/admin.
תוצאות: הפניה ל-Dashboard, CurrentUser מוצג — ✔.
בעיות: לא התגלו.
מטרה: דחיית סיסמה לא נכונה.
בוצע: admin/wrong.
תוצאות: "Invalid username or password." — ✔. ההודעה זהה לזו שהוחזרה בעת "משתמש לא קיים" (enumeration prevention).
בעיות: לא התגלו.
מטרה: יצירת ticket ב-browser A תשתקף ב-browser B באותו פרויקט.
בוצע: נפתחו שני חלונות (רגיל + incognito), שניהם מחוברים לפרויקט WG. נוצר ticket ב-A.
תוצאות: ticket הופיע ב-B תוך ~50ms — ✔.
בעיות: בתחילה ב-B לא התעדכן — התגלה שהוזנח לקרוא JoinProjectGroup ב-OnAfterRenderAsync. תוקן.
מטרה: שינוי Status ו-OrderIndex דרך drag-and-drop.
בוצע: גרירה מ-Backlog ל-InProgress.
תוצאות: ב-Firestore Status="InProgress", OrderIndex מעודכן — ✔.
בעיות: בתחילה ה-OrderIndex של יתר הכרטיסים בעמודת היעד לא התאזן — נוסף חישוב reindex בצד לקוח לפני שליחה.
מטרה: שימוש בקוד חוקי מצרף משתמש לפרויקט.
בוצע: הופק קוד ב-ProjectSettings, נכנס משתמש B עם הקוד.
תוצאות: B הופיע ב-ParticipantIds, הקוד סומן IsUsed=true — ✔.
בעיות: לא התגלו.
מטרה: דחיית קוד שהוא >24h.
בוצע: שינוי ידני של ExpiresAt ב-Firestore ל--1h, ניסיון שימוש.
תוצאות: UseInvitationCodeAsync החזיר false — ✔.
בעיות: לא התגלו.
מטרה: תגובה נשמרת ומופצת ב-SignalR.
בוצע: הוספת comment מ-A, צפייה ב-B.
תוצאות: התגובה הופיעה ב-B מיידית — ✔.
בעיות: תקלה ראשונית — comments הוצגו בסדר לא תקין. נוסף OrderBy(c => c.CreatedAt) בצד-לקוח (ללא אינדקס מורכב ב-Firestore).
מטרה: Logout מנקה CurrentUser ו-sessionStorage.
בוצע: לחיצה על "Logout".
תוצאות: הפניה ל-Login, sessionStorage ריק — ✔.
בעיות: לא התגלו.
מטרה: ניווט ישיר ל-/board ללא login חוסם.
בוצע: גלישה ל-/board בלשונית incognito.
תוצאות: הפניה ל-/login — ✔.
בעיות: לא התגלו.
מטרה: ה-SDK של Firestore מטפל ב-parameterization.
בוצע: הזנת ' OR '1'='1 בשדה username.
תוצאות: לא נמצא משתמש, הוחזר "Invalid" — ✔. לא התרחשה שאילתה רחבה. כמו-כן הוזנה פקודה דמוית-XSS כתוכן ticket — הופיעה כטקסט מלא ולא הורצה.
בעיות: לא התגלו.
מטרה: להבטיח התאוששות מאי-יציבות רשת.
בוצע: בזמן עבודה — ניתקתי Wi-Fi ל-5 שניות, ואז חיברתי מחדש.
תוצאות: Blazor Server הציג הודעת "Reconnecting…" ולאחר שחזור החיבור ממשיך רגיל — ✔.
בעיות: לא התגלו (auto-reconnect ברירת מחדל).
מטרה: אין "orphan rows".
בוצע: יצירת פרויקט דוגמה, 3 tickets עם 2 comments כל אחד, 1 invitation code; מחיקת הפרויקט.
תוצאות: Firestore Console — אפס רשומות מחוברות — ✔. תוקן לאחר שגילינו שבתחילה נשארו comments (נוסף DeleteCommentsByTicketIdAsync).
מטרה: ניסיון ליצור race condition.
בוצע: A ו-B גוררים את אותו ticket כמעט בו-זמנית לעמודות שונות.
תוצאות: השינוי האחרון זוכה (Firestore SetAsync אטומי ברמת document), ושני המשתמשים רואים את אותו מצב סופי ב-broadcast — ✔.
מטרה: וידוא UX סביר.
בוצע: seed של 50 tickets, מדידת זמן טעינה.
תוצאות: זמן טעינה ממוצע ~420ms מעל חיבור ביתי — ✔.
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/
dotnet --version צריך להחזיר 8.x)git clone https://gitlab.com/myprojects5451850/workgrid.gitworkgrid-988f8-firebase-adminsdk-...json בתיקיית הפרויקט (מוגדר כ-CopyToOutputDirectory=PreserveNewest).dotnet restore לשליפת NuGet packages.dotnet run מתיקיית WorkGrid/. ה-Seeding יוצר אוטומטית: משתמש admin / סיסמה admin, פרויקט "WorkGrid Platform" (key=WG) ו-10 tickets.https://localhost:7xxx/ (הפורט מופיע בקונסולה).admin. בשימוש אמיתי יש לשנות אותה מיד בכניסה
הראשונה דרך מסך Profile → Change Password.
sessionStorage.העבודה על 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) לפני הקוד הראשון.
תודה למנחה שלי [למלא: שם המנחה] על הליווי הצמוד, על שאלות חכמות ועל ה-code reviews שהפכו את הקוד שלי לבוגר יותר. תודה לצוות המורים של [למלא: שם בית הספר]. תודה גם למשפחה שלי על הסבלנות בשעות הלילה, ולחברים לכיתה שהסכימו להיות "Browser B" בבדיקות real-time.
הרשימה שלהלן מסודרת לפי כללי ה-APA 7. מקורות מסוגים שונים: תיעוד רשמי, מאמרים אקדמיים, ספרים, אתרי מידע וסרטוני וידאו.
Blazor Server הוא מודל שבו כל הקומפוננטות של Razor רצות על השרת. כאשר המשתמש מבצע פעולה (לחיצה, הקלדה), הדפדפן שולח RPC קטן דרך SignalR circuit אל השרת; השרת מריץ את ההנדלר הרלוונטי, מחשב מחדש את ה-render tree של הקומפוננטה, משווה ל-tree הקודם (diffing), ושולח פאטץ' מינימלי חזרה ל-DOM. הפאטץ' מוחל ב-JS קטן ב-client. היתרון: כל הקוד ב-C# (אין JavaScript), הקוד לא מגיע לדפדפן (אבטחה טובה יותר). החיסרון: תלות ברשת חיה, ועלויות זיכרון על השרת פר-חיבור.
SignalR מאפשרת לשייך כל ConnectionId לאחת או יותר מ-"קבוצות". כאשר השרת שולח SendAsync
לקבוצה "project_X", רק הלקוחות החברים בה יקבלו את ההודעה — יעיל בהרבה מ-SendAll, במיוחד עבור מערכת עם מאות
פרויקטים שרצים בו-זמנית.
אין טבלאות — רק collections של documents. כל document הוא JSON-like של שדות (מחרוזות, מספרים,
מערכים, תתי-אובייקטים). אין JOINs: אם רוצים "tickets של פרויקט X", מבצעים שאילתה על הקולקשן tickets
עם WhereEqualTo("ProjectId", X). אינדקסים בסיסיים נבנים אוטומטית; אינדקסים מורכבים
(WhereEqualTo + OrderBy על שדות שונים) דורשים הגדרה ידנית ב-Firebase Console. ב-WorkGrid ויתרנו עליהם ומיינו בצד-לקוח
(אוסף בדרך כלל מכיל <200 מסמכים, אז זה מהיר).
BCrypt מתבסס על מפתח של Blowfish ועל work factor מכוונן (ברירת מחדל 11 = 211=2048 איטרציות פנימיות). כל hash מכיל salt אקראי של 16 בייט, כך שגם אם שני משתמשים בחרו את אותה הסיסמה — ה-hashים שונים לחלוטין. Rainbow tables לא רלוונטיים. ולא פחות חשוב: הפונקציה איטית במכוון — ~100ms לחישוב — כך שבטאק ברוט-פורס אוטומטי מצליח לנסות רק עשרות אלפי סיסמאות בשנייה, לא מיליארדים.
להלן תדפיס מסודר של קבצי ה-.cs המרכזיים במערכת. קבצי .razor
ו-.css נותרו ברפוזיטורי. כל קטע מנוקד במספר קבצים.
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();
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}"); } }
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; }
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; }
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; }
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; }
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; }
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."); } }
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; } }
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].
— סוף מסמך —