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:
@@ -60,10 +60,25 @@ public class AppDbContext : DbContext
|
||||
public DbSet<WebsiteComment> WebsiteComments => Set<WebsiteComment>();
|
||||
public DbSet<DemoRequest> DemoRequests => Set<DemoRequest>();
|
||||
|
||||
// Push notifications (Pushe)
|
||||
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder 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 =>
|
||||
{
|
||||
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");
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
|
||||
@@ -35,6 +35,7 @@ public static class DependencyInjection
|
||||
services.AddHttpClient<ITaraPaymentGateway, TaraPaymentGateway>();
|
||||
services.AddHttpClient<ISnappfoodClient, SnappfoodClient>();
|
||||
services.AddHttpClient<ITap30Client, Tap30Client>();
|
||||
services.AddHttpClient<IPushSender, PusheNotificationSender>();
|
||||
services.AddScoped<ITarazTaxService, TarazTaxService>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user