Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 31 additions & 2 deletions 01-Database/GroundShareDB.sql
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@ CREATE TABLE city (
City_Name NVARCHAR(255) NOT NULL CHECK (LEN(TRIM(City_Name)) > 0),
District NVARCHAR(255)
);
GO

CREATE TABLE event_type (
EventType_ID INT IDENTITY(1,1) PRIMARY KEY,
Name NVARCHAR(255) NOT NULL UNIQUE CHECK (LEN(TRIM(Name)) > 0)
);
GO

-- ============================================================
-- 2. Streets (depends on city)
Expand All @@ -44,6 +46,7 @@ CREATE TABLE streets (
Street_Name NVARCHAR(255) NOT NULL CHECK (LEN(TRIM(Street_Name)) > 0),
UNIQUE (City_ID, Street_Name)
);
GO

-- ============================================================
-- 3. Location (depends on city, streets)
Expand All @@ -59,8 +62,10 @@ CREATE TABLE location (
Latitude FLOAT,
Longitude FLOAT
);
GO

CREATE INDEX IX_location_geo ON location(Latitude, Longitude) WHERE Latitude IS NOT NULL;
GO

-- ============================================================
-- 4. Users (depends on location)
Expand All @@ -82,6 +87,7 @@ CREATE TABLE [user] (
Created_At DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
Is_Active BIT NOT NULL DEFAULT 1
);
GO

-- ============================================================
-- 5. Refresh tokens (for JWT rotation)
Expand All @@ -95,8 +101,10 @@ CREATE TABLE refresh_token (
Created_At DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
Revoked_At DATETIME2
);
GO

CREATE INDEX IX_refresh_token_user ON refresh_token(User_ID);
GO

-- ============================================================
-- 6. Events / Reports (depends on location, event_type, user)
Expand All @@ -116,9 +124,12 @@ CREATE TABLE event (
Created_At DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
CHECK (End_Date IS NULL OR End_Date >= Start_Date)
);
GO

CREATE INDEX IX_event_location ON event(Location_ID);
GO
CREATE INDEX IX_event_user ON event(User_ID);
GO

-- ============================================================
-- 7. Event votes (relevancy — like Waze)
Expand All @@ -131,6 +142,7 @@ CREATE TABLE event_vote (
Voted_At DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
PRIMARY KEY (User_ID, Event_ID)
);
GO

