[Alerts] Customizable job alerts + Help capabilities showcase
CI/CD / CI · dotnet build (push) Successful in 1m8s
CI/CD / Deploy · hamkadr (push) Successful in 1m7s

Job alerts (هشدار شغلی): users save what they want — scope (shift/job/both), role, city, shift type, employment type, minimum pay — and get notified when an employer posts a match. New JobAlert model + AlertScope enum + DbContext (user-cascade, role set-null, IsActive index) + migration. /Me/Alerts page to create/pause/delete alerts; entry point added to the کارجو panel. NotificationService.NotifyNewShift/Job now unions preference matches with active-alert matches (deduped) so alert owners are notified on publish. Help page gains an 'امکانات همکادر' capability showcase grid (with a 'ساخت هشدار شغلی' CTA) so the page demonstrates what the app does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 18:17:56 +03:30
parent 42deac1261
commit 213faadf55
11 changed files with 1727 additions and 3 deletions
+10
View File
@@ -25,6 +25,7 @@ public class AppDbContext : DbContext
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<Report> Reports => Set<Report>();
public DbSet<FacilityDocument> FacilityDocuments => Set<FacilityDocument>();
public DbSet<JobAlert> JobAlerts => Set<JobAlert>();
protected override void OnModelCreating(ModelBuilder b)
{
@@ -83,6 +84,15 @@ public class AppDbContext : DbContext
.HasOne(d => d.Facility).WithMany(f => f.Documents)
.HasForeignKey(d => d.FacilityId).OnDelete(DeleteBehavior.Cascade);
// Job alerts belong to a user; remove with the user. Don't cascade from Role.
b.Entity<JobAlert>()
.HasOne(a => a.User).WithMany()
.HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Cascade);
b.Entity<JobAlert>()
.HasOne(a => a.Role).WithMany()
.HasForeignKey(a => a.RoleId).OnDelete(DeleteBehavior.SetNull);
b.Entity<JobAlert>().HasIndex(a => a.IsActive);
// Don't delete shifts/profiles just because a Role is removed.
b.Entity<Shift>()
.HasOne(s => s.Role).WithMany(r => r.Shifts)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class JobAlerts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "JobAlerts",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<int>(type: "integer", nullable: false),
Label = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
Scope = table.Column<int>(type: "integer", nullable: false),
RoleId = table.Column<int>(type: "integer", nullable: true),
CityId = table.Column<int>(type: "integer", nullable: true),
DistrictId = table.Column<int>(type: "integer", nullable: true),
ShiftType = table.Column<int>(type: "integer", nullable: true),
EmploymentType = table.Column<int>(type: "integer", nullable: true),
MinPay = table.Column<long>(type: "bigint", nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_JobAlerts", x => x.Id);
table.ForeignKey(
name: "FK_JobAlerts_Cities_CityId",
column: x => x.CityId,
principalTable: "Cities",
principalColumn: "Id");
table.ForeignKey(
name: "FK_JobAlerts_Roles_RoleId",
column: x => x.RoleId,
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_JobAlerts_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_JobAlerts_CityId",
table: "JobAlerts",
column: "CityId");
migrationBuilder.CreateIndex(
name: "IX_JobAlerts_IsActive",
table: "JobAlerts",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_JobAlerts_RoleId",
table: "JobAlerts",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_JobAlerts_UserId",
table: "JobAlerts",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JobAlerts");
}
}
}
@@ -438,6 +438,61 @@ namespace JobsMedical.Web.Migrations
b.ToTable("InterestEvents");
});
modelBuilder.Entity("JobsMedical.Web.Models.JobAlert", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CityId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("DistrictId")
.HasColumnType("integer");
b.Property<int?>("EmploymentType")
.HasColumnType("integer");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Label")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<long?>("MinPay")
.HasColumnType("bigint");
b.Property<int?>("RoleId")
.HasColumnType("integer");
b.Property<int>("Scope")
.HasColumnType("integer");
b.Property<int?>("ShiftType")
.HasColumnType("integer");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CityId");
b.HasIndex("IsActive");
b.HasIndex("RoleId");
b.HasIndex("UserId");
b.ToTable("JobAlerts");
});
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
{
b.Property<int>("Id")
@@ -993,6 +1048,30 @@ namespace JobsMedical.Web.Migrations
b.Navigation("Visitor");
});
modelBuilder.Entity("JobsMedical.Web.Models.JobAlert", b =>
{
b.HasOne("JobsMedical.Web.Models.City", "City")
.WithMany()
.HasForeignKey("CityId");
b.HasOne("JobsMedical.Web.Models.Role", "Role")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("JobsMedical.Web.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("City");
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
{
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
+8
View File
@@ -76,6 +76,14 @@ public enum ListingKind
Job = 1
}
/// <summary>Which listing types a job alert watches.</summary>
public enum AlertScope
{
Any = 0, // هر دو (شیفت و استخدام)
Shifts = 1, // فقط شیفت
Jobs = 2 // فقط استخدام
}
/// <summary>
/// Gender. On a listing it's the requirement (Any = فرقی نمی‌کند); on a person it's their gender
/// (Any = unspecified).
+37
View File
@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>
/// A saved job alert ("هشدار شغلی") — the user describes what they're after (kind, role, city,
/// shift type, employment type, minimum pay). When an employer publishes a matching shift/job,
/// the matching engine notifies the owner. A user can keep several alerts; each can be paused.
/// </summary>
public class JobAlert
{
public int Id { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
[MaxLength(120)] public string? Label { get; set; } // optional friendly name
public AlertScope Scope { get; set; } = AlertScope.Any;
public int? RoleId { get; set; } // null = any role
public Role? Role { get; set; }
public int? CityId { get; set; } // null = any city
public City? City { get; set; }
public int? DistrictId { get; set; } // null = any district
public ShiftType? ShiftType { get; set; } // for shifts; null = any
public EmploymentType? EmploymentType { get; set; } // for jobs; null = any
/// <summary>Minimum acceptable pay (تومان): per-shift amount for shifts, monthly salary for jobs.</summary>
public long? MinPay { get; set; }
public bool IsActive { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+45
View File
@@ -23,6 +23,51 @@
</div>
</div>
<h2 style="margin:6px 0 12px;">امکانات همکادر</h2>
<div class="grid grid-3" style="margin-bottom:18px;">
<div class="card card-pad">
<div style="font-size:26px;">🗓️</div>
<strong>شیفت و استخدام</strong>
<p class="muted" style="font-size:13px; margin:6px 0 0;">فرصت‌های شیفت کاری و موقعیت‌های استخدامی کادر درمان، یک‌جا.</p>
</div>
<div class="card card-pad">
<div style="font-size:26px;">🎯</div>
<strong>پیشنهاد هوشمند</strong>
<p class="muted" style="font-size:13px; margin:6px 0 0;">بر اساس نقش، شهر و علاقه‌مندی‌ات، بهترین فرصت‌ها را می‌بینی.</p>
</div>
<div class="card card-pad">
<div style="font-size:26px;">📍</div>
<strong>نزدیک من</strong>
<p class="muted" style="font-size:13px; margin:6px 0 0;">فرصت‌ها را بر اساس فاصله از موقعیت فعلی‌ات مرتب کن.</p>
</div>
<div class="card card-pad" style="border:1px solid var(--accent);">
<div style="font-size:26px;">🔎</div>
<strong>هشدار شغلی</strong>
<p class="muted" style="font-size:13px; margin:6px 0 8px;">بگو دنبال چه فرصتی هستی؛ تا آگهی متناسب ثبت شد، فوری باخبر شو.</p>
<a class="btn btn-accent" style="padding:6px 14px;" asp-page="/Me/Alerts">ساخت هشدار شغلی</a>
</div>
<div class="card card-pad">
<div style="font-size:26px;">🔔</div>
<strong>اعلان زنده</strong>
<p class="muted" style="font-size:13px; margin:6px 0 0;">اعلان درون‌برنامه‌ای که در ایران بدون سرویس‌های خارجی کار می‌کند.</p>
</div>
<div class="card card-pad">
<div style="font-size:26px;">✓</div>
<strong>مراکز تأییدشده</strong>
<p class="muted" style="font-size:13px; margin:6px 0 0;">نشان «تأیید شده» روی مراکزی که مدارکشان بررسی شده است.</p>
</div>
<div class="card card-pad">
<div style="font-size:26px;">📲</div>
<strong>نصب به‌صورت اپ (PWA)</strong>
<p class="muted" style="font-size:13px; margin:6px 0 0;">روی اندروید، iOS، ویندوز و وب نصب می‌شود. <a asp-page="/Download">دریافت اپ</a></p>
</div>
<div class="card card-pad">
<div style="font-size:26px;">🛡️</div>
<strong>گزارش و شکایت</strong>
<p class="muted" style="font-size:13px; margin:6px 0 0;">آگهی نادرست یا مرکز متخلف را گزارش کن تا بررسی شود.</p>
</div>
</div>
<div class="card card-pad legal" style="margin-bottom:14px;">
<h2>شروع سریع (۳ گام)</h2>
<ol style="padding-inline-start:20px; line-height:2;">
+134
View File
@@ -0,0 +1,134 @@
@page
@model JobsMedical.Web.Pages.Me.AlertsModel
@{
ViewData["Title"] = "هشدارهای شغلی";
string ScopeLabel(JobsMedical.Web.Models.AlertScope s) => s switch
{
JobsMedical.Web.Models.AlertScope.Shifts => "شیفت",
JobsMedical.Web.Models.AlertScope.Jobs => "استخدام",
_ => "شیفت و استخدام",
};
string ShiftLabel(JobsMedical.Web.Models.ShiftType t) => t switch
{
JobsMedical.Web.Models.ShiftType.Day => "صبح",
JobsMedical.Web.Models.ShiftType.Evening => "عصر",
JobsMedical.Web.Models.ShiftType.Night => "شب",
_ => "آنکال",
};
string EmpLabel(JobsMedical.Web.Models.EmploymentType t) => t switch
{
JobsMedical.Web.Models.EmploymentType.FullTime => "تمام‌وقت",
JobsMedical.Web.Models.EmploymentType.PartTime => "پاره‌وقت",
JobsMedical.Web.Models.EmploymentType.Contract => "قراردادی",
_ => "طرح",
};
}
<div class="page-head">
<div class="container">
<h1>🔎 هشدارهای شغلی</h1>
<p class="muted">بگو دنبال چه فرصتی هستی؛ هر وقت کارفرمایی آگهی متناسب ثبت کرد، فوری باخبرت می‌کنیم. <a asp-page="/Me/Index">← پنل کارجو</a></p>
</div>
</div>
<div class="container section" style="max-width:760px;">
@if (Model.Msg is not null) { <div class="alert alert-success">@Model.Msg</div> }
<div class="card card-pad" style="margin-bottom:16px;">
<h3 style="margin-top:0;">ساخت هشدار جدید</h3>
<form method="post" asp-page-handler="Create">
<div class="filter-group">
<label>نوع فرصت</label>
<select name="Scope">
<option value="0">شیفت و استخدام (هر دو)</option>
<option value="1">فقط شیفت</option>
<option value="2">فقط استخدام</option>
</select>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<div class="filter-group" style="flex:1; min-width:160px;">
<label>نقش</label>
<select name="RoleId">
<option value="">هر نقشی</option>
@foreach (var r in Model.Roles) { <option value="@r.Id">@r.Name</option> }
</select>
</div>
<div class="filter-group" style="flex:1; min-width:160px;">
<label>شهر</label>
<select name="CityId">
<option value="">هر شهری</option>
@foreach (var c in Model.Cities) { <option value="@c.Id">@c.Name</option> }
</select>
</div>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<div class="filter-group" style="flex:1; min-width:160px;">
<label>نوع شیفت (برای شیفت)</label>
<select name="ShiftType">
<option value="">فرقی نمی‌کند</option>
<option value="0">صبح</option>
<option value="1">عصر</option>
<option value="2">شب</option>
<option value="3">آنکال</option>
</select>
</div>
<div class="filter-group" style="flex:1; min-width:160px;">
<label>نوع همکاری (برای استخدام)</label>
<select name="EmploymentType">
<option value="">فرقی نمی‌کند</option>
<option value="0">تمام‌وقت</option>
<option value="1">پاره‌وقت</option>
<option value="2">قراردادی</option>
<option value="3">طرح</option>
</select>
</div>
</div>
<div class="filter-group">
<label>حداقل حقوق/دستمزد مورد انتظار (تومان)</label>
<input type="number" name="MinPay" min="0" step="100000" dir="ltr" placeholder="مثلاً ۲۰۰۰۰۰۰" />
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای شیفت: مبلغ هر شیفت؛ برای استخدام: حقوق ماهانه. خالی = بدون محدودیت.</p>
</div>
<div class="filter-group">
<label>برچسب (اختیاری)</label>
<input type="text" name="Label" placeholder="مثلاً پرستار شب تهران" />
</div>
<button type="submit" class="btn btn-accent btn-block btn-lg">ساخت هشدار</button>
</form>
</div>
<h3>هشدارهای من (@JalaliDate.ToPersianDigits(Model.Alerts.Count.ToString()))</h3>
@if (Model.Alerts.Count == 0)
{
<div class="card empty-state">هنوز هشداری نساخته‌ای. اولین هشدار را بالا بساز تا فرصت‌ها از دستت نروند.</div>
}
else
{
foreach (var a in Model.Alerts)
{
<div class="card card-pad" style="margin-bottom:10px; @(a.IsActive ? "" : "opacity:.6;")">
<div class="row" style="display:flex; justify-content:space-between; align-items:flex-start; gap:10px;">
<div>
<strong>@(a.Label ?? "هشدار شغلی")</strong>
@if (!a.IsActive) { <span class="badge badge-type">غیرفعال</span> }
<div class="muted" style="font-size:13px; margin-top:4px;">
<span class="badge badge-type">@ScopeLabel(a.Scope)</span>
@if (a.Role is not null) { <span class="badge badge-type">@a.Role.Name</span> }
@if (a.City is not null) { <span>📍 @a.City.Name</span> }
@if (a.ShiftType is not null) { <span class="badge badge-day">@ShiftLabel(a.ShiftType.Value)</span> }
@if (a.EmploymentType is not null) { <span class="badge badge-job">@EmpLabel(a.EmploymentType.Value)</span> }
@if (a.MinPay is not null) { <span>حداقل @JalaliDate.ToPersianDigits(a.MinPay.Value.ToString("#,0")) تومان</span> }
</div>
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<form method="post" asp-page-handler="Toggle" asp-route-id="@a.Id" style="display:inline;">
<button type="submit" class="btn btn-outline" style="padding:5px 12px;">@(a.IsActive ? "توقف" : "فعال‌سازی")</button>
</form>
<form method="post" asp-page-handler="Delete" asp-route-id="@a.Id" style="display:inline;" onsubmit="return confirm('این هشدار حذف شود؟');">
<button type="submit" class="btn btn-outline" style="padding:5px 12px; color:var(--danger); border-color:var(--danger);">حذف</button>
</form>
</div>
</div>
</div>
}
}
</div>
@@ -0,0 +1,79 @@
using System.Security.Claims;
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Me;
/// <summary>Job alerts (هشدار شغلی) — saved searches that notify the user on a new matching listing.</summary>
[Authorize]
public class AlertsModel : PageModel
{
private readonly AppDbContext _db;
public AlertsModel(AppDbContext db) => _db = db;
public List<Role> Roles { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
public List<JobAlert> Alerts { get; private set; } = new();
[TempData] public string? Msg { get; set; }
[BindProperty] public string? Label { get; set; }
[BindProperty] public AlertScope Scope { get; set; } = AlertScope.Any;
[BindProperty] public int? RoleId { get; set; }
[BindProperty] public int? CityId { get; set; }
[BindProperty] public ShiftType? ShiftType { get; set; }
[BindProperty] public EmploymentType? EmploymentType { get; set; }
[BindProperty] public long? MinPay { get; set; }
private int Uid => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public async Task OnGetAsync() => await LoadAsync();
public async Task<IActionResult> OnPostCreateAsync()
{
if (await _db.JobAlerts.CountAsync(a => a.UserId == Uid) >= 20)
{
Msg = "حداکثر تعداد هشدار شغلی ساخته شده است.";
return RedirectToPage();
}
_db.JobAlerts.Add(new JobAlert
{
UserId = Uid,
Label = string.IsNullOrWhiteSpace(Label) ? null : Label.Trim(),
Scope = Scope,
RoleId = RoleId,
CityId = CityId,
ShiftType = Scope == AlertScope.Jobs ? null : ShiftType,
EmploymentType = Scope == AlertScope.Shifts ? null : EmploymentType,
MinPay = MinPay is > 0 ? MinPay : null,
});
await _db.SaveChangesAsync();
Msg = "هشدار شغلی ساخته شد. به‌محض ثبت آگهی متناسب، باخبر می‌شوی.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostToggleAsync(int id)
{
var a = await _db.JobAlerts.FirstOrDefaultAsync(x => x.Id == id && x.UserId == Uid);
if (a is not null) { a.IsActive = !a.IsActive; await _db.SaveChangesAsync(); }
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
var a = await _db.JobAlerts.FirstOrDefaultAsync(x => x.Id == id && x.UserId == Uid);
if (a is not null) { _db.JobAlerts.Remove(a); await _db.SaveChangesAsync(); }
return RedirectToPage();
}
private async Task LoadAsync()
{
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Alerts = await _db.JobAlerts.Include(a => a.Role).Include(a => a.City)
.Where(a => a.UserId == Uid).OrderByDescending(a => a.CreatedAt).ToListAsync();
}
}
+4 -1
View File
@@ -35,7 +35,10 @@
}
</span>
</div>
<a class="btn btn-outline" asp-page="/Preferences/Index">تنظیم علاقه‌مندی‌ها</a>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<a class="btn btn-outline" asp-page="/Me/Alerts">🔎 هشدارهای شغلی</a>
<a class="btn btn-outline" asp-page="/Preferences/Index">تنظیم علاقه‌مندی‌ها</a>
</div>
</div>
@if (Model.Recommendations.Count > 0)
@@ -45,7 +45,9 @@ public class NotificationService
.FirstOrDefaultAsync(x => x.Id == shiftId);
if (s is null) return;
var users = await MatchingUserIdsAsync(s.RoleId, s.Facility.CityId, s.ShiftType);
var prefUsers = await MatchingUserIdsAsync(s.RoleId, s.Facility.CityId, s.ShiftType);
var alertUsers = await ShiftAlertUserIdsAsync(s);
var users = prefUsers.Union(alertUsers).Distinct().ToList();
var title = $"شیفت جدید: {s.Role.Name}";
var body = $"{s.Facility.Name} — {JalaliDate.WeekDayName(s.Date)} {JalaliDate.Time(s.StartTime)}";
await AddAsync(users, title, body, $"/Shifts/Details/{s.Id}");
@@ -57,7 +59,9 @@ public class NotificationService
.FirstOrDefaultAsync(x => x.Id == jobId);
if (j is null) return;
var users = await MatchingUserIdsAsync(j.RoleId, j.Facility.CityId, null);
var prefUsers = await MatchingUserIdsAsync(j.RoleId, j.Facility.CityId, null);
var alertUsers = await JobAlertUserIdsAsync(j);
var users = prefUsers.Union(alertUsers).Distinct().ToList();
await AddAsync(users, $"استخدام جدید: {j.Title}", j.Facility.Name, $"/Jobs/Details/{j.Id}");
}
@@ -75,6 +79,36 @@ public class NotificationService
return await q.Distinct().ToListAsync();
}
/// <summary>Owners of active job alerts that match this shift.</summary>
private async Task<List<int>> ShiftAlertUserIdsAsync(Shift s)
{
var cityId = s.Facility.CityId;
var districtId = s.Facility.DistrictId;
return await _db.JobAlerts.Where(a => a.IsActive
&& a.Scope != AlertScope.Jobs
&& (a.RoleId == null || a.RoleId == s.RoleId)
&& (a.CityId == null || a.CityId == cityId)
&& (a.DistrictId == null || a.DistrictId == districtId)
&& (a.ShiftType == null || a.ShiftType == s.ShiftType)
&& (a.MinPay == null || (s.PayAmount != null && s.PayAmount >= a.MinPay)))
.Select(a => a.UserId).Distinct().ToListAsync();
}
/// <summary>Owners of active job alerts that match this hiring opening.</summary>
private async Task<List<int>> JobAlertUserIdsAsync(JobOpening j)
{
var cityId = j.Facility.CityId;
var districtId = j.Facility.DistrictId;
return await _db.JobAlerts.Where(a => a.IsActive
&& a.Scope != AlertScope.Shifts
&& (a.RoleId == null || a.RoleId == j.RoleId)
&& (a.CityId == null || a.CityId == cityId)
&& (a.DistrictId == null || a.DistrictId == districtId)
&& (a.EmploymentType == null || a.EmploymentType == j.EmploymentType)
&& (a.MinPay == null || ((j.SalaryMax ?? j.SalaryMin) != null && (j.SalaryMax ?? j.SalaryMin) >= a.MinPay)))
.Select(a => a.UserId).Distinct().ToListAsync();
}
private async Task AddAsync(List<int> userIds, string title, string? body, string url)
{
if (userIds.Count == 0) return;