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
+22
View File
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>A doctor expressing interest in a shift. MVP = lightweight "interested" + contact handoff.</summary>
public class Application
{
public int Id { get; set; }
public int ShiftId { get; set; }
public Shift Shift { get; set; } = null!;
public int DoctorId { get; set; } // User.Id of the doctor
public User Doctor { get; set; } = null!;
public ApplicationStatus Status { get; set; } = ApplicationStatus.Interested;
[MaxLength(500)]
public string? Message { get; set; } // پیام اختیاری پزشک
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+19
View File
@@ -0,0 +1,19 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>Canonical city list used for filtering. Launch focuses on Tehran.</summary>
public class City
{
public int Id { get; set; }
[Required, MaxLength(100)]
public string Name { get; set; } = ""; // نام شهر، مثل «تهران»
[MaxLength(100)]
public string Province { get; set; } = ""; // استان
public bool IsActive { get; set; } = true; // آیا در این شهر سرویس فعال است
public ICollection<Facility> Facilities { get; set; } = new List<Facility>();
}
+22
View File
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>
/// A neighborhood / area within a city (e.g. سعادت‌آباد، تهرانپارس). Lets people narrow a
/// city down to where they actually want to work, alongside the "near me" distance filter.
/// </summary>
public class District
{
public int Id { get; set; }
public int CityId { get; set; }
public City City { get; set; } = null!;
[Required, MaxLength(120)]
public string Name { get; set; } = "";
public bool IsActive { get; set; } = true;
public ICollection<Facility> Facilities { get; set; } = new List<Facility>();
}
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>Profile for a doctor. Trust signal is the نظام پزشکی (medical council) number.</summary>
public class DoctorProfile
{
public int Id { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
public int? RoleId { get; set; } // نقش این فرد (پزشک/پرستار/ماما/...)
public Role? Role { get; set; }
[MaxLength(20)]
public string? LicenseNo { get; set; } // شماره نظام پزشکی/پرستاری
[MaxLength(100)]
public string Specialty { get; set; } = "پزشک عمومی"; // جزئیات تخصص (اختیاری)
public int? CityId { get; set; }
public City? City { get; set; }
public int YearsExperience { get; set; }
[MaxLength(1000)]
public string? Bio { get; set; }
public bool IsVerified { get; set; } // تأیید نظام پزشکی توسط ادمین
}
+75
View File
@@ -0,0 +1,75 @@
namespace JobsMedical.Web.Models;
public enum UserRole
{
Doctor = 0,
FacilityAdmin = 1,
Admin = 2
}
public enum FacilityType
{
Hospital = 0, // بیمارستان
Clinic = 1, // کلینیک
Polyclinic = 2 // درمانگاه
}
public enum ShiftType
{
Day = 0, // روز
Evening = 1, // عصر
Night = 2, // شب
OnCall = 3 // آنکال
}
public enum ShiftStatus
{
Open = 0, // باز
Filled = 1, // پر شده
Expired = 2, // منقضی
Cancelled = 3 // لغو شده
}
public enum ShiftSource
{
Direct = 0, // ثبت مستقیم مرکز درمانی
Admin = 1, // ثبت توسط ادمین
Aggregated = 2 // جمع‌آوری شده از کانال‌ها
}
public enum PayType
{
PerShift = 0, // مقطوع برای هر شیفت
PerHour = 1, // ساعتی
Negotiable = 2 // توافقی
}
public enum ApplicationStatus
{
Interested = 0, // اعلام تمایل
Accepted = 1, // پذیرفته شده
Rejected = 2, // رد شده
Withdrawn = 3 // انصراف
}
public enum RawListingStatus
{
New = 0, // جدید
Normalized = 1, // تبدیل شده به شیفت
Discarded = 2 // کنار گذاشته شده
}
public enum EmploymentType
{
FullTime = 0, // تمام‌وقت
PartTime = 1, // پاره‌وقت
Contract = 2, // قراردادی
Plan = 3 // طرح
}
/// <summary>What an aggregated/raw listing turned out to be — a shift or a hiring opening.</summary>
public enum ListingKind
{
Shift = 0,
Job = 1
}
+42
View File
@@ -0,0 +1,42 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>A hospital, clinic, or polyclinic that posts open shifts.</summary>
public class Facility
{
public int Id { get; set; }
[Required, MaxLength(200)]
public string Name { get; set; } = ""; // نام مرکز درمانی
public FacilityType Type { get; set; } = FacilityType.Hospital;
public int CityId { get; set; }
public City City { get; set; } = null!;
public int? DistrictId { get; set; } // محله/منطقه
public District? District { get; set; }
[MaxLength(500)]
public string? Address { get; set; } // آدرس
public double? Lat { get; set; } // مختصات برای نقشه نشان/بلد
public double? Lng { get; set; }
[MaxLength(20)]
public string? Phone { get; set; } // تلفن تماس مرکز
[MaxLength(50)]
public string? BaleId { get; set; } // شناسه بله برای ارتباط
public bool IsVerified { get; set; } // نشان «تأیید شده»
// Phase 2: facility self-serve. Null in MVP (admin manages).
public int? OwnerUserId { get; set; }
public User? OwnerUser { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<Shift> Shifts { get; set; } = new List<Shift>();
}
@@ -0,0 +1,35 @@
namespace JobsMedical.Web.Models;
public enum InterestEventType
{
View = 0, // باز کردن صفحه شیفت
Click = 1, // کلیک از فهرست
Save = 2, // ذخیره/علاقه‌مندی
Apply = 3, // اعلام تمایل
Dismiss = 4, // رد کردن
HideFacility = 5 // پنهان کردن یک مرکز
}
/// <summary>
/// One behavioral signal: this visitor did X to this shift. The accumulated log is the fuel
/// for collaborative filtering and ML ranking later; for now the pattern engine reads recent
/// events to infer affinity (e.g. repeated interest in night shifts at a given facility).
/// </summary>
public class InterestEvent
{
public long Id { get; set; }
public string VisitorId { get; set; } = "";
public Visitor Visitor { get; set; } = null!;
// Exactly one target is set — a shift or a job opening.
public int? ShiftId { get; set; }
public Shift? Shift { get; set; }
public int? JobOpeningId { get; set; }
public JobOpening? JobOpening { get; set; }
public InterestEventType EventType { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+45
View File
@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace JobsMedical.Web.Models;
/// <summary>
/// A permanent / ongoing hiring position (استخدام) — the hiring side of the marketplace,
/// alongside one-off <see cref="Shift"/>s. No date/time; instead an employment type and a
/// monthly salary range. Reuses <see cref="ShiftStatus"/> for lifecycle (Open/Filled/…).
/// </summary>
public class JobOpening
{
public int Id { get; set; }
public int FacilityId { get; set; }
public Facility Facility { get; set; } = null!;
public int RoleId { get; set; }
public Role Role { get; set; } = null!;
[Required, MaxLength(200)]
public string Title { get; set; } = ""; // عنوان موقعیت
public EmploymentType EmploymentType { get; set; } = EmploymentType.FullTime;
public long? SalaryMin { get; set; } // حقوق ماهانه (تومان)؛ null = توافقی
public long? SalaryMax { get; set; }
[MaxLength(2000)]
public string? Description { get; set; }
[MaxLength(1000)]
public string? Requirements { get; set; } // شرایط احراز
public ShiftStatus Status { get; set; } = ShiftStatus.Open;
public ShiftSource Source { get; set; } = ShiftSource.Admin;
[MaxLength(500)]
public string? SourceUrl { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Transient: distance (km) when "near me" is active. Not persisted.
[NotMapped] public double? DistanceKm { get; set; }
}
+31
View File
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>
/// Staging area for shift listings aggregated from Telegram / Bale / Divar channels.
/// An admin reviews and normalizes these into real <see cref="Shift"/> records.
/// This is how we beat the cold-start problem.
/// </summary>
public class RawListing
{
public int Id { get; set; }
[MaxLength(200)]
public string SourceChannel { get; set; } = ""; // نام کانال/منبع
[Required]
public string RawText { get; set; } = ""; // متن خام آگهی
public string? ParsedJson { get; set; } // نتیجه‌ی تجزیه‌ی خودکار (در صورت وجود)
public RawListingStatus Status { get; set; } = RawListingStatus.New;
public int? LinkedShiftId { get; set; } // شیفت ساخته‌شده از این آگهی
public Shift? LinkedShift { get; set; }
[MaxLength(500)]
public string? SourceUrl { get; set; }
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
}
+25
View File
@@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>
/// A healthcare staff role the platform serves. The taxonomy spans all of کادر درمان —
/// doctors, nurses, midwives, technicians — not just GPs. Used to tag shifts/openings and
/// to match people to opportunities.
/// </summary>
public class Role
{
public int Id { get; set; }
[Required, MaxLength(100)]
public string Name { get; set; } = ""; // مثل «پزشک عمومی»، «پرستار»، «ماما»
[MaxLength(50)]
public string Category { get; set; } = ""; // گروه: پزشک / پرستار / ماما / تکنسین
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public ICollection<Shift> Shifts { get; set; } = new List<Shift>();
}
+58
View File
@@ -0,0 +1,58 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>
/// An open shift at a facility. The heart of the platform.
/// Dates are stored as UTC <see cref="DateOnly"/>/<see cref="TimeOnly"/> and displayed as Jalali.
/// </summary>
public class Shift
{
public int Id { get; set; }
public int FacilityId { get; set; }
public Facility Facility { get; set; } = null!;
public int RoleId { get; set; } // نقش مورد نیاز (پزشک/پرستار/ماما/...)
public Role Role { get; set; } = null!;
public DateOnly Date { get; set; } // تاریخ شیفت (در نمایش به شمسی تبدیل می‌شود)
public TimeOnly StartTime { get; set; } // ساعت شروع
public TimeOnly EndTime { get; set; } // ساعت پایان
[MaxLength(100)]
public string SpecialtyRequired { get; set; } = "پزشک عمومی";
public ShiftType ShiftType { get; set; } = ShiftType.Day;
public long? PayAmount { get; set; } // مبلغ (تومان)؛ null یعنی توافقی
public PayType PayType { get; set; } = PayType.PerShift;
[MaxLength(1500)]
public string? Description { get; set; } // توضیحات
public ShiftStatus Status { get; set; } = ShiftStatus.Open;
public ShiftSource Source { get; set; } = ShiftSource.Admin;
[MaxLength(500)]
public string? SourceUrl { get; set; } // لینک منبع در صورت جمع‌آوری از کانال
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<Application> Applications { get; set; } = new List<Application>();
// Transient: distance (km) from the visitor when "near me" is active. Not persisted.
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public double? DistanceKm { get; set; }
// Convenience (not mapped): duration in hours, handles overnight shifts.
public double DurationHours
{
get
{
var span = EndTime.ToTimeSpan() - StartTime.ToTimeSpan();
if (span <= TimeSpan.Zero) span += TimeSpan.FromDays(1); // شیفت شب که به روز بعد می‌رسد
return span.TotalHours;
}
}
}
+28
View File
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>
/// Identity is the phone number (OTP login, no passwords). A user is a doctor,
/// a facility admin, or a platform admin.
/// </summary>
public class User
{
public int Id { get; set; }
[Required, MaxLength(20)]
public string Phone { get; set; } = ""; // شماره موبایل، شناسه یکتا
[MaxLength(150)]
public string? FullName { get; set; }
public UserRole Role { get; set; } = UserRole.Doctor;
public bool IsPhoneVerified { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public DoctorProfile? DoctorProfile { get; set; }
public ICollection<Application> Applications { get; set; } = new List<Application>();
}
@@ -0,0 +1,27 @@
namespace JobsMedical.Web.Models;
/// <summary>
/// What a visitor says they want — the explicit signal for the recommendation engine.
/// Stored per visitor (works pre-login); merges to the user account on login.
/// </summary>
public class UserPreferences
{
public int Id { get; set; }
public string VisitorId { get; set; } = "";
public Visitor Visitor { get; set; } = null!;
public int? RoleId { get; set; } // نقش مورد علاقه
public Role? Role { get; set; }
public int? CityId { get; set; } // شهر مورد علاقه
public City? City { get; set; }
public ShiftType? PreferredShiftType { get; set; } // نوع شیفت ترجیحی
public long? MinPay { get; set; } // حداقل حقوق مورد انتظار (تومان)
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public bool HasAny =>
RoleId is not null || CityId is not null || PreferredShiftType is not null || MinPay is not null;
}
+23
View File
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>
/// An anonymous visitor, identified by a cookie before they ever log in. This lets us track
/// interest and personalize from the very first visit; once the person logs in we link the
/// Visitor to their <see cref="User"/> and keep all the behavioral history.
/// </summary>
public class Visitor
{
[MaxLength(36)]
public string Id { get; set; } = ""; // GUID string stored in the hk_vid cookie
public int? UserId { get; set; } // set after login (history carries over)
public User? User { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
public UserPreferences? Preferences { get; set; }
public ICollection<InterestEvent> Events { get; set; } = new List<InterestEvent>();
}