diff --git a/docs/PWA-TWA.md b/docs/PWA-TWA.md new file mode 100644 index 0000000..f6b9772 --- /dev/null +++ b/docs/PWA-TWA.md @@ -0,0 +1,55 @@ +# Hamkadr PWA, notifications & Cafe Bazaar (TWA) + +Hamkadr ships as a **PWA** (installable web app). The same PWA is wrapped as a **TWA** (Trusted +Web Activity) to publish on **Cafe Bazaar** / Google Play as a real Android app — no separate +codebase. + +## What's already in the app +- `GET /manifest.webmanifest` — name, icons (`/icons/icon-192.png`, `-512`), `display: standalone`, fa/RTL. +- `GET /sw.js` — service worker: offline shell + `push` + `notificationclick` handlers. +- Registered in `_Layout` + iOS `apple-*` meta tags + `apple-touch-icon`. +- `/Download` — install + "enable notifications" + per-OS help. +- `POST /push/subscribe` — stores the browser's Web Push subscription (`WebPushSubscription`). +- VAPID + push settings in `/Admin/Settings`. + +Install paths: **Web/Windows** = browser "Install"; **Android** = Add to Home screen (or Bazaar TWA); +**iOS** = Safari → Share → Add to Home Screen. + +## Notifications +1. **On-device (works now):** the "🔔 فعال‌سازی اعلان‌ها" button asks permission and shows a local + confirmation notification. The service worker is ready to display server pushes. +2. **Server push (to wire):** generate a VAPID key pair and paste it in `/Admin/Settings`: + ```bash + npx web-push generate-vapid-keys # → Public Key + Private Key + ``` + Subscriptions are already captured in `WebPushSubscriptions`. To send, add the **WebPush** NuGet + (through the Nexus proxy) and a small broadcaster: + ```csharp + var client = new WebPush.WebPushClient(); + var vapid = new WebPush.VapidDetails(subject, publicKey, privateKey); + foreach (var s in subs) + client.SendNotification(new WebPush.PushSubscription(s.Endpoint, s.P256dh, s.Auth), + JsonSerializer.Serialize(new { title, body, url }), vapid); + ``` + ⚠️ Iran note: Chrome's push goes through FCM, which may be filtered. The **TWA on Bazaar** can + instead use Bazaar's own push (Pushe/native) for reliable Android delivery. + +## Publishing to Cafe Bazaar (TWA) +1. Install Bubblewrap: `npm i -g @bubblewrap/cli` +2. Initialize from the live manifest: + ```bash + bubblewrap init --manifest https://hamkadr.ir/manifest.webmanifest + bubblewrap build # produces app-release-signed.apk + the signing key + ``` +3. **Digital Asset Links** — so the TWA opens full-screen with no URL bar. Get the SHA-256 + fingerprint Bubblewrap printed and serve this at **`https://hamkadr.ir/.well-known/assetlinks.json`**: + ```json + [{ + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { "namespace": "android_app", "package_name": "ir.hamkadr.twa", + "sha256_cert_fingerprints": [""] } + }] + ``` + Easiest: add a tiny `location = /.well-known/assetlinks.json { ... }` block to the central nginx + (next to the hamkadr vhost) returning that JSON, then `nginx -s reload`. +4. Upload the signed APK to **Cafe Bazaar** developer panel; for Play, upload the AAB. diff --git a/src/JobsMedical.Web/Data/AppDbContext.cs b/src/JobsMedical.Web/Data/AppDbContext.cs index f62769e..1eb062f 100644 --- a/src/JobsMedical.Web/Data/AppDbContext.cs +++ b/src/JobsMedical.Web/Data/AppDbContext.cs @@ -21,6 +21,7 @@ public class AppDbContext : DbContext public DbSet UserPreferences => Set(); public DbSet InterestEvents => Set(); public DbSet AppSettings => Set(); + public DbSet WebPushSubscriptions => Set(); protected override void OnModelCreating(ModelBuilder b) { @@ -110,6 +111,8 @@ public class AppDbContext : DbContext b.Entity().HasIndex(j => j.Status); b.Entity().HasIndex(j => j.FacilityId); + b.Entity().HasIndex(s => s.Endpoint).IsUnique(); + // Dedupe ingested listings by content hash. b.Entity().HasIndex(r => r.ContentHash); b.Entity().HasIndex(r => r.Status); diff --git a/src/JobsMedical.Web/Migrations/20260604074557_PwaPush.Designer.cs b/src/JobsMedical.Web/Migrations/20260604074557_PwaPush.Designer.cs new file mode 100644 index 0000000..2af45cb --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604074557_PwaPush.Designer.cs @@ -0,0 +1,951 @@ +// +using System; +using JobsMedical.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260604074557_PwaPush")] + partial class PwaPush + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("JobsMedical.Web.Models.AppSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AiApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("AiAutoApprove") + .HasColumnType("boolean"); + + b.Property("AiEnabled") + .HasColumnType("boolean"); + + b.Property("AiEndpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("AiModel") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("AiSystemPrompt") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("AutoIngestEnabled") + .HasColumnType("boolean"); + + b.Property("AutoPublishMinConfidence") + .HasColumnType("integer"); + + b.Property("BaleBotToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BaleEnabled") + .HasColumnType("boolean"); + + b.Property("DivarCity") + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("DivarEnabled") + .HasColumnType("boolean"); + + b.Property("DivarQueries") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IngestIntervalMinutes") + .HasColumnType("integer"); + + b.Property("MedjobsEnabled") + .HasColumnType("boolean"); + + b.Property("MedjobsMaxAds") + .HasColumnType("integer"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("NeshanMapKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PushEnabled") + .HasColumnType("boolean"); + + b.Property("SmsApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SmsEnabled") + .HasColumnType("boolean"); + + b.Property("SmsSender") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("SmsTemplate") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TelegramChannels") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("TelegramEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VapidPrivateKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("VapidPublicKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("VapidSubject") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Application", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("integer"); + + b.Property("Message") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ShiftId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("ShiftId", "DoctorId") + .IsUnique(); + + b.ToTable("Applications"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.City", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Province") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.ToTable("Cities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.District", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.ToTable("Districts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.DoctorProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Bio") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("IsVerified") + .HasColumnType("boolean"); + + b.Property("LicenseNo") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("Specialty") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("YearsExperience") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DoctorProfiles"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BaleId") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DistrictId") + .HasColumnType("integer"); + + b.Property("IsVerified") + .HasColumnType("boolean"); + + b.Property("Lat") + .HasColumnType("double precision"); + + b.Property("Lng") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("integer"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("DistrictId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Facilities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("JobOpeningId") + .HasColumnType("integer"); + + b.Property("ShiftId") + .HasColumnType("integer"); + + b.Property("VisitorId") + .IsRequired() + .HasColumnType("character varying(36)"); + + b.HasKey("Id"); + + b.HasIndex("JobOpeningId"); + + b.HasIndex("ShiftId"); + + b.HasIndex("VisitorId", "CreatedAt"); + + b.ToTable("InterestEvents"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("EmploymentType") + .HasColumnType("integer"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("GenderRequirement") + .HasColumnType("integer"); + + b.Property("Requirements") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("SalaryMax") + .HasColumnType("bigint"); + + b.Property("SalaryMin") + .HasColumnType("bigint"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Status"); + + b.ToTable("JobOpenings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.RawListing", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Confidence") + .HasColumnType("integer"); + + b.Property("ContentHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FetchedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LinkedShiftId") + .HasColumnType("integer"); + + b.Property("ParsedJson") + .HasColumnType("text"); + + b.Property("RawText") + .IsRequired() + .HasColumnType("text"); + + b.Property("SourceChannel") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("ValidationNotes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("ContentHash"); + + b.HasIndex("LinkedShiftId"); + + b.HasIndex("Status"); + + b.ToTable("RawListings"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("Description") + .HasMaxLength(1500) + .HasColumnType("character varying(1500)"); + + b.Property("EndTime") + .HasColumnType("time without time zone"); + + b.Property("FacilityId") + .HasColumnType("integer"); + + b.Property("GenderRequirement") + .HasColumnType("integer"); + + b.Property("PayAmount") + .HasColumnType("bigint"); + + b.Property("PayType") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("SharePercent") + .HasColumnType("integer"); + + b.Property("ShiftType") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SpecialtyRequired") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("StartTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FacilityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("Date", "Status"); + + b.ToTable("Shifts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FullName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IsPhoneVerified") + .HasColumnType("boolean"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CityId") + .HasColumnType("integer"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("MinPay") + .HasColumnType("bigint"); + + b.Property("PreferredShiftType") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VisitorId") + .IsRequired() + .HasColumnType("character varying(36)"); + + b.HasKey("Id"); + + b.HasIndex("CityId"); + + b.HasIndex("RoleId"); + + b.HasIndex("VisitorId") + .IsUnique(); + + b.ToTable("UserPreferences"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b => + { + b.Property("Id") + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Visitors"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.WebPushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Auth") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("P256dh") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("VisitorId") + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.ToTable("WebPushSubscriptions"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Application", b => + { + b.HasOne("JobsMedical.Web.Models.User", "Doctor") + .WithMany("Applications") + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.Shift", "Shift") + .WithMany("Applications") + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("Shift"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.District", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany() + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.DoctorProfile", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany() + .HasForeignKey("CityId"); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId"); + + b.HasOne("JobsMedical.Web.Models.User", "User") + .WithOne("DoctorProfile") + .HasForeignKey("JobsMedical.Web.Models.DoctorProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany("Facilities") + .HasForeignKey("CityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.District", "District") + .WithMany("Facilities") + .HasForeignKey("DistrictId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("JobsMedical.Web.Models.User", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("City"); + + b.Navigation("District"); + + b.Navigation("OwnerUser"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => + { + b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening") + .WithMany() + .HasForeignKey("JobOpeningId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("JobsMedical.Web.Models.Shift", "Shift") + .WithMany() + .HasForeignKey("ShiftId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("JobsMedical.Web.Models.Visitor", "Visitor") + .WithMany("Events") + .HasForeignKey("VisitorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobOpening"); + + b.Navigation("Shift"); + + b.Navigation("Visitor"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b => + { + b.HasOne("JobsMedical.Web.Models.Facility", "Facility") + .WithMany() + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.RawListing", b => + { + b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift") + .WithMany() + .HasForeignKey("LinkedShiftId"); + + b.Navigation("LinkedShift"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => + { + b.HasOne("JobsMedical.Web.Models.Facility", "Facility") + .WithMany("Shifts") + .HasForeignKey("FacilityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany("Shifts") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Facility"); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b => + { + b.HasOne("JobsMedical.Web.Models.City", "City") + .WithMany() + .HasForeignKey("CityId"); + + b.HasOne("JobsMedical.Web.Models.Role", "Role") + .WithMany() + .HasForeignKey("RoleId"); + + b.HasOne("JobsMedical.Web.Models.Visitor", "Visitor") + .WithOne("Preferences") + .HasForeignKey("JobsMedical.Web.Models.UserPreferences", "VisitorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("City"); + + b.Navigation("Role"); + + b.Navigation("Visitor"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b => + { + b.HasOne("JobsMedical.Web.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.City", b => + { + b.Navigation("Facilities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.District", b => + { + b.Navigation("Facilities"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Facility", b => + { + b.Navigation("Shifts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Role", b => + { + b.Navigation("Shifts"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.User", b => + { + b.Navigation("Applications"); + + b.Navigation("DoctorProfile"); + }); + + modelBuilder.Entity("JobsMedical.Web.Models.Visitor", b => + { + b.Navigation("Events"); + + b.Navigation("Preferences"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/JobsMedical.Web/Migrations/20260604074557_PwaPush.cs b/src/JobsMedical.Web/Migrations/20260604074557_PwaPush.cs new file mode 100644 index 0000000..86d9782 --- /dev/null +++ b/src/JobsMedical.Web/Migrations/20260604074557_PwaPush.cs @@ -0,0 +1,90 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace JobsMedical.Web.Migrations +{ + /// + public partial class PwaPush : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PushEnabled", + table: "AppSettings", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "VapidPrivateKey", + table: "AppSettings", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "VapidPublicKey", + table: "AppSettings", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "VapidSubject", + table: "AppSettings", + type: "character varying(120)", + maxLength: 120, + nullable: true); + + migrationBuilder.CreateTable( + name: "WebPushSubscriptions", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Endpoint = table.Column(type: "character varying(600)", maxLength: 600, nullable: false), + P256dh = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Auth = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + VisitorId = table.Column(type: "character varying(36)", maxLength: 36, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WebPushSubscriptions", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_WebPushSubscriptions_Endpoint", + table: "WebPushSubscriptions", + column: "Endpoint", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WebPushSubscriptions"); + + migrationBuilder.DropColumn( + name: "PushEnabled", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "VapidPrivateKey", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "VapidPublicKey", + table: "AppSettings"); + + migrationBuilder.DropColumn( + name: "VapidSubject", + table: "AppSettings"); + } + } +} diff --git a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs index 89aeca6..4cca539 100644 --- a/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs +++ b/src/JobsMedical.Web/Migrations/AppDbContextModelSnapshot.cs @@ -93,6 +93,9 @@ namespace JobsMedical.Web.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("PushEnabled") + .HasColumnType("boolean"); + b.Property("SmsApiKey") .HasMaxLength(200) .HasColumnType("character varying(200)"); @@ -118,6 +121,18 @@ namespace JobsMedical.Web.Migrations b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); + b.Property("VapidPrivateKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("VapidPublicKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("VapidSubject") + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + b.HasKey("Id"); b.ToTable("AppSettings"); @@ -668,6 +683,44 @@ namespace JobsMedical.Web.Migrations b.ToTable("Visitors"); }); + modelBuilder.Entity("JobsMedical.Web.Models.WebPushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Auth") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(600) + .HasColumnType("character varying(600)"); + + b.Property("P256dh") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("VisitorId") + .HasMaxLength(36) + .HasColumnType("character varying(36)"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.ToTable("WebPushSubscriptions"); + }); + modelBuilder.Entity("JobsMedical.Web.Models.Application", b => { b.HasOne("JobsMedical.Web.Models.User", "Doctor") diff --git a/src/JobsMedical.Web/Models/AppSetting.cs b/src/JobsMedical.Web/Models/AppSetting.cs index 0e441f2..3968c21 100644 --- a/src/JobsMedical.Web/Models/AppSetting.cs +++ b/src/JobsMedical.Web/Models/AppSetting.cs @@ -66,6 +66,12 @@ public class AppSetting /// (Google Maps is blocked in Iran). Empty → only the "my location" button is shown. [MaxLength(200)] public string? NeshanMapKey { get; set; } + // --- Web Push (PWA notifications). VAPID keypair; generate once with the web-push tooling. --- + public bool PushEnabled { get; set; } = false; + [MaxLength(200)] public string? VapidPublicKey { get; set; } + [MaxLength(200)] public string? VapidPrivateKey { get; set; } + [MaxLength(120)] public string? VapidSubject { get; set; } = "mailto:admin@hamkadr.ir"; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; /// Split a textarea (newline/comma separated) into trimmed non-empty items. diff --git a/src/JobsMedical.Web/Models/WebPushSubscription.cs b/src/JobsMedical.Web/Models/WebPushSubscription.cs new file mode 100644 index 0000000..6670957 --- /dev/null +++ b/src/JobsMedical.Web/Models/WebPushSubscription.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; + +namespace JobsMedical.Web.Models; + +/// A browser's Web Push subscription (PWA notifications). One row per device/browser. +public class WebPushSubscription +{ + public int Id { get; set; } + + [Required, MaxLength(600)] + public string Endpoint { get; set; } = ""; // push service URL (unique key) + + [Required, MaxLength(200)] + public string P256dh { get; set; } = ""; // client public key + + [Required, MaxLength(100)] + public string Auth { get; set; } = ""; // client auth secret + + [MaxLength(36)] + public string? VisitorId { get; set; } // who subscribed (anonymous visitor) + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} + +/// Shape posted by the browser's PushManager.subscribe().toJSON(). +public record PushSubscriptionDto(string? Endpoint, PushKeysDto? Keys); +public record PushKeysDto(string? P256dh, string? Auth); diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml index fa2eaef..f3ccc61 100644 --- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml +++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml @@ -142,6 +142,28 @@

