Skip to content

Commit 500890c

Browse files
YuvalCopilot
authored andcommitted
feat: integrate Firebase Cloud Messaging for push notifications
- Added @capacitor/push-notifications to the client package for push notification support. - Implemented push notification registration and unregistration in AuthContext. - Created pushNotifications service to handle FCM registration for web and native platforms. - Added FcmSender service to handle sending notifications to device tokens. - Defined INotificationsDAL interface for managing device tokens in the server. - Implemented device token registration and unregistration API endpoints. - Added Firebase configuration files for both Android and iOS platforms. - Created a service worker for handling background messages in web applications. - Enhanced EventCard component to display images in a popup with animations. Co-authored-by: Copilot <copilot@github.com>
1 parent 62e41c7 commit 500890c

22 files changed

Lines changed: 2068 additions & 16 deletions

01-Database/GroundShareDB.sql

Lines changed: 491 additions & 0 deletions
Large diffs are not rendered by default.

02-Server/Controllers/NotificationsController.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
namespace GroundShareAPI.Controllers
77
{
8+
public sealed record RegisterDeviceRequest(string Token, string Platform);
9+
810
[Route("api/[controller]")]
911
[ApiController]
1012
[Authorize]
@@ -29,6 +31,27 @@ public async Task<IActionResult> Toggle(int locationId)
2931
return Ok(new { Is_Subscribed = isSubscribed });
3032
}
3133

34+
[HttpPost("device")]
35+
public async Task<IActionResult> RegisterDevice([FromBody] RegisterDeviceRequest req)
36+
{
37+
if (string.IsNullOrWhiteSpace(req.Token))
38+
return BadRequest(new { error = "Token required" });
39+
if (req.Platform is not ("ios" or "android" or "web"))
40+
return BadRequest(new { error = "Platform must be ios, android, or web" });
41+
42+
await _dal.UpsertDeviceTokenAsync(GetUserId(), req.Token, req.Platform);
43+
return Ok(new { success = true });
44+
}
45+
46+
[HttpDelete("device")]
47+
public async Task<IActionResult> UnregisterDevice([FromQuery] string token)
48+
{
49+
if (string.IsNullOrWhiteSpace(token))
50+
return BadRequest(new { error = "Token required" });
51+
await _dal.DeleteDeviceTokenAsync(token);
52+
return NoContent();
53+
}
54+
3255
private int GetUserId()
3356
{
3457
return int.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value);

02-Server/DAL/EventsDAL.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,15 @@ public async Task<int> CreateEventAsync(
6060
return Convert.ToInt32(result);
6161
}
6262

