PWA: installable app (web/win/android/ios) + download/help page + push notifications
- manifest.webmanifest + service worker (offline shell + push + notificationclick) + PNG icons (192/512/apple) + iOS meta + SW registration → installable everywhere - /Download page: per-OS install help (web/windows/android/ios), install button (beforeinstallprompt), 'enable notifications' flow, usage guide, Bazaar/TWA note; nav + footer links - Web Push foundation: WebPushSubscription entity + /push/subscribe (stores), VAPID + push settings in /Admin/Settings, on-device local notification; server broadcast documented (WebPush via Nexus) - docs/PWA-TWA.md: VAPID keygen, server-push wiring, Bubblewrap→Cafe Bazaar + assetlinks steps - Verified: manifest/sw/icons served, download page, subscribe stores (200), layout wired Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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": ["<SHA256_FROM_BUBBLEWRAP>"] }
|
||||
}]
|
||||
```
|
||||
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.
|
||||
@@ -21,6 +21,7 @@ public class AppDbContext : DbContext
|
||||
public DbSet<UserPreferences> UserPreferences => Set<UserPreferences>();
|
||||
public DbSet<InterestEvent> InterestEvents => Set<InterestEvent>();
|
||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder b)
|
||||
{
|
||||
@@ -110,6 +111,8 @@ public class AppDbContext : DbContext
|
||||
b.Entity<JobOpening>().HasIndex(j => j.Status);
|
||||
b.Entity<JobOpening>().HasIndex(j => j.FacilityId);
|
||||
|
||||
b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique();
|
||||
|
||||
// Dedupe ingested listings by content hash.
|
||||
b.Entity<RawListing>().HasIndex(r => r.ContentHash);
|
||||
b.Entity<RawListing>().HasIndex(r => r.Status);
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("AiApiKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("AiAutoApprove")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("AiEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("AiEndpoint")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("AiModel")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("AiSystemPrompt")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<bool>("AutoIngestEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("AutoPublishMinConfidence")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("BaleBotToken")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("BaleEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("DivarCity")
|
||||
.HasMaxLength(60)
|
||||
.HasColumnType("character varying(60)");
|
||||
|
||||
b.Property<bool>("DivarEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("DivarQueries")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("IngestIntervalMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("MedjobsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("MedjobsMaxAds")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Mode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("NeshanMapKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("PushEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SmsApiKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("SmsEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SmsSender")
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)");
|
||||
|
||||
b.Property<string>("SmsTemplate")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TelegramChannels")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<bool>("TelegramEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("VapidPrivateKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("VapidPublicKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("VapidSubject")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Application", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("DoctorId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("ShiftId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Province")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Cities");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<int?>("CityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LicenseNo")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Specialty")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Address")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("BaleId")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<int>("CityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("DistrictId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsVerified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<double?>("Lat")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("Lng")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int?>("OwnerUserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<int>("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<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EventType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("JobOpeningId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ShiftId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<int>("EmploymentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("FacilityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("GenderRequirement")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Requirements")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<int>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long?>("SalaryMax")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<long?>("SalaryMin")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("Source")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("Confidence")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ContentHash")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTime>("FetchedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("LinkedShiftId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ParsedJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RawText")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("SourceChannel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Roles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1500)
|
||||
.HasColumnType("character varying(1500)");
|
||||
|
||||
b.Property<TimeOnly>("EndTime")
|
||||
.HasColumnType("time without time zone");
|
||||
|
||||
b.Property<int>("FacilityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("GenderRequirement")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long?>("PayAmount")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("PayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("SharePercent")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ShiftType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Source")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("SpecialtyRequired")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<TimeOnly>("StartTime")
|
||||
.HasColumnType("time without time zone");
|
||||
|
||||
b.Property<int>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("FullName")
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)");
|
||||
|
||||
b.Property<bool>("IsPhoneVerified")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Phone")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("CityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Gender")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long?>("MinPay")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int?>("PreferredShiftType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasMaxLength(36)
|
||||
.HasColumnType("character varying(36)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("LastSeenAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Visitors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.WebPushSubscription", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Auth")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Endpoint")
|
||||
.IsRequired()
|
||||
.HasMaxLength(600)
|
||||
.HasColumnType("character varying(600)");
|
||||
|
||||
b.Property<string>("P256dh")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PwaPush : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PushEnabled",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "VapidPrivateKey",
|
||||
table: "AppSettings",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "VapidPublicKey",
|
||||
table: "AppSettings",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "VapidSubject",
|
||||
table: "AppSettings",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WebPushSubscriptions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
Endpoint = table.Column<string>(type: "character varying(600)", maxLength: 600, nullable: false),
|
||||
P256dh = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Auth = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
VisitorId = table.Column<string>(type: "character varying(36)", maxLength: 36, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,9 @@ namespace JobsMedical.Web.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<bool>("PushEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("SmsApiKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
@@ -118,6 +121,18 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("VapidPrivateKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("VapidPublicKey")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Auth")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Endpoint")
|
||||
.IsRequired()
|
||||
.HasMaxLength(600)
|
||||
.HasColumnType("character varying(600)");
|
||||
|
||||
b.Property<string>("P256dh")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("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")
|
||||
|
||||
@@ -66,6 +66,12 @@ public class AppSetting
|
||||
/// (Google Maps is blocked in Iran). Empty → only the "my location" button is shown.</summary>
|
||||
[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;
|
||||
|
||||
/// <summary>Split a textarea (newline/comma separated) into trimmed non-empty items.</summary>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace JobsMedical.Web.Models;
|
||||
|
||||
/// <summary>A browser's Web Push subscription (PWA notifications). One row per device/browser.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Shape posted by the browser's PushManager.subscribe().toJSON().</summary>
|
||||
public record PushSubscriptionDto(string? Endpoint, PushKeysDto? Keys);
|
||||
public record PushKeysDto(string? P256dh, string? Auth);
|
||||
@@ -142,6 +142,28 @@
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای انتخاب موقعیت روی نقشه در فرم ثبت مرکز. بدون کلید، فقط دکمه «موقعیت فعلی من» نمایش داده میشود.</p>
|
||||
</div>
|
||||
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||
<h3 style="margin-top:0;">اعلانها (Web Push / PWA)</h3>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="PushEnabled" value="true" style="width:auto;" checked="@Model.PushEnabled" />
|
||||
فعالسازی اشتراک اعلان مرورگری
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>VAPID Public Key</label>
|
||||
<input type="text" name="VapidPublicKey" value="@Model.VapidPublicKey" dir="ltr" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>VAPID Private Key</label>
|
||||
<input type="password" name="VapidPrivateKey" value="@Model.VapidPrivateKey" dir="ltr" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>VAPID Subject</label>
|
||||
<input type="text" name="VapidSubject" value="@Model.VapidSubject" dir="ltr" placeholder="mailto:admin@hamkadr.ir" />
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;">جفتکلید VAPID را یکبار بساز (web-push). بدون آن، اعلان محلی روی دستگاه کار میکند ولی ارسال از سرور نیاز به کلید دارد.</p>
|
||||
|
||||
<button type="submit" class="btn btn-accent btn-block btn-lg">ذخیره تنظیمات</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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<IActionResult> 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();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.DownloadModel
|
||||
@{
|
||||
ViewData["Title"] = "دریافت اپلیکیشن همکادر";
|
||||
ViewData["Description"] = "نصب اپلیکیشن همکادر روی موبایل و دسکتاپ — اندروید، iOS، ویندوز و وب. دریافت اعلان فرصتهای شغلی کادر درمان.";
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>دریافت اپلیکیشن همکادر</h1>
|
||||
<p class="muted">همکادر یک «اپ تحت وب» (PWA) است؛ بدون فروشگاه هم نصب میشود و مثل یک اپ واقعی روی صفحهی اصلی مینشیند و اعلان میفرستد.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section">
|
||||
<div class="rec-banner">
|
||||
<div>
|
||||
<h2 style="margin:0 0 4px;">نصب سریع روی این دستگاه</h2>
|
||||
<span style="opacity:.9; font-size:14px;">با یک دکمه، همکادر را به صفحهی اصلی اضافه کن</span>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<button id="installBtn" class="btn btn-outline" style="display:none;">⬇️ نصب اپلیکیشن</button>
|
||||
<button id="notifyBtn" class="btn btn-outline">🔔 فعالسازی اعلانها</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="pwaMsg" class="muted" style="font-size:13px; margin-top:-6px;"></p>
|
||||
|
||||
<div class="grid grid-4" style="margin-top:8px;">
|
||||
<div class="card card-pad">
|
||||
<h3 style="margin-top:0;">🌐 وب</h3>
|
||||
<p class="muted" style="font-size:13.5px;">همین حالا در مرورگر باز است. برای نصب، دکمهی «نصب اپلیکیشن» بالا را بزن (کروم/اج).</p>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<h3 style="margin-top:0;">🪟 ویندوز</h3>
|
||||
<p class="muted" style="font-size:13.5px;">در Chrome/Edge، آیکن نصب (⊕) در نوار آدرس را بزن یا منو ← «Install همکادر». روی دسکتاپ مثل یک برنامه باز میشود.</p>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<h3 style="margin-top:0;">🤖 اندروید</h3>
|
||||
<p class="muted" style="font-size:13.5px;">در Chrome دکمهی «نصب اپلیکیشن» یا منو ← «Add to Home screen». بهزودی از <strong>کافهبازار</strong> هم قابل نصب خواهد بود.</p>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<h3 style="margin-top:0;"> iOS</h3>
|
||||
<p class="muted" style="font-size:13.5px;">در Safari، دکمهی Share (□↑) ← «Add to Home Screen» ← Add. آیکن همکادر روی صفحهی اصلی میآید.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad" style="margin-top:20px;">
|
||||
<h2 style="margin-top:0; font-size:20px;">راهنمای استفاده</h2>
|
||||
<ol style="margin:0; padding-inline-start:20px; line-height:2;">
|
||||
<li><strong>نصب کن</strong> — طبق راهنمای دستگاهت بالا، همکادر را به صفحهی اصلی اضافه کن.</li>
|
||||
<li><strong>وارد شو</strong> — با شماره موبایل و کد پیامکی؛ هنگام ثبتنام نوع حساب (کادر درمان یا کارفرما) را انتخاب کن.</li>
|
||||
<li><strong>کادر درمان:</strong> در «علاقهمندیها» نقش/شهر/نوع شیفت را تعیین کن تا پیشنهادهای متناسب بگیری؛ با «نزدیک من» نزدیکترین فرصتها را ببین؛ روی شیفت/استخدام «اعلام تمایل» بزن.</li>
|
||||
<li><strong>کارفرما:</strong> مرکزت را ثبت کن (موقعیت را روی نقشه بگذار)، سپس شیفت/استخدام منتشر کن و متقاضیان را در پنل ببین.</li>
|
||||
<li><strong>اعلانها</strong> — دکمهی «فعالسازی اعلانها» را بزن تا از فرصتهای جدید باخبر شوی.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
var VAPID = '@Model.VapidPublicKey';
|
||||
var msg = document.getElementById('pwaMsg');
|
||||
|
||||
// --- Install (Add to Home Screen) ---
|
||||
var deferred = null, installBtn = document.getElementById('installBtn');
|
||||
window.addEventListener('beforeinstallprompt', function (e) { e.preventDefault(); deferred = e; installBtn.style.display = 'inline-flex'; });
|
||||
installBtn.addEventListener('click', async function () {
|
||||
if (!deferred) { msg.textContent = 'برای نصب از منوی مرورگر «Add to Home screen / Install» استفاده کن.'; return; }
|
||||
deferred.prompt(); await deferred.userChoice; deferred = null; installBtn.style.display = 'none';
|
||||
});
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) { msg.textContent = '✓ اپلیکیشن نصب شده و در حالت مستقل اجرا میشود.'; }
|
||||
|
||||
// --- Notifications ---
|
||||
function b64ToUint8(b64) {
|
||||
var pad = '='.repeat((4 - b64.length % 4) % 4);
|
||||
var s = (b64 + pad).replace(/-/g, '+').replace(/_/g, '/');
|
||||
var raw = atob(s); var arr = new Uint8Array(raw.length);
|
||||
for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
document.getElementById('notifyBtn').addEventListener('click', async function () {
|
||||
if (!('Notification' in window) || !('serviceWorker' in navigator)) { msg.textContent = 'مرورگر شما از اعلانها پشتیبانی نمیکند.'; return; }
|
||||
var perm = await Notification.requestPermission();
|
||||
if (perm !== 'granted') { msg.textContent = 'اجازهی اعلان داده نشد.'; return; }
|
||||
var reg = await navigator.serviceWorker.ready;
|
||||
if (VAPID) {
|
||||
try {
|
||||
var sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToUint8(VAPID) });
|
||||
await fetch('/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub) });
|
||||
} catch (e) { /* push service may be unreachable; local notifications still work */ }
|
||||
}
|
||||
reg.showNotification('همکادر', { body: 'اعلانها فعال شد ✓ از فرصتهای جدید باخبر میشوی.', icon: '/icons/icon-192.png', dir: 'rtl', lang: 'fa' });
|
||||
msg.textContent = '✓ اعلانها فعال شد.';
|
||||
});
|
||||
</script>
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,15 @@
|
||||
self-hosted under wwwroot/fonts (@@font-face in site.css) — no external CDN. *@
|
||||
<link rel="preload" href="~/fonts/Vazirmatn-Regular.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
|
||||
@* PWA: installable app (Web/Windows/Android via this manifest; iOS via apple-* tags) *@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#0e8f8a" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="همکادر" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
@@ -25,6 +34,7 @@
|
||||
<a asp-page="/Shifts/Index">شیفتها</a>
|
||||
<a asp-page="/Jobs/Index">استخدام</a>
|
||||
<a asp-page="/Calendar/Index">تقویم هفتگی</a>
|
||||
<a asp-page="/Download">دریافت اپ</a>
|
||||
<a asp-page="/Facilities/Index">مراکز درمانی</a>
|
||||
<a asp-page="/Preferences/Index">علاقهمندیها</a>
|
||||
</nav>
|
||||
@@ -63,10 +73,19 @@
|
||||
<strong>همکادر</strong>
|
||||
<p class="muted">سامانه واسط میان کادر درمان و مراکز درمانی برای شیفت و استخدام</p>
|
||||
</div>
|
||||
<div class="muted">© ۱۴۰۵ همکادر — همه حقوق محفوظ است</div>
|
||||
<div class="muted">
|
||||
<a asp-page="/Download" style="font-weight:700;">📲 دریافت اپلیکیشن</a>
|
||||
· © ۱۴۰۵ همکادر — همه حقوق محفوظ است
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@* Register the PWA service worker (offline + push notifications). *@
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () { navigator.serviceWorker.register('/sw.js').catch(function () {}); });
|
||||
}
|
||||
</script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -119,6 +119,64 @@ app.MapRazorPages()
|
||||
// Lightweight liveness probe for the deploy health-wait loop (and uptime checks).
|
||||
app.MapGet("/healthz", () => Results.Text("ok"));
|
||||
|
||||
// ---- PWA: web manifest + service worker (served from root for full scope) ----
|
||||
app.MapGet("/manifest.webmanifest", () => Results.Content("""
|
||||
{
|
||||
"name": "همکادر — شیفت و استخدام کادر درمان",
|
||||
"short_name": "همکادر",
|
||||
"lang": "fa", "dir": "rtl",
|
||||
"start_url": "/", "scope": "/",
|
||||
"display": "standalone", "orientation": "portrait",
|
||||
"background_color": "#f4f7f9", "theme_color": "#0e8f8a",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "شیفتها", "url": "/Shifts" },
|
||||
{ "name": "استخدام", "url": "/Jobs" }
|
||||
]
|
||||
}
|
||||
""", "application/manifest+json"));
|
||||
|
||||
// Store a browser's push subscription (from the "enable notifications" flow).
|
||||
app.MapPost("/push/subscribe", async (PushSubscriptionDto dto, AppDbContext db, VisitorContext vc) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Endpoint) || dto.Keys?.P256dh is null || dto.Keys?.Auth is null)
|
||||
return Results.BadRequest();
|
||||
if (!await db.WebPushSubscriptions.AnyAsync(s => s.Endpoint == dto.Endpoint))
|
||||
{
|
||||
db.WebPushSubscriptions.Add(new WebPushSubscription
|
||||
{
|
||||
Endpoint = dto.Endpoint, P256dh = dto.Keys.P256dh, Auth = dto.Keys.Auth, VisitorId = vc.VisitorId,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
app.MapGet("/sw.js", () => Results.Content("""
|
||||
const CACHE = 'hamkadr-v1';
|
||||
self.addEventListener('install', e => { self.skipWaiting(); e.waitUntil(caches.open(CACHE).then(c => c.addAll(['/']))); });
|
||||
self.addEventListener('activate', e => { e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k))))); self.clients.claim(); });
|
||||
self.addEventListener('fetch', e => {
|
||||
const req = e.request;
|
||||
if (req.method !== 'GET' || new URL(req.url).origin !== location.origin) return;
|
||||
e.respondWith(fetch(req).then(res => { const copy = res.clone(); caches.open(CACHE).then(c => c.put(req, copy)); return res; })
|
||||
.catch(() => caches.match(req).then(m => m || caches.match('/'))));
|
||||
});
|
||||
self.addEventListener('push', e => {
|
||||
let d = { title: 'همکادر', body: 'فرصت جدید برای شما', url: '/' };
|
||||
try { if (e.data) d = Object.assign(d, e.data.json()); } catch (_) { if (e.data) d.body = e.data.text(); }
|
||||
e.waitUntil(self.registration.showNotification(d.title, { body: d.body, icon: '/icons/icon-192.png', badge: '/icons/icon-192.png', dir: 'rtl', lang: 'fa', data: { url: d.url } }));
|
||||
});
|
||||
self.addEventListener('notificationclick', e => {
|
||||
e.notification.close();
|
||||
const url = (e.notification.data && e.notification.data.url) || '/';
|
||||
e.waitUntil(clients.matchAll({ type: 'window' }).then(cl => { for (const c of cl) { if ('focus' in c) { c.navigate(url); return c.focus(); } } return clients.openWindow(url); }));
|
||||
});
|
||||
""", "text/javascript"));
|
||||
|
||||
// ---- SEO: robots.txt + dynamic sitemap.xml (so Google indexes every live shift/job page) ----
|
||||
app.MapGet("/robots.txt", (HttpContext ctx) =>
|
||||
{
|
||||
|
||||
@@ -51,6 +51,10 @@ public class SettingsService
|
||||
s.SmsTemplate = incoming.SmsTemplate?.Trim();
|
||||
s.SmsSender = incoming.SmsSender?.Trim();
|
||||
s.NeshanMapKey = incoming.NeshanMapKey?.Trim();
|
||||
s.PushEnabled = incoming.PushEnabled;
|
||||
s.VapidPublicKey = incoming.VapidPublicKey?.Trim();
|
||||
s.VapidPrivateKey = incoming.VapidPrivateKey?.Trim();
|
||||
s.VapidSubject = string.IsNullOrWhiteSpace(incoming.VapidSubject) ? "mailto:admin@hamkadr.ir" : incoming.VapidSubject.Trim();
|
||||
s.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 718 B |
Binary file not shown.
|
After Width: | Height: | Size: 760 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Reference in New Issue
Block a user