برای انتخاب موقعیت روی نقشه در فرم ثبت مرکز. بدون کلید، فقط دکمه «موقعیت فعلی من» نمایش داده می‌شود.

+
+

اعلان‌ها (Web Push / PWA)

+
+ +
+
+ + +
+
+ + +
+
+ + +
+

جفت‌کلید VAPID را یک‌بار بساز (web-push). بدون آن، اعلان محلی روی دستگاه کار می‌کند ولی ارسال از سرور نیاز به کلید دارد.

+ diff --git a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs index 765b20f..3223caa 100644 --- a/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Admin/Settings.cshtml.cs @@ -37,6 +37,10 @@ public class SettingsModel : PageModel [BindProperty] public string? SmsTemplate { get; set; } [BindProperty] public string? SmsSender { get; set; } [BindProperty] public string? NeshanMapKey { get; set; } + [BindProperty] public bool PushEnabled { get; set; } + [BindProperty] public string? VapidPublicKey { get; set; } + [BindProperty] public string? VapidPrivateKey { get; set; } + [BindProperty] public string? VapidSubject { get; set; } [TempData] public string? Saved { get; set; } public async Task OnGetAsync() @@ -66,6 +70,10 @@ public class SettingsModel : PageModel SmsTemplate = s.SmsTemplate; SmsSender = s.SmsSender; NeshanMapKey = s.NeshanMapKey; + PushEnabled = s.PushEnabled; + VapidPublicKey = s.VapidPublicKey; + VapidPrivateKey = s.VapidPrivateKey; + VapidSubject = s.VapidSubject; } public async Task OnPostAsync() @@ -96,6 +104,10 @@ public class SettingsModel : PageModel SmsTemplate = SmsTemplate, SmsSender = SmsSender, NeshanMapKey = NeshanMapKey, + PushEnabled = PushEnabled, + VapidPublicKey = VapidPublicKey, + VapidPrivateKey = VapidPrivateKey, + VapidSubject = VapidSubject, }); Saved = "تنظیمات ذخیره شد."; return RedirectToPage(); diff --git a/src/JobsMedical.Web/Pages/Download.cshtml b/src/JobsMedical.Web/Pages/Download.cshtml new file mode 100644 index 0000000..fa68401 --- /dev/null +++ b/src/JobsMedical.Web/Pages/Download.cshtml @@ -0,0 +1,96 @@ +@page +@model JobsMedical.Web.Pages.DownloadModel +@{ + ViewData["Title"] = "دریافت اپلیکیشن همکادر"; + ViewData["Description"] = "نصب اپلیکیشن همکادر روی موبایل و دسکتاپ — اندروید، iOS، ویندوز و وب. دریافت اعلان فرصت‌های شغلی کادر درمان."; +} + +
+
+