-- ============================================================
-- 8. Comments on events
Expand All @@ -144,8 +156,10 @@ CREATE TABLE comment (
Picture NVARCHAR(MAX),
Created_At DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
GO

CREATE INDEX IX_comment_event ON comment(Event_ID);
GO

-- ============================================================
-- 9. Reviews (on locations, with good/bad sentiment)
Expand All @@ -161,9 +175,12 @@ CREATE TABLE user_review (
Picture NVARCHAR(MAX),
Created_At DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
GO

CREATE INDEX IX_review_location ON user_review(Location_ID);
GO
CREATE UNIQUE INDEX UQ_review_user_location ON user_review(User_ID, Location_ID);
GO

-- ============================================================
-- 10. Favorites
Expand All @@ -175,6 +192,7 @@ CREATE TABLE add_to_favorites (
Added_Date DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
PRIMARY KEY (User_ID, Location_ID)
);
GO

-- ============================================================
-- 11. Plan status (building permits)
Expand All @@ -191,6 +209,7 @@ CREATE TABLE PlanStatus (
Current_Stage NVARCHAR(255) DEFAULT N'Initiated',
CHECK (End_Date IS NULL OR End_Date >= Start_Date)
);
GO

-- ============================================================
-- 12. Comparison history
Expand All @@ -201,6 +220,7 @@ CREATE TABLE comparison (
User_ID INT NOT NULL REFERENCES [user](User_ID),
Created_At DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
GO

CREATE TABLE comparison_address (
Comparison_ID INT NOT NULL REFERENCES comparison(Comparison_ID),
Expand All @@ -210,6 +230,7 @@ CREATE TABLE comparison_address (
Address NVARCHAR(500),
PRIMARY KEY (Comparison_ID, Location_ID)
);
GO

-- ============================================================
-- 13. Survey / onboarding answers
Expand All @@ -232,6 +253,7 @@ CREATE TABLE survey (
Is_GovRepresentative BIT NOT NULL DEFAULT 0, -- אני נציג/ת רשות / עובד/ת עירייה
Created_At DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
GO

-- ============================================================
-- 14. User activity log (for gamification feed)
Expand All @@ -245,8 +267,10 @@ CREATE TABLE user_activity (
XP_Earned INT NOT NULL DEFAULT 0,
Created_At DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
);
GO

CREATE INDEX IX_activity_user ON user_activity(User_ID, Created_At DESC);
GO

-- ============================================================
-- 15. Notification subscriptions (bell toggle per location)
Expand All @@ -258,6 +282,7 @@ CREATE TABLE notification_subscription (
Created_At DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
PRIMARY KEY (User_ID, Location_ID)
);
GO

-- ============================================================
-- Seed event types
Expand Down Expand Up @@ -485,8 +510,10 @@ AS
BEGIN
SET NOCOUNT ON;
SELECT e.Event_ID, e.Description, e.EventStatus, e.Start_Date, e.End_Date, e.Picture, e.Created_At,
et.Name AS EventType_Name,
et.Name AS EventType,
u.Full_Name AS Author,
ISNULL((SELECT SUM(Vote) FROM event_vote WHERE Event_ID = e.Event_ID), 0) AS Vote_Count,
ISNULL((SELECT SUM(Vote) FROM event_vote WHERE Event_ID = e.Event_ID), 0) AS VoteCount
FROM event e
INNER JOIN event_type et ON e.EventType_ID = et.EventType_ID
Expand Down Expand Up @@ -572,8 +599,10 @@ BEGIN
END
END

-- Return new total
SELECT ISNULL(SUM(Vote), 0) AS VoteCount FROM event_vote WHERE Event_ID = @Event_ID;
-- Return new total (both aliases during additive migration)
SELECT ISNULL(SUM(Vote), 0) AS Vote_Count,
ISNULL(SUM(Vote), 0) AS VoteCount
FROM event_vote WHERE Event_ID = @Event_ID;
END
GO

Expand Down
114 changes: 55 additions & 59 deletions 02-Server/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
using System.Text.RegularExpressions;
using Google.Apis.Auth;
using Microsoft.Extensions.Options;
using WebApplication1.Options;
using GroundShareAPI.Options;

namespace GroundShareAPI.Controllers
{
Expand Down Expand Up @@ -75,39 +75,42 @@ public async Task<IActionResult> Register([FromBody] RegisterRequest request)
var streetName = Sanitize(request.Street_Name);
var houseNumber = Sanitize(request.House_Number);

// --- Email validation ---
// Validation errors flow through ExceptionHandlingMiddleware so the
// client sees one ApiResponse<object> shape for every failure.
var fieldErrors = new Dictionary<string, string>();

if (string.IsNullOrEmpty(email))
return BadRequest(new { field = "email", message = "כתובת מייל היא שדה חובה" });
if (email.Length > 254)
return BadRequest(new { field = "email", message = "כתובת מייל ארוכה מדי" });
if (!EmailRegex.IsMatch(email))
return BadRequest(new { field = "email", message = "כתובת מייל לא תקינה" });
fieldErrors["email"] = "כתובת מייל היא שדה חובה";
else if (email.Length > 254)
fieldErrors["email"] = "כתובת מייל ארוכה מדי";
else if (!EmailRegex.IsMatch(email))
fieldErrors["email"] = "כתובת מייל לא תקינה";

// --- Password validation ---
if (string.IsNullOrEmpty(password))
return BadRequest(new { field = "password", message = "סיסמה היא שדה חובה" });
if (password.Length < 8)
return BadRequest(new { field = "password", message = "הסיסמה חייבת להכיל לפחות 8 תווים" });
if (password.Length > 128)
return BadRequest(new { field = "password", message = "הסיסמה ארוכה מדי (מקסימום 128 תווים)" });
if (!password.Any(char.IsUpper))
return BadRequest(new { field = "password", message = "הסיסמה חייבת לכלול לפחות אות גדולה באנגלית" });
if (!password.Any(char.IsLower))
return BadRequest(new { field = "password", message = "הסיסמה חייבת לכלול לפחות אות קטנה באנגלית" });
if (!password.Any(char.IsDigit))
return BadRequest(new { field = "password", message = "הסיסמה חייבת לכלול לפחות ספרה אחת" });

// --- Full name validation ---
fieldErrors["password"] = "סיסמה היא שדה חובה";
else if (password.Length < 8)
fieldErrors["password"] = "הסיסמה חייבת להכיל לפחות 8 תווים";
else if (password.Length > 128)
fieldErrors["password"] = "הסיסמה ארוכה מדי (מקסימום 128 תווים)";
else if (!password.Any(char.IsUpper))
fieldErrors["password"] = "הסיסמה חייבת לכלול לפחות אות גדולה באנגלית";
else if (!password.Any(char.IsLower))
fieldErrors["password"] = "הסיסמה חייבת לכלול לפחות אות קטנה באנגלית";
else if (!password.Any(char.IsDigit))
fieldErrors["password"] = "הסיסמה חייבת לכלול לפחות ספרה אחת";

if (string.IsNullOrEmpty(fullName))
return BadRequest(new { field = "fullName", message = "שם מלא הוא שדה חובה" });
if (fullName.Length < 2)
return BadRequest(new { field = "fullName", message = "שם חייב להכיל לפחות 2 תווים" });
if (fullName.Length > 50)
return BadRequest(new { field = "fullName", message = "שם ארוך מדי (מקסימום 50 תווים)" });
fieldErrors["fullName"] = "שם מלא הוא שדה חובה";
else if (fullName.Length < 2)
fieldErrors["fullName"] = "שם חייב להכיל לפחות 2 תווים";
else if (fullName.Length > 50)
fieldErrors["fullName"] = "שם ארוך מדי (מקסימום 50 תווים)";

// --- Phone validation (optional) ---
if (!string.IsNullOrEmpty(phone) && !PhoneRegex.IsMatch(phone))
return BadRequest(new { field = "phone", message = "מספר טלפון לא תקין" });
fieldErrors["phone"] = "מספר טלפון לא תקין";

if (fieldErrors.Count > 0)
throw new Exceptions.ValidationException("נתוני הרשמה אינם תקינים", fieldErrors);

try
{
Expand All @@ -121,7 +124,7 @@ public async Task<IActionResult> Register([FromBody] RegisterRequest request)
string.IsNullOrEmpty(houseNumber) ? null : houseNumber);

if (userRow == null)
return StatusCode(500, new { message = "שגיאה בהרשמה" });
throw new InvalidOperationException("User row was null after successful register.");

int userId = Convert.ToInt32(userRow["User_ID"]);
string accessToken = GenerateAccessToken(userId, email);
Expand Down Expand Up @@ -151,7 +154,7 @@ public async Task<IActionResult> Register([FromBody] RegisterRequest request)
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
return BadRequest(new { message = "Email and password are required." });
throw new Exceptions.ValidationException("Email and password are required.");

var userRow = await _usersDal.GetUserByEmailRawAsync(request.Email);

Expand All @@ -174,7 +177,7 @@ public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
await _audit.LogAsync(null, "LOGIN_FAILURE", ClientIp, ClientUserAgent,
JsonSerializer.Serialize(new { email = request.Email }));
return Unauthorized(new { message = "אימות נכשל" });
throw new UnauthorizedException("אימות נכשל");
}

int userId = Convert.ToInt32(userRow["User_ID"]);
Expand Down Expand Up @@ -202,7 +205,7 @@ await _audit.LogAsync(null, "LOGIN_FAILURE", ClientIp, ClientUserAgent,
public async Task<IActionResult> GoogleSignIn([FromBody] GoogleSignInRequest request)
{
if (string.IsNullOrWhiteSpace(request?.IdToken))
return BadRequest(new { message = "Missing Google credential." });
throw new Exceptions.ValidationException("Missing Google credential.");

// -----------------------------------------------------------------
// Verify the ID token using Google's official library.
Expand All @@ -227,65 +230,58 @@ public async Task<IActionResult> GoogleSignIn([FromBody] GoogleSignInRequest req
});

if (!payload.EmailVerified)
return Unauthorized(new { message = "Google email is not verified." });
throw new UnauthorizedException("Google email is not verified.");

email = payload.Email ?? "";
fullName = payload.Name ?? payload.GivenName ?? email.Split('@')[0];

if (string.IsNullOrWhiteSpace(email))
return Unauthorized(new { message = "Google token missing email." });
throw new UnauthorizedException("Google token missing email.");
}
catch (InvalidJwtException)
{
return Unauthorized(new { message = "Invalid Google token." });
throw new UnauthorizedException("Invalid Google token.");
}

email = Sanitize(email).ToLowerInvariant();
fullName = Sanitize(fullName);
if (fullName.Length > 50) fullName = fullName.Substring(0, 50);
if (string.IsNullOrWhiteSpace(fullName)) fullName = email.Split('@')[0];

try
{
var userRow = await _socialAuthDal.UpsertGoogleUserAsync(email, fullName);
if (userRow == null)
return StatusCode(500, new { message = "Failed to create or load user." });
var userRow = await _socialAuthDal.UpsertGoogleUserAsync(email, fullName);
if (userRow == null)
throw new InvalidOperationException("Failed to create or load Google user.");

int userId = Convert.ToInt32(userRow["User_ID"]);
string accessToken = GenerateAccessToken(userId, email);
string refreshToken = GenerateRefreshToken();
int userId = Convert.ToInt32(userRow["User_ID"]);
string accessToken = GenerateAccessToken(userId, email);
string refreshToken = GenerateRefreshToken();

await _refreshTokenDal.SaveTokenAsync(userId, refreshToken, DateTime.UtcNow.AddDays(7));
await _audit.LogAsync(userId, "GOOGLE_SIGNIN", ClientIp, ClientUserAgent);
await _refreshTokenDal.SaveTokenAsync(userId, refreshToken, DateTime.UtcNow.AddDays(7));
await _audit.LogAsync(userId, "GOOGLE_SIGNIN", ClientIp, ClientUserAgent);

return Ok(new
{
accessToken,
refreshToken,
user = userRow
});
}
catch (Exception)
return Ok(new
{
return StatusCode(500, new { message = "An error occurred during Google sign-in." });
}
accessToken,
refreshToken,
user = userRow
});
}

[HttpPost("refresh")]
[EnableRateLimiting("auth-refresh")]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
if (string.IsNullOrWhiteSpace(request.RefreshToken))
return BadRequest(new { message = "Refresh token is required." });
throw new Exceptions.ValidationException("Refresh token is required.");

var tokenRow = await _refreshTokenDal.GetTokenAsync(request.RefreshToken);

if (tokenRow == null)
return Unauthorized(new { message = "Invalid refresh token." });
throw new UnauthorizedException("Invalid refresh token.");

var expiresAt = Convert.ToDateTime(tokenRow["Expires_At"]);
if (expiresAt < DateTime.UtcNow)
return Unauthorized(new { message = "Refresh token expired." });
throw new UnauthorizedException("Refresh token expired.");

// Revoke old token
await _refreshTokenDal.RevokeTokenAsync(request.RefreshToken);
Expand All @@ -295,7 +291,7 @@ public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
// Get user data
var userRow = await _usersDal.GetUserByIdAsync(userId);
if (userRow == null)
return Unauthorized(new { message = "User not found." });
throw new UnauthorizedException("User not found.");

string email = userRow["Email"]?.ToString() ?? "";
string newAccessToken = GenerateAccessToken(userId, email);
Expand Down
2 changes: 1 addition & 1 deletion 02-Server/Controllers/EventsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public async Task<IActionResult> Vote(int eventId, [FromBody] VoteRequest reques
{
int userId = GetUserId();
int newCount = await _eventService.VoteOnEventAsync(userId, eventId, request.Vote);
return Ok(new { VoteCount = newCount });
return Ok(new { Vote_Count = newCount, VoteCount = newCount });
}

[HttpGet("{eventId}/comments")]
Expand Down
Loading
Loading