Initial commit — Hamkadr (همکادر) healthcare-staffing marketplace
ASP.NET Core 10 Razor Pages + PostgreSQL/EF Core. RTL Persian, Jalali dates, self-hosted Vazirmatn, teal/coral brand. Features: - Shift listings: browse/filter (city, district, role, type, pay), weekly Jalali calendar, detail + interest handoff, near-me distance sort - Hiring (استخدام) listings with employment type + salary range - Pattern-engine recommendations + anonymous interest tracking (visitor cookie) - Heuristic Persian listing-parser + admin queue (raw channel post → shift/job) - Phone-OTP cookie auth + visitor-history linking + profile Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
public static class Geo
|
||||
{
|
||||
private const double EarthRadiusKm = 6371.0;
|
||||
|
||||
/// <summary>Great-circle (Haversine) distance in kilometers between two lat/lng points.</summary>
|
||||
public static double DistanceKm(double lat1, double lng1, double lat2, double lng2)
|
||||
{
|
||||
double dLat = ToRad(lat2 - lat1);
|
||||
double dLng = ToRad(lng2 - lng1);
|
||||
double a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2)
|
||||
+ Math.Cos(ToRad(lat1)) * Math.Cos(ToRad(lat2))
|
||||
* Math.Sin(dLng / 2) * Math.Sin(dLng / 2);
|
||||
return EarthRadiusKm * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));
|
||||
}
|
||||
|
||||
private static double ToRad(double deg) => deg * Math.PI / 180.0;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persists visitor preferences and behavioral events. Creates the <see cref="Visitor"/> row
|
||||
/// lazily on first write, so anonymous browsing doesn't hit the DB on every request.
|
||||
/// </summary>
|
||||
public class InterestService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly VisitorContext _visitor;
|
||||
|
||||
public InterestService(AppDbContext db, VisitorContext visitor)
|
||||
{
|
||||
_db = db;
|
||||
_visitor = visitor;
|
||||
}
|
||||
|
||||
public string VisitorId => _visitor.VisitorId;
|
||||
|
||||
private async Task EnsureVisitorAsync()
|
||||
{
|
||||
var id = VisitorId;
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
var exists = await _db.Visitors.AnyAsync(v => v.Id == id);
|
||||
if (!exists)
|
||||
{
|
||||
_db.Visitors.Add(new Visitor { Id = id });
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await _db.Visitors.Where(v => v.Id == id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(v => v.LastSeenAt, DateTime.UtcNow));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogAsync(InterestEventType type, int shiftId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(VisitorId)) return;
|
||||
await EnsureVisitorAsync();
|
||||
_db.InterestEvents.Add(new InterestEvent
|
||||
{
|
||||
VisitorId = VisitorId,
|
||||
ShiftId = shiftId,
|
||||
EventType = type,
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task LogJobAsync(InterestEventType type, int jobOpeningId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(VisitorId)) return;
|
||||
await EnsureVisitorAsync();
|
||||
_db.InterestEvents.Add(new InterestEvent
|
||||
{
|
||||
VisitorId = VisitorId,
|
||||
JobOpeningId = jobOpeningId,
|
||||
EventType = type,
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public Task<UserPreferences?> GetPreferencesAsync()
|
||||
{
|
||||
var id = VisitorId;
|
||||
if (string.IsNullOrEmpty(id)) return Task.FromResult<UserPreferences?>(null);
|
||||
return _db.UserPreferences.AsNoTracking().FirstOrDefaultAsync(p => p.VisitorId == id);
|
||||
}
|
||||
|
||||
public async Task SavePreferencesAsync(int? roleId, int? cityId, ShiftType? shiftType, long? minPay)
|
||||
{
|
||||
await EnsureVisitorAsync();
|
||||
var prefs = await _db.UserPreferences.FirstOrDefaultAsync(p => p.VisitorId == VisitorId);
|
||||
if (prefs is null)
|
||||
{
|
||||
prefs = new UserPreferences { VisitorId = VisitorId };
|
||||
_db.UserPreferences.Add(prefs);
|
||||
}
|
||||
prefs.RoleId = roleId;
|
||||
prefs.CityId = cityId;
|
||||
prefs.PreferredShiftType = shiftType;
|
||||
prefs.MinPay = minPay;
|
||||
prefs.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>Recent events for this visitor (newest first) — the behavioral signal.</summary>
|
||||
public Task<List<InterestEvent>> RecentEventsAsync(int take = 100)
|
||||
{
|
||||
var id = VisitorId;
|
||||
if (string.IsNullOrEmpty(id)) return Task.FromResult(new List<InterestEvent>());
|
||||
return _db.InterestEvents.AsNoTracking()
|
||||
.Where(e => e.VisitorId == id)
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Jalali (Shamsi) date helpers built on .NET's <see cref="PersianCalendar"/>.
|
||||
/// Rule of thumb in this app: store Gregorian, display Jalali.
|
||||
/// </summary>
|
||||
public static class JalaliDate
|
||||
{
|
||||
private static readonly PersianCalendar Pc = new();
|
||||
|
||||
private static readonly string[] MonthNames =
|
||||
{
|
||||
"فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور",
|
||||
"مهر", "آبان", "آذر", "دی", "بهمن", "اسفند"
|
||||
};
|
||||
|
||||
// PersianCalendar: 0=Saturday .. 6=Friday is NOT how DayOfWeek maps; map explicitly.
|
||||
private static readonly string[] WeekDayNames =
|
||||
{
|
||||
"شنبه", "یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه"
|
||||
};
|
||||
|
||||
private static readonly char[] PersianDigits = { '۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹' };
|
||||
|
||||
/// <summary>Convert Latin digits in a string to Persian digits.</summary>
|
||||
public static string ToPersianDigits(string input)
|
||||
{
|
||||
var chars = input.ToCharArray();
|
||||
for (var i = 0; i < chars.Length; i++)
|
||||
if (chars[i] is >= '0' and <= '9')
|
||||
chars[i] = PersianDigits[chars[i] - '0'];
|
||||
return new string(chars);
|
||||
}
|
||||
|
||||
/// <summary>e.g. "۱۵ خرداد ۱۴۰۵".</summary>
|
||||
public static string ToLongDate(DateOnly date)
|
||||
{
|
||||
var dt = date.ToDateTime(TimeOnly.MinValue);
|
||||
var y = Pc.GetYear(dt);
|
||||
var m = Pc.GetMonth(dt);
|
||||
var d = Pc.GetDayOfMonth(dt);
|
||||
return ToPersianDigits($"{d} {MonthNames[m - 1]} {y}");
|
||||
}
|
||||
|
||||
/// <summary>e.g. "۱۴۰۵/۰۳/۱۵".</summary>
|
||||
public static string ToShortDate(DateOnly date)
|
||||
{
|
||||
var dt = date.ToDateTime(TimeOnly.MinValue);
|
||||
return ToPersianDigits($"{Pc.GetYear(dt):0000}/{Pc.GetMonth(dt):00}/{Pc.GetDayOfMonth(dt):00}");
|
||||
}
|
||||
|
||||
/// <summary>Persian weekday name, e.g. "سهشنبه".</summary>
|
||||
public static string WeekDayName(DateOnly date)
|
||||
{
|
||||
var dt = date.ToDateTime(TimeOnly.MinValue);
|
||||
return WeekDayNames[WeekIndex(dt)];
|
||||
}
|
||||
|
||||
/// <summary>0 = Saturday (شنبه, start of Persian week) .. 6 = Friday (جمعه).</summary>
|
||||
public static int WeekIndex(DateTime dt) => ((int)dt.DayOfWeek + 1) % 7;
|
||||
|
||||
/// <summary>The Saturday that starts the Persian week containing <paramref name="date"/>.</summary>
|
||||
public static DateOnly StartOfPersianWeek(DateOnly date)
|
||||
{
|
||||
var idx = WeekIndex(date.ToDateTime(TimeOnly.MinValue));
|
||||
return date.AddDays(-idx);
|
||||
}
|
||||
|
||||
/// <summary>Persian month name for a given date.</summary>
|
||||
public static string MonthName(DateOnly date)
|
||||
{
|
||||
var dt = date.ToDateTime(TimeOnly.MinValue);
|
||||
return MonthNames[Pc.GetMonth(dt) - 1];
|
||||
}
|
||||
|
||||
/// <summary>Just the day-of-month in Persian digits.</summary>
|
||||
public static string DayOfMonth(DateOnly date)
|
||||
{
|
||||
var dt = date.ToDateTime(TimeOnly.MinValue);
|
||||
return ToPersianDigits(Pc.GetDayOfMonth(dt).ToString());
|
||||
}
|
||||
|
||||
/// <summary>Format a time as "۰۸:۰۰".</summary>
|
||||
public static string Time(TimeOnly t) => ToPersianDigits(t.ToString("HH\\:mm"));
|
||||
|
||||
/// <summary>Format a Toman amount, e.g. "۱٬۵۰۰٬۰۰۰ تومان" or "توافقی" if null.</summary>
|
||||
public static string Toman(long? amount)
|
||||
=> amount is null ? "توافقی" : ToPersianDigits(amount.Value.ToString("#,0")) + " تومان";
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using JobsMedical.Web.Models;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
/// <summary>Structured guess extracted from a raw channel post. All fields are best-effort.</summary>
|
||||
public class ParsedListing
|
||||
{
|
||||
public ListingKind Kind { get; set; } = ListingKind.Shift;
|
||||
public string? RoleName { get; set; }
|
||||
public ShiftType? ShiftType { get; set; }
|
||||
public EmploymentType? EmploymentType { get; set; }
|
||||
public long? PayAmount { get; set; } // shift pay or single salary figure
|
||||
public bool PayNegotiable { get; set; }
|
||||
public string? CityName { get; set; }
|
||||
public string? DistrictName { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public List<string> Notes { get; set; } = new(); // what was/wasn't detected (shown to admin)
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a messy Persian channel/Divar post into a structured listing guess. This is the
|
||||
/// Stage-1 implementation: transparent keyword + regex heuristics, no AI dependency (important
|
||||
/// since LLM APIs are blocked from Iran). A future LlmListingParser can implement the same
|
||||
/// interface and be swapped in via DI without touching the admin queue.
|
||||
/// </summary>
|
||||
public interface IListingParser
|
||||
{
|
||||
ParsedListing Parse(string rawText, IEnumerable<string> knownRoles,
|
||||
IEnumerable<string> knownCities, IEnumerable<string> knownDistricts);
|
||||
}
|
||||
|
||||
public class HeuristicListingParser : IListingParser
|
||||
{
|
||||
public ParsedListing Parse(string raw, IEnumerable<string> knownRoles,
|
||||
IEnumerable<string> knownCities, IEnumerable<string> knownDistricts)
|
||||
{
|
||||
var p = new ParsedListing();
|
||||
var text = Normalize(raw);
|
||||
|
||||
// --- Kind: shift vs hiring ---
|
||||
bool jobSignals = ContainsAny(text, "استخدام", "جذب", "دعوت به همکاری", "تمام وقت", "تماموقت", "قرارداد", "ماهانه", "حقوق ثابت");
|
||||
bool shiftSignals = ContainsAny(text, "شیفت", "آنکال", "انکال", "نوبت", "کشیک");
|
||||
p.Kind = (jobSignals && !shiftSignals) ? ListingKind.Job : ListingKind.Shift;
|
||||
p.Notes.Add(p.Kind == ListingKind.Job ? "نوع: استخدام (تشخیص خودکار)" : "نوع: شیفت (تشخیص خودکار)");
|
||||
|
||||
// --- Role (longest match first so «پزشک متخصص» beats «پزشک») ---
|
||||
foreach (var role in knownRoles.OrderByDescending(r => r.Length))
|
||||
{
|
||||
if (text.Contains(Normalize(role))) { p.RoleName = role; break; }
|
||||
}
|
||||
if (p.RoleName is null && ContainsAny(text, "پزشک", "دکتر")) p.RoleName = "پزشک عمومی";
|
||||
p.Notes.Add(p.RoleName is null ? "نقش: تشخیص داده نشد" : $"نقش: {p.RoleName}");
|
||||
|
||||
// --- Shift type ---
|
||||
if (ContainsAny(text, "آنکال", "انکال")) p.ShiftType = Models.ShiftType.OnCall;
|
||||
else if (text.Contains("شب")) p.ShiftType = Models.ShiftType.Night;
|
||||
else if (text.Contains("عصر")) p.ShiftType = Models.ShiftType.Evening;
|
||||
else if (ContainsAny(text, "صبح", "روز")) p.ShiftType = Models.ShiftType.Day;
|
||||
|
||||
// --- Employment type ---
|
||||
if (ContainsAny(text, "پاره وقت", "پارهوقت", "پارت تایم")) p.EmploymentType = Models.EmploymentType.PartTime;
|
||||
else if (text.Contains("طرح")) p.EmploymentType = Models.EmploymentType.Plan;
|
||||
else if (text.Contains("قرارداد")) p.EmploymentType = Models.EmploymentType.Contract;
|
||||
else if (ContainsAny(text, "تمام وقت", "تماموقت")) p.EmploymentType = Models.EmploymentType.FullTime;
|
||||
|
||||
// --- City / district ---
|
||||
p.CityName = knownCities.FirstOrDefault(c => text.Contains(Normalize(c)));
|
||||
p.DistrictName = knownDistricts.OrderByDescending(d => d.Length)
|
||||
.FirstOrDefault(d => text.Contains(Normalize(d)));
|
||||
|
||||
// --- Pay ---
|
||||
if (ContainsAny(text, "توافقی", "توافق")) { p.PayNegotiable = true; p.Notes.Add("حقوق: توافقی"); }
|
||||
else
|
||||
{
|
||||
var amount = ExtractAmount(text);
|
||||
if (amount is not null) { p.PayAmount = amount; p.Notes.Add($"حقوق تخمینی: {amount:#,0} تومان"); }
|
||||
else p.Notes.Add("حقوق: تشخیص داده نشد");
|
||||
}
|
||||
|
||||
// --- Phone ---
|
||||
var phone = Regex.Match(ToLatinDigits(text), @"0?9\d{9}");
|
||||
if (phone.Success) p.Phone = phone.Value;
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
/// <summary>Pull a Toman figure out of free text, handling «میلیون» and Persian digits.</summary>
|
||||
private static long? ExtractAmount(string text)
|
||||
{
|
||||
var latin = ToLatinDigits(text);
|
||||
// e.g. "۲ میلیون" / "2.5 میلیون"
|
||||
var million = Regex.Match(latin, @"(\d+(?:[.,]\d+)?)\s*میلیون");
|
||||
if (million.Success && double.TryParse(million.Groups[1].Value.Replace(",", "."),
|
||||
System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var m))
|
||||
return (long)(m * 1_000_000);
|
||||
|
||||
// Otherwise the largest plain number that looks like money (>= 6 digits after removing separators).
|
||||
long best = 0;
|
||||
foreach (Match num in Regex.Matches(latin, @"[\d٬,،.]{6,}"))
|
||||
{
|
||||
var digits = Regex.Replace(num.Value, @"[^\d]", "");
|
||||
if (digits.Length >= 6 && long.TryParse(digits, out var v) && v > best) best = v;
|
||||
}
|
||||
return best > 0 ? best : null;
|
||||
}
|
||||
|
||||
private static string Normalize(string s) => s
|
||||
.Replace('ي', 'ی').Replace('ك', 'ک').Replace('', ' ').Trim();
|
||||
|
||||
private static bool ContainsAny(string text, params string[] needles)
|
||||
=> needles.Any(n => text.Contains(n));
|
||||
|
||||
private static string ToLatinDigits(string s)
|
||||
{
|
||||
var chars = s.ToCharArray();
|
||||
for (var i = 0; i < chars.Length; i++)
|
||||
{
|
||||
if (chars[i] >= '۰' && chars[i] <= '۹') chars[i] = (char)('0' + (chars[i] - '۰'));
|
||||
else if (chars[i] >= '٠' && chars[i] <= '٩') chars[i] = (char)('0' + (chars[i] - '٠'));
|
||||
}
|
||||
return new string(chars);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One-time-code issuing/verification. Codes live in memory for 5 minutes. In dev the code is
|
||||
/// returned to the caller so it can be shown on screen; in production this is where an Iranian
|
||||
/// SMS gateway (Kavenegar / SMS.ir) would send the code instead.
|
||||
/// </summary>
|
||||
public class OtpService
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
public OtpService(IMemoryCache cache) => _cache = cache;
|
||||
|
||||
private static string Key(string phone) => $"otp:{Normalize(phone)}";
|
||||
|
||||
/// <summary>Generate, store, and (in dev) return a 5-digit code for the phone.</summary>
|
||||
public string Issue(string phone)
|
||||
{
|
||||
var code = Random.Shared.Next(10000, 100000).ToString();
|
||||
_cache.Set(Key(phone), code, TimeSpan.FromMinutes(5));
|
||||
// TODO(prod): send `code` via Kavenegar/SMS.ir instead of returning it.
|
||||
return code;
|
||||
}
|
||||
|
||||
public bool Verify(string phone, string code)
|
||||
{
|
||||
if (_cache.TryGetValue(Key(phone), out string? stored) && stored == code?.Trim())
|
||||
{
|
||||
_cache.Remove(Key(phone));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Normalize Iranian mobile numbers (Persian digits → Latin, strip spaces).</summary>
|
||||
public static string Normalize(string phone)
|
||||
{
|
||||
var chars = phone.Trim().ToCharArray();
|
||||
for (var i = 0; i < chars.Length; i++)
|
||||
if (chars[i] >= '۰' && chars[i] <= '۹') chars[i] = (char)('0' + (chars[i] - '۰'));
|
||||
return new string(chars).Replace(" ", "").Replace("-", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
public record Recommendation(Shift Shift, double Score, List<string> Reasons);
|
||||
|
||||
/// <summary>
|
||||
/// Stage 1 of the recommendation engine: a transparent, rule-based pattern engine.
|
||||
/// It scores open shifts against a visitor's explicit preferences AND their recent behavior
|
||||
/// (which roles/facilities/shift-types they keep engaging with), and returns the top matches
|
||||
/// each with a human-readable reason. No ML/AI infra required — works from the first visit,
|
||||
/// and every result is explainable. Behavioral data logged now feeds the ML stages later.
|
||||
/// </summary>
|
||||
public class RecommendationService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly InterestService _interest;
|
||||
|
||||
public RecommendationService(AppDbContext db, InterestService interest)
|
||||
{
|
||||
_db = db;
|
||||
_interest = interest;
|
||||
}
|
||||
|
||||
// Tunable weights — the whole point of a pattern engine is that these are legible.
|
||||
private const double WRolePref = 40, WRoleBehavior = 15;
|
||||
private const double WCityPref = 20;
|
||||
private const double WShiftTypePref = 15, WShiftTypeBehavior = 8;
|
||||
private const double WPayMeetsExpectation = 10;
|
||||
private const double WFacilityAffinity = 12;
|
||||
private const double WFreshness = 5, WSoon = 6;
|
||||
private const double PenaltyDismissedFacility = 60;
|
||||
|
||||
public async Task<List<Recommendation>> GetForVisitorAsync(int take = 6)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var prefs = await _interest.GetPreferencesAsync();
|
||||
var events = await _interest.RecentEventsAsync(150);
|
||||
|
||||
var candidates = await _db.Shifts
|
||||
.Include(s => s.Facility).ThenInclude(f => f.City)
|
||||
.Include(s => s.Role)
|
||||
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
|
||||
.ToListAsync();
|
||||
|
||||
// Cold start: no preferences and no behavior → just show the freshest opportunities.
|
||||
if (prefs is null && events.Count == 0)
|
||||
{
|
||||
return candidates
|
||||
.OrderBy(s => s.Date).ThenBy(s => s.StartTime)
|
||||
.Take(take)
|
||||
.Select(s => new Recommendation(s, 0, new() { "جدیدترین فرصتها" }))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Derive behavioral affinities from the event log (shift events only — jobs are separate).
|
||||
var shiftEvents = events.Where(e => e.ShiftId is not null).ToList();
|
||||
var eventShiftIds = shiftEvents.Select(e => e.ShiftId!.Value).Distinct().ToList();
|
||||
var eventShifts = candidates.Where(s => eventShiftIds.Contains(s.Id))
|
||||
.Concat(await _db.Shifts.Include(s => s.Role)
|
||||
.Where(s => eventShiftIds.Contains(s.Id)).ToListAsync())
|
||||
.DistinctBy(s => s.Id)
|
||||
.ToDictionary(s => s.Id);
|
||||
|
||||
var positive = new[] { InterestEventType.View, InterestEventType.Click,
|
||||
InterestEventType.Save, InterestEventType.Apply };
|
||||
|
||||
var roleAffinity = TopBy(shiftEvents, positive, eventShifts, s => s.RoleId);
|
||||
var shiftTypeAffinity = TopBy(shiftEvents, positive, eventShifts, s => (int)s.ShiftType);
|
||||
var facilityAffinity = TopBy(shiftEvents, positive, eventShifts, s => s.FacilityId);
|
||||
|
||||
var dismissedFacilities = shiftEvents
|
||||
.Where(e => e.EventType is InterestEventType.Dismiss or InterestEventType.HideFacility)
|
||||
.Select(e => eventShifts.TryGetValue(e.ShiftId!.Value, out var s) ? s.FacilityId : 0)
|
||||
.Where(id => id != 0).ToHashSet();
|
||||
|
||||
var results = new List<Recommendation>();
|
||||
foreach (var s in candidates)
|
||||
{
|
||||
double score = 0;
|
||||
var reasons = new List<string>();
|
||||
|
||||
if (prefs?.RoleId is int pr && pr == s.RoleId)
|
||||
{ score += WRolePref; reasons.Add($"متناسب با نقش مورد علاقه شما ({s.Role.Name})"); }
|
||||
else if (roleAffinity.Contains(s.RoleId))
|
||||
{ score += WRoleBehavior; reasons.Add($"چون به فرصتهای «{s.Role.Name}» علاقه نشان دادی"); }
|
||||
|
||||
if (prefs?.CityId is int pc && pc == s.Facility.CityId)
|
||||
{ score += WCityPref; reasons.Add($"در شهر مورد علاقه شما ({s.Facility.City.Name})"); }
|
||||
|
||||
if (prefs?.PreferredShiftType is ShiftType pst && pst == s.ShiftType)
|
||||
{ score += WShiftTypePref; reasons.Add($"نوع شیفت دلخواه شما ({ShiftTypeLabel(s.ShiftType)})"); }
|
||||
else if (shiftTypeAffinity.Contains((int)s.ShiftType))
|
||||
{ score += WShiftTypeBehavior; reasons.Add($"شبیه شیفتهایی که دیدهای ({ShiftTypeLabel(s.ShiftType)})"); }
|
||||
|
||||
if (prefs?.MinPay is long min && s.PayAmount is long pay && pay >= min)
|
||||
{ score += WPayMeetsExpectation; reasons.Add("حقوق بالاتر از حد انتظار شما"); }
|
||||
|
||||
if (facilityAffinity.Contains(s.FacilityId))
|
||||
{ score += WFacilityAffinity; reasons.Add($"از مرکزی که قبلاً به آن علاقه نشان دادی ({s.Facility.Name})"); }
|
||||
|
||||
if (dismissedFacilities.Contains(s.FacilityId))
|
||||
score -= PenaltyDismissedFacility;
|
||||
|
||||
// Sooner shifts and freshly posted ones get a small nudge.
|
||||
var daysOut = s.Date.DayNumber - today.DayNumber;
|
||||
if (daysOut <= 3) score += WSoon;
|
||||
if ((DateTime.UtcNow - s.CreatedAt).TotalDays <= 2) score += WFreshness;
|
||||
|
||||
if (reasons.Count == 0) reasons.Add("پیشنهاد بر اساس فعالیت شما");
|
||||
results.Add(new Recommendation(s, score, reasons));
|
||||
}
|
||||
|
||||
return results
|
||||
.Where(r => r.Score > 0)
|
||||
.OrderByDescending(r => r.Score).ThenBy(r => r.Shift.Date)
|
||||
.Take(take)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Keys the visitor engaged with most (positive events), top 3.</summary>
|
||||
private static HashSet<int> TopBy(
|
||||
List<InterestEvent> events, InterestEventType[] positive,
|
||||
Dictionary<int, Shift> shiftById, Func<Shift, int> key)
|
||||
{
|
||||
return events
|
||||
.Where(e => e.ShiftId is not null && positive.Contains(e.EventType)
|
||||
&& shiftById.ContainsKey(e.ShiftId.Value))
|
||||
.GroupBy(e => key(shiftById[e.ShiftId!.Value]))
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Take(3)
|
||||
.Select(g => g.Key)
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
private static string ShiftTypeLabel(ShiftType t) => t switch
|
||||
{
|
||||
ShiftType.Day => "صبح",
|
||||
ShiftType.Evening => "عصر",
|
||||
ShiftType.Night => "شب",
|
||||
_ => "آنکال",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace JobsMedical.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the current anonymous visitor id for the request. The id is created and written to
|
||||
/// the <c>hk_vid</c> cookie by <see cref="VisitorCookieMiddleware"/>; this scoped accessor just
|
||||
/// reads it back out of <c>HttpContext.Items</c>.
|
||||
/// </summary>
|
||||
public class VisitorContext
|
||||
{
|
||||
public const string CookieName = "hk_vid";
|
||||
public const string ItemKey = "VisitorId";
|
||||
|
||||
private readonly IHttpContextAccessor _http;
|
||||
public VisitorContext(IHttpContextAccessor http) => _http = http;
|
||||
|
||||
public string VisitorId =>
|
||||
_http.HttpContext?.Items[ItemKey] as string ?? "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures every visitor carries a stable <c>hk_vid</c> cookie (a GUID) so we can track interest
|
||||
/// from the first visit, before any login. On login we link this id to the user account.
|
||||
/// </summary>
|
||||
public class VisitorCookieMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
public VisitorCookieMiddleware(RequestDelegate next) => _next = next;
|
||||
|
||||
public async Task InvokeAsync(HttpContext ctx)
|
||||
{
|
||||
var id = ctx.Request.Cookies[VisitorContext.CookieName];
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
id = Guid.NewGuid().ToString();
|
||||
ctx.Response.Cookies.Append(VisitorContext.CookieName, id, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
IsEssential = true, // functional, not tracking-consent gated
|
||||
SameSite = SameSiteMode.Lax,
|
||||
MaxAge = TimeSpan.FromDays(365),
|
||||
});
|
||||
}
|
||||
ctx.Items[VisitorContext.ItemKey] = id;
|
||||
await _next(ctx);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user