دریافت اپلیکیشن همکادر

+

همکادر یک «اپ تحت وب» (PWA) است؛ بدون فروشگاه هم نصب می‌شود و مثل یک اپ واقعی روی صفحه‌ی اصلی می‌نشیند و اعلان می‌فرستد.

+
+
+ +
+
+
+

نصب سریع روی این دستگاه

+ با یک دکمه، همکادر را به صفحه‌ی اصلی اضافه کن +
+
+ + +
+
+

+ +
+
+

🌐 وب

+

همین حالا در مرورگر باز است. برای نصب، دکمه‌ی «نصب اپلیکیشن» بالا را بزن (کروم/اج).

+
+
+

🪟 ویندوز

+

در Chrome/Edge، آیکن نصب (⊕) در نوار آدرس را بزن یا منو ← «Install همکادر». روی دسکتاپ مثل یک برنامه باز می‌شود.

+
+
+

🤖 اندروید

+

در Chrome دکمه‌ی «نصب اپلیکیشن» یا منو ← «Add to Home screen». به‌زودی از کافه‌بازار هم قابل نصب خواهد بود.

+
+
+

iOS

+

در Safari، دکمه‌ی Share (□↑) ← «Add to Home Screen» ← Add. آیکن همکادر روی صفحه‌ی اصلی می‌آید.

