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,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);
|
||||
}
|
||||
}
|
||||
@@ -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); // 0–2 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user