2fb86a435e
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>
103 lines
3.2 KiB
C#
103 lines
3.2 KiB
C#
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();
|
|
}
|
|
}
|