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:
soroush.asadi
2026-06-03 01:43:55 +03:30
commit 2fb86a435e
150 changed files with 90993 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
using JobsMedical.Web.Models;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Data;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<City> Cities => Set<City>();
public DbSet<District> Districts => Set<District>();
public DbSet<Role> Roles => Set<Role>();
public DbSet<User> Users => Set<User>();
public DbSet<DoctorProfile> DoctorProfiles => Set<DoctorProfile>();
public DbSet<Facility> Facilities => Set<Facility>();
public DbSet<Shift> Shifts => Set<Shift>();
public DbSet<JobOpening> JobOpenings => Set<JobOpening>();
public DbSet<Application> Applications => Set<Application>();
public DbSet<RawListing> RawListings => Set<RawListing>();
public DbSet<Visitor> Visitors => Set<Visitor>();
public DbSet<UserPreferences> UserPreferences => Set<UserPreferences>();
public DbSet<InterestEvent> InterestEvents => Set<InterestEvent>();
protected override void OnModelCreating(ModelBuilder b)
{
base.OnModelCreating(b);
// Phone is the unique identity.
b.Entity<User>().HasIndex(u => u.Phone).IsUnique();
// One-to-one User <-> DoctorProfile.
b.Entity<DoctorProfile>()
.HasOne(d => d.User)
.WithOne(u => u.DoctorProfile)
.HasForeignKey<DoctorProfile>(d => d.UserId)
.OnDelete(DeleteBehavior.Cascade);
// A doctor can apply to a shift only once.
b.Entity<Application>()
.HasIndex(a => new { a.ShiftId, a.DoctorId })
.IsUnique();
b.Entity<Application>()
.HasOne(a => a.Doctor)
.WithMany(u => u.Applications)
.HasForeignKey(a => a.DoctorId)
.OnDelete(DeleteBehavior.Cascade);
b.Entity<Application>()
.HasOne(a => a.Shift)
.WithMany(s => s.Applications)
.HasForeignKey(a => a.ShiftId)
.OnDelete(DeleteBehavior.Cascade);
// Facility owner is optional (Phase 2 self-serve); don't cascade-delete users.
b.Entity<Facility>()
.HasOne(f => f.OwnerUser)
.WithMany()
.HasForeignKey(f => f.OwnerUserId)
.OnDelete(DeleteBehavior.SetNull);
// Common query indexes: filtering shifts by city/date/status happens constantly.
b.Entity<Shift>().HasIndex(s => new { s.Date, s.Status });
b.Entity<Shift>().HasIndex(s => s.FacilityId);
b.Entity<Shift>().HasIndex(s => s.RoleId);
b.Entity<Facility>().HasIndex(f => f.CityId);
b.Entity<Facility>().HasIndex(f => f.DistrictId);
b.Entity<District>()
.HasOne(d => d.City).WithMany()
.HasForeignKey(d => d.CityId).OnDelete(DeleteBehavior.Cascade);
b.Entity<Facility>()
.HasOne(f => f.District).WithMany(d => d.Facilities)
.HasForeignKey(f => f.DistrictId).OnDelete(DeleteBehavior.SetNull);
// Don't delete shifts/profiles just because a Role is removed.
b.Entity<Shift>()
.HasOne(s => s.Role).WithMany(r => r.Shifts)
.HasForeignKey(s => s.RoleId).OnDelete(DeleteBehavior.Restrict);
// Visitor identity + behavioral tracking.
b.Entity<Visitor>()
.HasOne(v => v.User).WithMany()
.HasForeignKey(v => v.UserId).OnDelete(DeleteBehavior.SetNull);
b.Entity<UserPreferences>()
.HasOne(p => p.Visitor).WithOne(v => v.Preferences)
.HasForeignKey<UserPreferences>(p => p.VisitorId).OnDelete(DeleteBehavior.Cascade);
b.Entity<InterestEvent>()
.HasOne(e => e.Visitor).WithMany(v => v.Events)
.HasForeignKey(e => e.VisitorId).OnDelete(DeleteBehavior.Cascade);
b.Entity<InterestEvent>()
.HasOne(e => e.Shift).WithMany()
.HasForeignKey(e => e.ShiftId).OnDelete(DeleteBehavior.Cascade);
b.Entity<InterestEvent>()
.HasOne(e => e.JobOpening).WithMany()
.HasForeignKey(e => e.JobOpeningId).OnDelete(DeleteBehavior.Cascade);
// Reading "this visitor's recent events" is the hot path for recommendations.
b.Entity<InterestEvent>().HasIndex(e => new { e.VisitorId, e.CreatedAt });
// Job openings: same query patterns as shifts.
b.Entity<JobOpening>()
.HasOne(j => j.Role).WithMany()
.HasForeignKey(j => j.RoleId).OnDelete(DeleteBehavior.Restrict);
b.Entity<JobOpening>()
.HasOne(j => j.Facility).WithMany()
.HasForeignKey(j => j.FacilityId).OnDelete(DeleteBehavior.Cascade);
b.Entity<JobOpening>().HasIndex(j => j.Status);
b.Entity<JobOpening>().HasIndex(j => j.FacilityId);
}
}
+182
View File
@@ -0,0 +1,182 @@
using JobsMedical.Web.Models;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Data;
/// <summary>
/// Seeds a believable Tehran-focused board so the marketplace doesn't look empty on first run
/// (the cold-start problem). Idempotent: only seeds when the DB is empty.
/// </summary>
public static class SeedData
{
public static async Task EnsureSeededAsync(AppDbContext db)
{
if (await db.Cities.AnyAsync()) return;
var tehran = new City { Name = "تهران", Province = "تهران", IsActive = true };
var cities = new[]
{
tehran,
new City { Name = "کرج", Province = "البرز", IsActive = false },
new City { Name = "مشهد", Province = "خراسان رضوی", IsActive = false },
new City { Name = "اصفهان", Province = "اصفهان", IsActive = false },
new City { Name = "شیراز", Province = "فارس", IsActive = false },
};
db.Cities.AddRange(cities);
await db.SaveChangesAsync(); // need tehran.Id before districts reference it
var roles = new[]
{
new Role { Name = "پزشک عمومی", Category = "پزشک", SortOrder = 1 },
new Role { Name = "پزشک متخصص", Category = "پزشک", SortOrder = 2 },
new Role { Name = "پرستار", Category = "پرستار", SortOrder = 3 },
new Role { Name = "ماما", Category = "ماما", SortOrder = 4 },
new Role { Name = "تکنسین اتاق عمل", Category = "تکنسین", SortOrder = 5 },
new Role { Name = "تکنسین فوریت‌های پزشکی", Category = "تکنسین", SortOrder = 6 },
new Role { Name = "کارشناس آزمایشگاه", Category = "تکنسین", SortOrder = 7 },
};
db.Roles.AddRange(roles);
// Tehran neighborhoods (محله/منطقه) for the within-city filter.
var saadatAbad = new District { Name = "سعادت‌آباد", CityId = tehran.Id };
var shahrakGharb = new District { Name = "شهرک غرب", CityId = tehran.Id };
var valiasr = new District { Name = "ولیعصر / پارک‌وی", CityId = tehran.Id };
var narmak = new District { Name = "نارمک", CityId = tehran.Id };
var tehranpars = new District { Name = "تهرانپارس", CityId = tehran.Id };
var gisha = new District { Name = "گیشا / برج میلاد", CityId = tehran.Id };
db.Districts.AddRange(saadatAbad, shahrakGharb, valiasr, narmak, tehranpars, gisha,
new District { Name = "ونک", CityId = tehran.Id },
new District { Name = "تجریش", CityId = tehran.Id });
await db.SaveChangesAsync();
var facilities = new[]
{
new Facility { Name = "بیمارستان میلاد", Type = FacilityType.Hospital, CityId = tehran.Id,
DistrictId = gisha.Id,
Address = "تهران، بزرگراه همت، روبه‌روی برج میلاد", Phone = "021-82032000",
Lat = 35.7448, Lng = 51.3753, IsVerified = true },
new Facility { Name = "بیمارستان دی", Type = FacilityType.Hospital, CityId = tehran.Id,
DistrictId = valiasr.Id,
Address = "تهران، خیابان ولیعصر، بالاتر از پارک‌وی", Phone = "021-23601",
Lat = 35.7986, Lng = 51.4087, IsVerified = true },
new Facility { Name = "کلینیک تخصصی پارسیان", Type = FacilityType.Clinic, CityId = tehran.Id,
DistrictId = saadatAbad.Id,
Address = "تهران، سعادت‌آباد، میدان کاج", Phone = "021-22360000",
Lat = 35.7872, Lng = 51.3760, IsVerified = false },
new Facility { Name = "درمانگاه شبانه‌روزی البرز", Type = FacilityType.Polyclinic, CityId = tehran.Id,
DistrictId = narmak.Id,
Address = "تهران، نارمک، میدان هلال احمر", Phone = "021-77900000",
Lat = 35.7448, Lng = 51.5085, IsVerified = true },
new Facility { Name = "بیمارستان آتیه", Type = FacilityType.Hospital, CityId = tehran.Id,
DistrictId = shahrakGharb.Id,
Address = "تهران، شهرک غرب، بلوار فرحزادی", Phone = "021-82721",
Lat = 35.7570, Lng = 51.3680, IsVerified = true },
new Facility { Name = "کلینیک درمانی مهر", Type = FacilityType.Clinic, CityId = tehran.Id,
DistrictId = tehranpars.Id,
Address = "تهران، تهرانپارس، فلکه دوم", Phone = "021-77700000",
Lat = 35.7350, Lng = 51.5400, IsVerified = false },
};
db.Facilities.AddRange(facilities);
await db.SaveChangesAsync();
// Build ~2 weeks of shifts starting today, a few per facility per day, across roles.
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var rng = new Random(20260602); // deterministic seed for reproducible sample data
var shifts = new List<Shift>();
// Weighted role pool — GP and nurse most common, others sprinkled in.
var rolePool = new[]
{
roles[0], roles[0], roles[0], // پزشک عمومی (most common)
roles[2], roles[2], // پرستار
roles[1], // پزشک متخصص
roles[3], // ماما
roles[4], roles[5], roles[6], // تکنسین‌ها
};
var templates = new[]
{
(ShiftType.Day, new TimeOnly(8, 0), new TimeOnly(14, 0), "شیفت صبح", 1_500_000L),
(ShiftType.Evening, new TimeOnly(14, 0), new TimeOnly(20, 0), "شیفت عصر", 1_800_000L),
(ShiftType.Night, new TimeOnly(20, 0), new TimeOnly(8, 0), "شیفت شب", 2_500_000L),
(ShiftType.OnCall, new TimeOnly(8, 0), new TimeOnly(8, 0), "آنکال", 0L),
};
foreach (var f in facilities)
{
for (var d = 0; d < 14; d++)
{
var date = today.AddDays(d);
var count = rng.Next(0, 3); // 02 shifts per facility per day
for (var i = 0; i < count; i++)
{
var t = templates[rng.Next(templates.Length)];
var negotiable = rng.Next(0, 4) == 0;
var role = rolePool[rng.Next(rolePool.Length)];
shifts.Add(new Shift
{
FacilityId = f.Id,
RoleId = role.Id,
Date = date,
StartTime = t.Item2,
EndTime = t.Item3,
ShiftType = t.Item1,
SpecialtyRequired = role.Name,
Description = $"{t.Item4} - نیازمند {role.Name} مسلط به امور درمانگاه/اورژانس",
PayType = t.Item1 == ShiftType.OnCall || negotiable ? PayType.Negotiable : PayType.PerShift,
PayAmount = t.Item1 == ShiftType.OnCall || negotiable ? null : t.Item5,
Status = ShiftStatus.Open,
Source = ShiftSource.Admin,
});
}
}
}
db.Shifts.AddRange(shifts);
// Permanent hiring openings (استخدام) — the hiring side of the marketplace.
db.JobOpenings.AddRange(
new JobOpening { FacilityId = facilities[0].Id, RoleId = roles[2].Id,
Title = "استخدام پرستار بخش اورژانس", EmploymentType = EmploymentType.FullTime,
SalaryMin = 18_000_000, SalaryMax = 25_000_000,
Description = "استخدام تمام‌وقت پرستار جهت بخش اورژانس با سابقه کار.",
Requirements = "حداقل ۲ سال سابقه، مسلط به ICU", Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[1].Id, RoleId = roles[0].Id,
Title = "پزشک عمومی مقیم", EmploymentType = EmploymentType.Contract,
SalaryMin = 40_000_000, SalaryMax = 55_000_000,
Description = "پزشک عمومی مقیم جهت بیمارستان، شیفت‌های چرخشی.",
Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[2].Id, RoleId = roles[3].Id,
Title = "ماما جهت کلینیک زنان", EmploymentType = EmploymentType.PartTime,
SalaryMin = null, SalaryMax = null,
Description = "همکاری پاره‌وقت ماما در کلینیک تخصصی زنان و زایمان.",
Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[4].Id, RoleId = roles[4].Id,
Title = "تکنسین اتاق عمل", EmploymentType = EmploymentType.FullTime,
SalaryMin = 16_000_000, SalaryMax = 22_000_000,
Description = "تکنسین اتاق عمل جهت بیمارستان، تمام‌وقت با بیمه.",
Requirements = "مدرک تکنسین اتاق عمل، آشنا به ابزار جراحی", Status = ShiftStatus.Open },
new JobOpening { FacilityId = facilities[3].Id, RoleId = roles[0].Id,
Title = "پزشک عمومی طرح", EmploymentType = EmploymentType.Plan,
SalaryMin = 30_000_000, SalaryMax = 30_000_000,
Description = "جذب پزشک عمومی جهت گذراندن طرح در درمانگاه شبانه‌روزی.",
Status = ShiftStatus.Open });
await db.SaveChangesAsync();
// A couple of raw listings waiting in the admin normalization queue.
db.RawListings.AddRange(
new RawListing
{
SourceChannel = "کانال شیفت پزشکان تهران",
RawText = "نیازمند پزشک عمومی جهت شیفت شب درمانگاه در منطقه غرب تهران، کارانه توافقی. تماس: ۰۹۱۲xxxxxxx",
Status = RawListingStatus.New,
},
new RawListing
{
SourceChannel = "Divar - استخدام پزشک",
RawText = "بیمارستان خصوصی جهت تکمیل کادر درمان به پزشک عمومی برای شیفت‌های روز نیازمند است.",
Status = RawListingStatus.New,
});
await db.SaveChangesAsync();
}
}