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:
soroush.asadi
2026-05-29 17:06:42 +03:30
parent 289c808257
commit 963d02a265
15 changed files with 3754 additions and 0 deletions
@@ -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);
}
}