+
+
+ +
+

راهنمای استفاده

+
    +
  1. نصب کن — طبق راهنمای دستگاهت بالا، همکادر را به صفحه‌ی اصلی اضافه کن.
  2. +
  3. وارد شو — با شماره‌ موبایل و کد پیامکی؛ هنگام ثبت‌نام نوع حساب (کادر درمان یا کارفرما) را انتخاب کن.
  4. +
  5. کادر درمان: در «علاقه‌مندی‌ها» نقش/شهر/نوع شیفت را تعیین کن تا پیشنهادهای متناسب بگیری؛ با «نزدیک من» نزدیک‌ترین فرصت‌ها را ببین؛ روی شیفت/استخدام «اعلام تمایل» بزن.
  6. +
  7. کارفرما: مرکزت را ثبت کن (موقعیت را روی نقشه بگذار)، سپس شیفت/استخدام منتشر کن و متقاضیان را در پنل ببین.
  8. +
  9. اعلان‌ها — دکمه‌ی «فعال‌سازی اعلان‌ها» را بزن تا از فرصت‌های جدید باخبر شوی.
  10. +
+
+
+ +@section Scripts { + +} diff --git a/src/JobsMedical.Web/Pages/Download.cshtml.cs b/src/JobsMedical.Web/Pages/Download.cshtml.cs new file mode 100644 index 0000000..e7218cf --- /dev/null +++ b/src/JobsMedical.Web/Pages/Download.cshtml.cs @@ -0,0 +1,20 @@ +using JobsMedical.Web.Services.Scraping; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace JobsMedical.Web.Pages; + +public class DownloadModel : PageModel +{ + private readonly SettingsService _settings; + public DownloadModel(SettingsService settings) => _settings = settings; + + public string? VapidPublicKey { get; private set; } + public bool PushReady { get; private set; } + + public async Task OnGetAsync() + { + var s = await _settings.GetAsync(); + VapidPublicKey = s.VapidPublicKey; + PushReady = s.PushEnabled && !string.IsNullOrWhiteSpace(s.VapidPublicKey); + } +} diff --git a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml index 1232571..adb6077 100644 --- a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml @@ -12,6 +12,15 @@ self-hosted under wwwroot/fonts (@@font-face in site.css) — no external CDN. *@ + + @* PWA: installable app (Web/Windows/Android via this manifest; iOS via apple-* tags) *@ + + + + + + +