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<IDailyReportService, DailyReportService>();
|
||||||
services.AddScoped<IPublicService, PublicService>();
|
services.AddScoped<IPublicService, PublicService>();
|
||||||
services.AddScoped<IReviewService, ReviewService>();
|
services.AddScoped<IReviewService, ReviewService>();
|
||||||
|
services.AddScoped<IPushDeviceService, PushDeviceService>();
|
||||||
services.AddScoped<IBillingService, BillingService>();
|
services.AddScoped<IBillingService, BillingService>();
|
||||||
services.AddScoped<IBillingPaymentOrchestrator, BillingPaymentOrchestrator>();
|
services.AddScoped<IBillingPaymentOrchestrator, BillingPaymentOrchestrator>();
|
||||||
services.Configure<DeliveryPlatformsOptions>(configuration.GetSection(DeliveryPlatformsOptions.SectionName));
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace Meezi.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A registered push-notification device (Pushe). One row per install token.
|
||||||
|
/// Used for targeted order/reservation pushes; broadcast/marketing pushes go
|
||||||
|
/// through Pushe topics (city-{slug}, cafe-{slug}) the device subscribes to
|
||||||
|
/// client-side, but we mirror the city here for server-side fallback targeting.
|
||||||
|
/// </summary>
|
||||||
|
public class PushDevice : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>Pushe device/install token.</summary>
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>"android" (only platform shipped today).</summary>
|
||||||
|
public string Platform { get; set; } = "android";
|
||||||
|
|
||||||
|
/// <summary>Optional city slug the user is browsing — for city-topic targeting.</summary>
|
||||||
|
public string? City { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional link to a platform consumer account (OTP login).</summary>
|
||||||
|
public string? ConsumerAccountId { get; set; }
|
||||||
|
|
||||||
|
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Meezi.Core.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends push notifications through the Pushe gateway (Iran-safe; FCM is
|
||||||
|
/// unreliable inside Iran). Topics are subscribed client-side via the native
|
||||||
|
/// Pushe SDK; this service triggers the actual sends from the backend.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPushSender
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Broadcasts to every device subscribed to a Pushe topic.
|
||||||
|
/// Topics: <c>city-{slug}</c> (marketing / new cafés),
|
||||||
|
/// <c>cafe-{slug}</c> (saved-café alerts).
|
||||||
|
/// </summary>
|
||||||
|
Task SendToTopicAsync(
|
||||||
|
string topic,
|
||||||
|
string title,
|
||||||
|
string body,
|
||||||
|
string? deepLink = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends to one or more specific device tokens — used for transactional
|
||||||
|
/// order / reservation updates.
|
||||||
|
/// </summary>
|
||||||
|
Task SendToTokensAsync(
|
||||||
|
IReadOnlyCollection<string> tokens,
|
||||||
|
string title,
|
||||||
|
string body,
|
||||||
|
string? deepLink = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -60,10 +60,25 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<WebsiteComment> WebsiteComments => Set<WebsiteComment>();
|
public DbSet<WebsiteComment> WebsiteComments => Set<WebsiteComment>();
|
||||||
public DbSet<DemoRequest> DemoRequests => Set<DemoRequest>();
|
public DbSet<DemoRequest> DemoRequests => Set<DemoRequest>();
|
||||||
|
|
||||||
|
// Push notifications (Pushe)
|
||||||
|
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<PushDevice>(e =>
|
||||||
|
{
|
||||||
|
e.HasKey(x => x.Id);
|
||||||
|
e.HasIndex(x => x.Token).IsUnique();
|
||||||
|
e.HasIndex(x => x.City);
|
||||||
|
e.Property(x => x.Token).HasMaxLength(256).IsRequired();
|
||||||
|
e.Property(x => x.Platform).HasMaxLength(20).IsRequired();
|
||||||
|
e.Property(x => x.City).HasMaxLength(100);
|
||||||
|
e.Property(x => x.ConsumerAccountId).HasMaxLength(64);
|
||||||
|
e.HasQueryFilter(x => x.DeletedAt == null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<Cafe>(e =>
|
modelBuilder.Entity<Cafe>(e =>
|
||||||
{
|
{
|
||||||
e.HasKey(x => x.Id);
|
e.HasKey(x => x.Id);
|
||||||
|
|||||||
+3141
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPushDevices : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "PushDevices",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Token = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
|
||||||
|
Platform = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||||
|
City = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||||
|
ConsumerAccountId = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||||
|
LastSeenAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_PushDevices", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PushDevices_City",
|
||||||
|
table: "PushDevices",
|
||||||
|
column: "City");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PushDevices_Token",
|
||||||
|
table: "PushDevices",
|
||||||
|
column: "Token",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "PushDevices");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1641,6 +1641,48 @@ namespace Meezi.Infrastructure.Data.Migrations
|
|||||||
b.ToTable("PlatformSettings");
|
b.ToTable("PlatformSettings");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Meezi.Core.Entities.PushDevice", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("City")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<string>("ConsumerAccountId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("LastSeenAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Platform")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("character varying(256)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("City");
|
||||||
|
|
||||||
|
b.HasIndex("Token")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("PushDevices");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b =>
|
modelBuilder.Entity("Meezi.Core.Entities.QueueTicket", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ public static class DependencyInjection
|
|||||||
services.AddHttpClient<ITaraPaymentGateway, TaraPaymentGateway>();
|
services.AddHttpClient<ITaraPaymentGateway, TaraPaymentGateway>();
|
||||||
services.AddHttpClient<ISnappfoodClient, SnappfoodClient>();
|
services.AddHttpClient<ISnappfoodClient, SnappfoodClient>();
|
||||||
services.AddHttpClient<ITap30Client, Tap30Client>();
|
services.AddHttpClient<ITap30Client, Tap30Client>();
|
||||||
|
services.AddHttpClient<IPushSender, PusheNotificationSender>();
|
||||||
services.AddScoped<ITarazTaxService, TarazTaxService>();
|
services.AddScoped<ITarazTaxService, TarazTaxService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using Meezi.Core.Interfaces;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Meezi.Infrastructure.ExternalServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pushe (pushe.co) push-notification gateway — Iran-safe alternative to FCM.
|
||||||
|
///
|
||||||
|
/// Config is read from the platform runtime config (DB) first, then falls back
|
||||||
|
/// to IConfiguration:
|
||||||
|
/// integrations.pushe.token / "Pushe:Token" — Pushe REST API token
|
||||||
|
/// integrations.pushe.appId / "Pushe:AppId" — Pushe application id (app_ids)
|
||||||
|
/// integrations.pushe.enabled / "Pushe:Enabled"
|
||||||
|
///
|
||||||
|
/// Send endpoint: POST {BaseUrl}/messaging/notifications/
|
||||||
|
/// Verify the exact request body against the current Pushe API docs — the
|
||||||
|
/// shape below targets the v2 messaging API (topic via "data.subscriptions",
|
||||||
|
/// devices via "devices").
|
||||||
|
/// </summary>
|
||||||
|
public class PusheNotificationSender : IPushSender
|
||||||
|
{
|
||||||
|
private const string DbKeyToken = "integrations.pushe.token";
|
||||||
|
private const string DbKeyAppId = "integrations.pushe.appId";
|
||||||
|
private const string DbKeyEnabled = "integrations.pushe.enabled";
|
||||||
|
|
||||||
|
private const string BaseUrl = "https://api.pushe.co/v2";
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IPlatformRuntimeConfig _platform;
|
||||||
|
private readonly IHostEnvironment _environment;
|
||||||
|
private readonly ILogger<PusheNotificationSender> _logger;
|
||||||
|
|
||||||
|
public PusheNotificationSender(
|
||||||
|
HttpClient http,
|
||||||
|
IConfiguration configuration,
|
||||||
|
IPlatformRuntimeConfig platform,
|
||||||
|
IHostEnvironment environment,
|
||||||
|
ILogger<PusheNotificationSender> logger)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_configuration = configuration;
|
||||||
|
_platform = platform;
|
||||||
|
_environment = environment;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendToTopicAsync(
|
||||||
|
string topic, string title, string body, string? deepLink = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
=> SendAsync(new { topics = new[] { topic } }, title, body, deepLink, $"topic '{topic}'", cancellationToken);
|
||||||
|
|
||||||
|
public Task SendToTokensAsync(
|
||||||
|
IReadOnlyCollection<string> tokens, string title, string body, string? deepLink = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (tokens.Count == 0) return Task.CompletedTask;
|
||||||
|
return SendAsync(new { devices = tokens.ToArray() }, title, body, deepLink,
|
||||||
|
$"{tokens.Count} device(s)", cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendAsync(
|
||||||
|
object target, string title, string body, string? deepLink,
|
||||||
|
string targetDescription, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (token, appId, enabled) = await GetConfigAsync(ct);
|
||||||
|
|
||||||
|
if (!enabled || string.IsNullOrWhiteSpace(token) || string.IsNullOrWhiteSpace(appId))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Pushe not configured — push to {Target} skipped: {Title}", targetDescription, title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[DEV PUSH] {Target} :: {Title} — {Body}", targetDescription, title, body);
|
||||||
|
return; // Skip real push in development
|
||||||
|
}
|
||||||
|
|
||||||
|
var notification = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["title"] = title,
|
||||||
|
["content"] = body,
|
||||||
|
};
|
||||||
|
if (!string.IsNullOrWhiteSpace(deepLink))
|
||||||
|
{
|
||||||
|
notification["action"] = new { action_type = "U", url = deepLink };
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["app_ids"] = new[] { appId },
|
||||||
|
["data"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["notification"] = notification,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Merge target (topics / devices) into "data".
|
||||||
|
foreach (var prop in target.GetType().GetProperties())
|
||||||
|
{
|
||||||
|
((Dictionary<string, object?>)payload["data"]!)[prop.Name] = prop.GetValue(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, $"{BaseUrl}/messaging/notifications/")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(payload),
|
||||||
|
};
|
||||||
|
req.Headers.TryAddWithoutValidation("Authorization", $"Token {token}");
|
||||||
|
|
||||||
|
var response = await _http.SendAsync(req, ct);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var detail = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Pushe send to {Target} failed: {Status} {Detail}",
|
||||||
|
targetDescription, (int)response.StatusCode, detail);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Pushe push sent to {Target}: {Title}", targetDescription, title);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Pushe send to {Target} threw", targetDescription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string? Token, string? AppId, bool Enabled)> GetConfigAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var enabledRaw = await _platform.GetAsync(DbKeyEnabled, ct)
|
||||||
|
?? _configuration["Pushe:Enabled"];
|
||||||
|
var enabled = !string.Equals(enabledRaw, "false", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var token = await _platform.GetAsync(DbKeyToken, ct)
|
||||||
|
?? _configuration["Pushe:Token"];
|
||||||
|
var appId = await _platform.GetAsync(DbKeyAppId, ct)
|
||||||
|
?? _configuration["Pushe:AppId"];
|
||||||
|
|
||||||
|
return (token, appId, enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { CapacitorConfig } from "@capacitor/cli";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capacitor native-shell config for Koja (کجا).
|
||||||
|
*
|
||||||
|
* Strategy: the app is an SSR/ISR Next.js site, so we do NOT bundle a static
|
||||||
|
* export. Instead the native WebView loads the live hosted PWA via `server.url`.
|
||||||
|
* Content updates ship instantly (no store re-publish); only native changes
|
||||||
|
* (push SDK, splash, permissions) require a new APK/AAB.
|
||||||
|
*
|
||||||
|
* Stores: Cafe Bazaar + Myket + direct APK. (No Google Play / FCM — Iran.)
|
||||||
|
* Push: Pushe (cordova-plugin-pushe), consumed through Capacitor.
|
||||||
|
*/
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: "ir.meezi.koja",
|
||||||
|
appName: "کجا",
|
||||||
|
// `webDir` is required by the CLI but unused when `server.url` is set.
|
||||||
|
webDir: "public",
|
||||||
|
server: {
|
||||||
|
// Production PWA the WebView loads. Override per-build with CAP_SERVER_URL.
|
||||||
|
url: process.env.CAP_SERVER_URL ?? "https://koja.meezi.ir",
|
||||||
|
cleartext: false,
|
||||||
|
androidScheme: "https",
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
allowMixedContent: false,
|
||||||
|
backgroundColor: "#f9fafb",
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
SplashScreen: {
|
||||||
|
launchShowDuration: 1200,
|
||||||
|
backgroundColor: "#0F6E56",
|
||||||
|
androidScaleType: "CENTER_CROP",
|
||||||
|
showSpinner: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* push.ts — Pushe (Iran-safe) push notification glue for the Capacitor shell.
|
||||||
|
*
|
||||||
|
* On the web (browser) these calls are inert no-ops; they only do real work
|
||||||
|
* inside the native Android shell where `cordova-plugin-pushe` injects the
|
||||||
|
* global `window.Pushe` object.
|
||||||
|
*
|
||||||
|
* Topics
|
||||||
|
* city-{slug} → marketing / new-café pushes for a city
|
||||||
|
* cafe-{slug} → saved-café alerts (new menu, events, status)
|
||||||
|
* Per-device token → targeted order / reservation updates (registered to backend)
|
||||||
|
*
|
||||||
|
* Backend contract (to implement):
|
||||||
|
* POST /api/public/push/register { token, platform: "android", city?, savedCafes?: string[] }
|
||||||
|
* POST /api/public/push/unregister { token }
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://api.meezi.ir";
|
||||||
|
|
||||||
|
interface PusheBridge {
|
||||||
|
initialize?: (success?: () => void, error?: (e: unknown) => void) => void;
|
||||||
|
getDeviceId?: (cb: (id: string) => void) => void;
|
||||||
|
subscribe: (topic: string) => void;
|
||||||
|
unsubscribe: (topic: string) => void;
|
||||||
|
setUserConsent?: (granted: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridge(): PusheBridge | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return (window as unknown as { Pushe?: PusheBridge }).Pushe ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True only inside the native Capacitor shell with Pushe available. */
|
||||||
|
export function isPushAvailable(): boolean {
|
||||||
|
return bridge() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the Pushe device token (advertising/device id), or null on web. */
|
||||||
|
export function getDeviceToken(): Promise<string | null> {
|
||||||
|
const p = bridge();
|
||||||
|
if (!p?.getDeviceId) return Promise.resolve(null);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
p.getDeviceId!((id) => resolve(id || null));
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeTopic(topic: string): void {
|
||||||
|
bridge()?.subscribe(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsubscribeTopic(topic: string): void {
|
||||||
|
bridge()?.unsubscribe(topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marketing / new-café pushes for a city. */
|
||||||
|
export const cityTopic = (citySlug: string) => `city-${citySlug}`;
|
||||||
|
/** Saved-café alerts. */
|
||||||
|
export const cafeTopic = (cafeSlug: string) => `cafe-${cafeSlug}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers this device with the backend so it can receive targeted
|
||||||
|
* (order/reservation) pushes and so topic membership is mirrored server-side.
|
||||||
|
* No-op on the web.
|
||||||
|
*/
|
||||||
|
export async function registerDevice(opts: {
|
||||||
|
city?: string;
|
||||||
|
savedCafes?: string[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const token = await getDeviceToken();
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await fetch(`${API_URL}/api/public/push/register`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token, platform: "android", ...opts }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call once after the user grants notification consent. Initializes Pushe,
|
||||||
|
* registers the device, and subscribes to the user's city topic.
|
||||||
|
*/
|
||||||
|
export async function enablePush(citySlug?: string): Promise<void> {
|
||||||
|
const p = bridge();
|
||||||
|
if (!p) return;
|
||||||
|
p.setUserConsent?.(true);
|
||||||
|
p.initialize?.();
|
||||||
|
if (citySlug) subscribeTopic(cityTopic(citySlug));
|
||||||
|
await registerDevice({ city: citySlug });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user