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,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;
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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; } // تأیید نظام پزشکی توسط ادمین
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
Reference in New Issue
Block a user