Add push notifications (Pushe) + Capacitor shell for Koja
Iran-safe push for the Koja Android app (Cafe Bazaar / Myket / direct APK):
Backend
- PushDevice entity + EF migration; idempotent device register/unregister.
- IPushSender / PusheNotificationSender (Pushe REST) — SendToTopic for
marketing (city-{slug}) and saved-café (cafe-{slug}) pushes, SendToTokens
for targeted order/reservation updates.
- Public register/unregister endpoints + authorized topic broadcast.
App
- capacitor.config.ts (native WebView loads the live PWA via server.url).
- push.ts Pushe glue: topic helpers + backend device registration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.API.Services;
|
||||
using Meezi.Core.Interfaces;
|
||||
using Meezi.Shared;
|
||||
|
||||
namespace Meezi.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Push-notification endpoints for the native (Capacitor + Pushe) app.
|
||||
///
|
||||
/// POST /api/public/push/register — anonymous device registration
|
||||
/// POST /api/public/push/unregister — anonymous device removal
|
||||
/// POST /api/push/broadcast — authorized topic broadcast (marketing /
|
||||
/// saved-café alerts)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
public class PushController : ControllerBase
|
||||
{
|
||||
private readonly IPushDeviceService _devices;
|
||||
private readonly IPushSender _sender;
|
||||
|
||||
public PushController(IPushDeviceService devices, IPushSender sender)
|
||||
{
|
||||
_devices = devices;
|
||||
_sender = sender;
|
||||
}
|
||||
|
||||
[HttpPost("api/public/push/register")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> Register(
|
||||
[FromBody] RegisterPushDeviceRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("INVALID_TOKEN", "Device token is required.")));
|
||||
|
||||
await _devices.RegisterAsync(request, ct);
|
||||
return Ok(new ApiResponse<object>(true, new { registered = true }));
|
||||
}
|
||||
|
||||
[HttpPost("api/public/push/unregister")]
|
||||
[AllowAnonymous]
|
||||
[EnableRateLimiting("public-read")]
|
||||
public async Task<IActionResult> Unregister(
|
||||
[FromBody] UnregisterPushDeviceRequest request, CancellationToken ct)
|
||||
{
|
||||
await _devices.UnregisterAsync(request.Token, ct);
|
||||
return Ok(new ApiResponse<object>(true, new { unregistered = true }));
|
||||
}
|
||||
|
||||
[HttpPost("api/push/broadcast")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> Broadcast(
|
||||
[FromBody] BroadcastPushRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Topic))
|
||||
return BadRequest(new ApiResponse<object>(false, null,
|
||||
new ApiError("INVALID_TOPIC", "Topic is required.")));
|
||||
|
||||
await _sender.SendToTopicAsync(request.Topic, request.Title, request.Body, request.DeepLink, ct);
|
||||
return Ok(new ApiResponse<object>(true, new { sent = true }));
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<IDailyReportService, DailyReportService>();
|
||||
services.AddScoped<IPublicService, PublicService>();
|
||||
services.AddScoped<IReviewService, ReviewService>();
|
||||
services.AddScoped<IPushDeviceService, PushDeviceService>();
|
||||
services.AddScoped<IBillingService, BillingService>();
|
||||
services.AddScoped<IBillingPaymentOrchestrator, BillingPaymentOrchestrator>();
|
||||
services.Configure<DeliveryPlatformsOptions>(configuration.GetSection(DeliveryPlatformsOptions.SectionName));
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Meezi.API.Models.Public;
|
||||
|
||||
/// <summary>Device registration from the native (Capacitor + Pushe) shell.</summary>
|
||||
public record RegisterPushDeviceRequest(
|
||||
string Token,
|
||||
string Platform = "android",
|
||||
string? City = null,
|
||||
string? ConsumerAccountId = null);
|
||||
|
||||
public record UnregisterPushDeviceRequest(string Token);
|
||||
|
||||
/// <summary>Broadcast a push to a Pushe topic (city-{slug} / cafe-{slug}).</summary>
|
||||
public record BroadcastPushRequest(
|
||||
string Topic,
|
||||
string Title,
|
||||
string Body,
|
||||
string? DeepLink = null);
|
||||
@@ -0,0 +1,16 @@
|
||||
using Meezi.API.Models.Public;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
/// <summary>Persists push-device registrations for targeted (token) pushes.</summary>
|
||||
public interface IPushDeviceService
|
||||
{
|
||||
/// <summary>Upserts a device by token (idempotent — safe to call on every app open).</summary>
|
||||
Task RegisterAsync(RegisterPushDeviceRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Soft-deletes a device by token (e.g. on logout / uninstall signal).</summary>
|
||||
Task UnregisterAsync(string token, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Resolves device tokens for a consumer account — for order/reservation pushes.</summary>
|
||||
Task<IReadOnlyList<string>> GetTokensForConsumerAsync(string consumerAccountId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Meezi.API.Models.Public;
|
||||
using Meezi.Core.Entities;
|
||||
using Meezi.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Meezi.API.Services;
|
||||
|
||||
public class PushDeviceService : IPushDeviceService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public PushDeviceService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task RegisterAsync(RegisterPushDeviceRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token)) return;
|
||||
|
||||
var existing = await _db.PushDevices
|
||||
.FirstOrDefaultAsync(d => d.Token == request.Token, ct);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
_db.PushDevices.Add(new PushDevice
|
||||
{
|
||||
Token = request.Token,
|
||||
Platform = string.IsNullOrWhiteSpace(request.Platform) ? "android" : request.Platform,
|
||||
City = request.City,
|
||||
ConsumerAccountId = request.ConsumerAccountId,
|
||||
LastSeenAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.City = request.City ?? existing.City;
|
||||
existing.ConsumerAccountId = request.ConsumerAccountId ?? existing.ConsumerAccountId;
|
||||
existing.LastSeenAt = DateTime.UtcNow;
|
||||
existing.DeletedAt = null; // re-activate if it was previously removed
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task UnregisterAsync(string token, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return;
|
||||
|
||||
var device = await _db.PushDevices.FirstOrDefaultAsync(d => d.Token == token, ct);
|
||||
if (device is null) return;
|
||||
|
||||
device.DeletedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetTokensForConsumerAsync(
|
||||
string consumerAccountId, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(consumerAccountId)) return [];
|
||||
|
||||
return await _db.PushDevices
|
||||
.Where(d => d.ConsumerAccountId == consumerAccountId)
|
||||
.Select(d => d.Token)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user