63+
public async Task<int?> GetLocationIdForEventAsync(int eventId)
64+
{
65+
using SqlConnection con = await ConnectAsync();
66+
var cmd = new SqlCommand("SELECT Location_ID FROM [event] WHERE Event_ID = @Event_ID", con);
67+
cmd.Parameters.AddWithValue("@Event_ID", eventId);
68+
var result = await cmd.ExecuteScalarAsync();
69+
return result is null or DBNull ? null : Convert.ToInt32(result);
70+
}
71+
6372
public async Task<List<Dictionary<string, object>>> GetEventsByLocationAsync(int locationId)
6473
{
6574
using SqlConnection con = await ConnectAsync();
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace GroundShareAPI.DAL.Interfaces;
2+
3+
public interface INotificationsDAL
4+
{
5+
Task<bool> IsSubscribedAsync(int userId, int locationId);
6+
Task<bool> ToggleNotificationAsync(int userId, int locationId);
7+
8+
Task UpsertDeviceTokenAsync(int userId, string token, string platform);
9+
Task DeleteDeviceTokenAsync(string token);
10+
Task<List<(string Token, string Platform, int UserId)>> GetTokensForLocationAsync(int locationId);
11+
}

02-Server/DAL/NotificationsDAL.cs

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
// ---------------------------------------------------------------------------
2-
// NotificationsDAL — Data access for notification subscriptions.
3-
// ---------------------------------------------------------------------------
4-
// Phase 4.7: Async conversion.
2+
// NotificationsDAL — Data access for notification subscriptions + FCM device tokens.
53
// ---------------------------------------------------------------------------
64

5+
using GroundShareAPI.DAL.Interfaces;
76
using Microsoft.Data.SqlClient;
87

98
namespace GroundShareAPI.DAL
109
{
11-
public class NotificationsDAL : DBServices
10+
public class NotificationsDAL : DBServices, INotificationsDAL
1211
{
1312
public NotificationsDAL(IConfiguration configuration) : base(configuration) { }
1413

@@ -33,5 +32,44 @@ public async Task<bool> ToggleNotificationAsync(int userId, int locationId)
3332
SqlCommand cmd = CreateSP("sp_ToggleNotification", con, p);
3433
return Convert.ToInt32(await cmd.ExecuteScalarAsync()) == 1;
3534
}
35+
36+
public async Task UpsertDeviceTokenAsync(int userId, string token, string platform)
37+
{
38+
using SqlConnection con = await ConnectAsync();
39+
var p = new Dictionary<string, object>
40+
{
41+
{ "@User_ID", userId },
42+
{ "@Token", token },
43+
{ "@Platform", platform }
44+
};
45+
SqlCommand cmd = CreateSP("sp_UpsertDeviceToken", con, p);
46+
await cmd.ExecuteNonQueryAsync();
47+
}
48+
49+
public async Task DeleteDeviceTokenAsync(string token)
50+
{
51+
using SqlConnection con = await ConnectAsync();
52+
var p = new Dictionary<string, object> { { "@Token", token } };
53+
SqlCommand cmd = CreateSP("sp_DeleteDeviceToken", con, p);
54+
await cmd.ExecuteNonQueryAsync();
55+
}
56+
57+
public async Task<List<(string Token, string Platform, int UserId)>> GetTokensForLocationAsync(int locationId)
58+
{
59+
using SqlConnection con = await ConnectAsync();
60+
var p = new Dictionary<string, object> { { "@Location_ID", locationId } };
61+
SqlCommand cmd = CreateSP("sp_GetTokensForLocation", con, p);
62+
63+
var list = new List<(string, string, int)>();
64+
using SqlDataReader reader = await cmd.ExecuteReaderAsync();
65+
while (await reader.ReadAsync())
66+
{
67+
list.Add((
68+
reader.GetString(reader.GetOrdinal("Token")),
69+
reader.GetString(reader.GetOrdinal("Platform")),
70+
reader.GetInt32(reader.GetOrdinal("User_ID"))));
71+
}
72+
return list;
73+
}
3674
}
3775
}

02-Server/GroundShareAPI.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<PackageReference Include="Azure.Identity" Version="1.21.0" />
1414
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
1515
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
16+
<PackageReference Include="FirebaseAdmin" Version="3.5.0" />
1617
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
1718
<PackageReference Include="Google.Apis.Auth" Version="1.73.0" />
1819
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />

02-Server/Program.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@
164164
builder.Services.AddScoped<LocationsDAL>();
165165
builder.Services.AddScoped<FavoritesDAL>();
166166
builder.Services.AddScoped<NotificationsDAL>();
167+
builder.Services.AddScoped<GroundShareAPI.DAL.Interfaces.INotificationsDAL>(sp => sp.GetRequiredService<NotificationsDAL>());
167168
builder.Services.AddScoped<PlanStatusDAL>();
168169
builder.Services.AddScoped<RefreshTokenDAL>();
169170
builder.Services.AddScoped<SocialAuthDAL>();
@@ -182,6 +183,7 @@
182183
// -----------------------------------------------------------------------------
183184
builder.Services.AddScoped<IEventService, EventService>();
184185
builder.Services.AddScoped<IAuditService, AuditService>();
186+
builder.Services.AddScoped<IFcmSender, FcmSender>();
185187

186188
// -----------------------------------------------------------------------------
187189
// Blob storage registration (Phase 5.4)
@@ -356,10 +358,13 @@ await context.HttpContext.Response.WriteAsJsonAsync(new
356358
opt.QueueLimit = 0;
357359
});
358360

359-
// Auth register: 3 per hour per IP — prevents mass account creation
361+
// Auth register: 3/hour in prod to prevent mass account creation;
362+
// relaxed to 50/hour in Development so local testing (repeat signups
363+
// from the same IP) isn't blocked.
364+
var registerPermitLimit = builder.Environment.IsDevelopment() ? 50 : 3;
360365
options.AddFixedWindowLimiter("auth-register", opt =>
361366
{
362-
opt.PermitLimit = 3;
367+
opt.PermitLimit = registerPermitLimit;
363368
opt.Window = TimeSpan.FromHours(1);
364369
opt.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
365370
opt.QueueLimit = 0;
@@ -518,3 +523,4 @@ await context.HttpContext.Response.WriteAsJsonAsync(new
518523
{
519524
Log.CloseAndFlush();
520525
}
526+

02-Server/Services/EventService.cs

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
// ---------------------------------------------------------------------------
22
// EventService — Business logic for the Events domain.
33
// ---------------------------------------------------------------------------
4-
// Phase 4.7: All methods are now async — including Vote and Comment which
5-
// were deferred in Phase 4.5.
6-
// Phase 4.6: Input validation handled by FluentValidation (Validators/).
7-
// ---------------------------------------------------------------------------
84

95
using GroundShareAPI.DAL;
6+
using GroundShareAPI.DAL.Interfaces;
107

118
namespace GroundShareAPI.Services;
129

@@ -15,12 +12,21 @@ public sealed class EventService : IEventService
1512
private readonly EventsDAL _eventsDal;
1613
private readonly VotesDAL _votesDal;
1714
private readonly CommentsDAL _commentsDal;
15+
private readonly IServiceScopeFactory _scopeFactory;
16+
private readonly ILogger<EventService> _logger;
1817

19-
public EventService(EventsDAL eventsDal, VotesDAL votesDal, CommentsDAL commentsDal)
18+
public EventService(
19+
EventsDAL eventsDal,
20+
VotesDAL votesDal,
21+
CommentsDAL commentsDal,
22+
IServiceScopeFactory scopeFactory,
23+
ILogger<EventService> logger)
2024
{
2125
_eventsDal = eventsDal;
2226
_votesDal = votesDal;
2327
_commentsDal = commentsDal;
28+
_scopeFactory = scopeFactory;
29+
_logger = logger;
2430
}
2531

2632
public async Task<int> CreateEventAsync(
@@ -37,11 +43,57 @@ public async Task<int> CreateEventAsync(
3743
double? lat,
3844
double? lng)
3945
{
40-
return await _eventsDal.CreateEventAsync(
46+
int eventId = await _eventsDal.CreateEventAsync(
4147
cityName, streetName, houseNumber,
4248
eventTypeId, userId, startDate, endDate,
4349
description, eventStatus, picture,
4450
lat, lng);
51+
52+
// Fire-and-forget FCM push to subscribers of this location.
53+
// A fresh DI scope is created so scoped services (DAL, FcmSender) stay
54+
// alive after the HTTP request's scope is disposed.
55+
_ = Task.Run(() => SendPushForNewEventAsync(eventId, userId, cityName, streetName, houseNumber, description));
56+
57+
return eventId;
58+
}
59+
60+
private async Task SendPushForNewEventAsync(
61+
int eventId, int authorUserId, string cityName, string? streetName, string? houseNumber, string? description)
62+
{
63+
try
64+
{
65+
using var scope = _scopeFactory.CreateScope();
66+
var eventsDal = scope.ServiceProvider.GetRequiredService<EventsDAL>();
67+
var notificationsDal = scope.ServiceProvider.GetRequiredService<INotificationsDAL>();
68+
var fcm = scope.ServiceProvider.GetRequiredService<IFcmSender>();
69+
70+
int? locationId = await eventsDal.GetLocationIdForEventAsync(eventId);
71+
if (locationId is null) return;
72+
73+
var rows = await notificationsDal.GetTokensForLocationAsync(locationId.Value);
74+
var tokens = rows.Where(r => r.UserId != authorUserId).Select(r => r.Token).ToList();
75+
if (tokens.Count == 0) return;
76+
77+
var addr = string.IsNullOrWhiteSpace(streetName)
78+
? cityName
79+
: $"{streetName} {houseNumber}, {cityName}".Trim();
80+
81+
string title = "אירוע חדש בשכונה שלך";
82+
string body = string.IsNullOrWhiteSpace(description) ? addr : $"{addr}{description}";
83+
84+
var data = new Dictionary<string, string>
85+
{
86+
["event_id"] = eventId.ToString(),
87+
["location_id"] = locationId.Value.ToString(),
88+
["type"] = "new_event",
89+
};
90+
91+
await fcm.SendToTokensAsync(tokens, title, body, data);
92+
}
93+
catch (Exception ex)
94+
{
95+
_logger.LogError(ex, "Failed to send push for event {EventId}", eventId);
96+
}
4597
}
4698

4799
public async Task<List<Dictionary<string, object>>> GetEventsByLocationAsync(int locationId)

02-Server/Services/FcmSender.cs

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
using FirebaseAdmin;
2+
using FirebaseAdmin.Messaging;
3+
using Google.Apis.Auth.OAuth2;
4+
using GroundShareAPI.DAL.Interfaces;
5+
6+
namespace GroundShareAPI.Services;
7+
8+
public interface IFcmSender
9+
{
10+
Task<int> SendToTokensAsync(IEnumerable<string> tokens, string title, string body,
11+
IReadOnlyDictionary<string, string>? data = null, CancellationToken ct = default);
12+
}
13+
14+
public sealed class FcmSender : IFcmSender
15+
{
16+
private static readonly object _initLock = new();
17+
private static bool _initialized;
18+
private static bool _configured;
19+
20+
private readonly ILogger<FcmSender> _logger;
21+
private readonly INotificationsDAL _dal;
22+
23+
public FcmSender(IConfiguration config, ILogger<FcmSender> logger, INotificationsDAL dal)
24+
{
25+
_logger = logger;
26+
_dal = dal;
27+
EnsureInitialized(config, logger);
28+
}
29+
30+
private static void EnsureInitialized(IConfiguration config, ILogger logger)
31+
{
32+
if (_initialized) return;
33+
lock (_initLock)
34+
{
35+
if (_initialized) return;
36+
37+
var json = config["Fcm:ServiceAccountJson"];
38+
var file = config["Fcm:ServiceAccountFile"];
39+
40+
GoogleCredential? credential = null;
41+
if (!string.IsNullOrWhiteSpace(json))
42+
{
43+
credential = GoogleCredential.FromJson(json);
44+
}
45+
else if (!string.IsNullOrWhiteSpace(file) && File.Exists(file))
46+
{
47+
credential = GoogleCredential.FromFile(file);
48+
}
49+
50+
if (credential is null)
51+
{
52+
logger.LogWarning(
53+
"FCM not configured — no Fcm:ServiceAccountJson or Fcm:ServiceAccountFile found. Push notifications will be disabled.");
54+
_initialized = true;
55+
_configured = false;
56+
return;
57+
}
58+
59+
if (FirebaseApp.DefaultInstance is null)
60+
{
61+
FirebaseApp.Create(new AppOptions { Credential = credential });
62+
}
63+
_initialized = true;
64+
_configured = true;
65+
}
66+
}
67+
68+
public async Task<int> SendToTokensAsync(IEnumerable<string> tokens, string title, string body,
69+
IReadOnlyDictionary<string, string>? data = null, CancellationToken ct = default)
70+
{
71+
if (!_configured)
72+
{
73+
_logger.LogDebug("FCM send skipped — sender not configured.");
74+
return 0;
75+
}
76+
77+
var tokenList = tokens.Distinct().ToList();
78+
if (tokenList.Count == 0) return 0;
79+
80+
var message = new MulticastMessage
81+
{
82+
Tokens = tokenList,
83+
Notification = new Notification { Title = title, Body = body },
84+
Data = data ?? new Dictionary<string, string>(),
85+
Android = new AndroidConfig { Priority = Priority.High },
86+
Apns = new ApnsConfig { Aps = new Aps { Sound = "default" } },
87+
};
88+
89+
var response = await FirebaseMessaging.DefaultInstance.SendEachForMulticastAsync(message, ct);
90+
91+
for (int i = 0; i < response.Responses.Count; i++)
92+
{
93+
var r = response.Responses[i];
94+
if (!r.IsSuccess && r.Exception is FirebaseMessagingException fex &&
95+
(fex.MessagingErrorCode == MessagingErrorCode.Unregistered ||
96+
fex.MessagingErrorCode == MessagingErrorCode.InvalidArgument))
97+
{
98+
try { await _dal.DeleteDeviceTokenAsync(tokenList[i]); }
99+
catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete dead FCM token"); }
100+
}
101+
}
102+
103+
_logger.LogInformation("FCM multicast: {Success}/{Total} succeeded", response.SuccessCount, tokenList.Count);
104+
return response.SuccessCount;
105+
}
106+
}

02-Server/appsettings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"ContainerName": "uploads",
1919
"SasExpiryMinutes": 60
2020
},
21+
"Fcm": {
22+
"ServiceAccountFile": "",
23+
"ServiceAccountJson": ""
24+
},
2125
"Logging": {
2226
"LogLevel": {
2327
"Default": "Information",

0 commit comments

Comments
 (0)