Commit Graph

164 Commits

Author SHA1 Message Date
soroush.asadi fce13aaeb0 Fix dark/low-contrast text on the homepage recommendations banner
CI/CD / CI · dotnet build (push) Successful in 3m58s
CI/CD / Deploy · hamkadr (push) Successful in 3m29s
The teal «پیشنهادهای ویژه شما» banner is an <a> that had inline color:inherit, which overrode the
.rec-banner white text with the dark body color — making the subtitle nearly unreadable. Use white.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:51:16 +03:30
soroush.asadi 9fc83b231b Show real exception details to admins on the error page (diagnostics)
CI/CD / CI · dotnet build (push) Successful in 42s
CI/CD / Deploy · hamkadr (push) Successful in 1m4s
Production hides the exception behind a generic 500, so a logged-in Admin couldn''t see why a page
(e.g. /Admin/Settings) failed. Surface the exception type/message/inner/stack on the /Error page ONLY
when the current user is in the Admin role; everyone else still sees the generic message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:14:20 +03:30
soroush.asadi 2d4ea3a762 Fix «از X تا توافقی» salary display when only the minimum is known
CI/CD / CI · dotnet build (push) Successful in 3m50s
CI/CD / Deploy · hamkadr (push) Successful in 2m27s
The pay extractor now fills SalaryMin (e.g. «۳۱ م» -> 31M) but leaves SalaryMax null, which rendered
as «از ۳۱,۰۰۰,۰۰۰ تا توافقی ماهانه». Show «از ۳۱,۰۰۰,۰۰۰ تومان ماهانه» (from-only) in that case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:40:23 +03:30
soroush.asadi c1c914df9f Add per-user Like (پسندیدن) with a liked page and counts
CI/CD / CI · dotnet build (push) Successful in 2m54s
CI/CD / Deploy · hamkadr (push) Successful in 2m48s
Logged-in users can like a listing (job/shift/talent); dislike is removed per request — only likes.
- Like model (polymorphic by TargetType+TargetId) + EF migration; unique per (user, listing).
- POST /like toggles the like (auth required) and returns {liked, count}.
- Detail pages: the old ♡ Save / ✕ Dismiss buttons are replaced by a single heart Like button that
  shows the live count and toggles in place; clicking while logged out redirects to login.
- New «❤️ پسندیده‌ها» page (/Me/Liked) lists everything the user liked (open listings only), with a
  nav entry shown only when authenticated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:25:10 +03:30
soroush.asadi 39c866f4c7 Fix useless bare-divar.ir links + hide empty homepage shifts section
CI/CD / CI · dotnet build (push) Successful in 4m6s
CI/CD / Deploy · hamkadr (push) Successful in 3m35s
- Divar listings with no extractable post token were given SourceUrl «https://divar.ir» — a link
  that just opens Divar''s homepage, not the ad. Store null instead, and guard the contact-modal
  fallback to require a real path (so existing bare-domain links stop being offered too).
- Homepage «جدیدترین شیفت‌ها»: only render when there are real open shifts. Almost all aggregated
  ads are ongoing hiring (jobs), not dated shifts, so the section was showing a fabricated shift
  date (the «۱۸ خرداد» on the welcome page). Now it hides when empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:50:21 +03:30
soroush.asadi fdeefb7625 Move recommendations to a dedicated page + consolidate preferences there
CI/CD / CI · dotnet build (push) Successful in 49s
CI/CD / Deploy · hamkadr (push) Successful in 1m17s
The personalized «پیشنهادهای ویژه شما» feed lived on the homepage and its settings on a separate
/Preferences page. New /Recommendations page combines both — the recommendation cards plus the
preference controls (role/city/shift-type/pay/gender) that drive them, so the settings sit next to
their result. Saving prefs reloads the feed in place.

- Homepage: recommendation section replaced with a CTA card linking to /Recommendations; the model
  no longer loads recommendations.
- Nav: « پیشنهادها» entry added.
- /Preferences now redirects to /Recommendations (old links/bookmarks keep working).
- Page is NoIndex (personalized to the visitor).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:41:17 +03:30
soroush.asadi 1f628d971e Default aggregated ads to Job, not Shift (stop fabricating shift dates/times)
CI/CD / CI · dotnet build (push) Successful in 1m54s
CI/CD / Deploy · hamkadr (push) Successful in 2m19s
A generic hiring ad like «پرستار درمانگاه» was published as a dated SHIFT with an invented date
(«فردا») and default hours («۰۸:۰۰–۱۴:۰۰») the source never stated — because classification defaulted
to Shift. Now a dated Shift is only produced when the text carries an explicit shift signal
(شیفت/آنکال/کشیک/نوبت); everything else is an ongoing hiring post → Job (no date to invent). Fixed in
both the parser default and the Publish branch (so an AI mislabel can''t force a shift either).

ReclassifyMisclassifiedShiftsAsync (in the post-ingest auto-cleanup) converts the existing signal-less
aggregated shifts into jobs in place — copies the content to a JobOpening and archives the old shift
(its URL 410s). After one pass it''s a no-op since new ads no longer become shifts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:08:47 +03:30
soroush.asadi b3e7123d74 Extract Iranian salary shorthand (X تومان = millions) + pay backfill
CI/CD / CI · dotnet build (push) Successful in 2m15s
CI/CD / Deploy · hamkadr (push) Successful in 1m58s
Parser: most jobs read «توافقی» because the amount extractor only saw 6–10 digit numbers, missing
the way Iranian ads actually state pay — «۱۵ تومان»، «۴۰ تا ۵۰ تومان»، «۲۰ میلیون»، «۲۰م» all mean
MILLIONS of toman. Add colloquial detection (1–3 digit number + تومان/م/میلیون → ×1,000,000, lower
bound of a range), guarded so it never matches dates/hours or a long literal-toman figure. Also: a
stated amount now wins over «توافقی» (ads often say a number AND «… بقیه توافقی»).

Backfill: BackfillPayAsync re-parses existing aggregated jobs/talent that have no salary and fills
it in place (no AI, no ID/URL change) — wired into the post-ingest auto-cleanup and exposed as an
admin button. Existing «توافقی» listings with a stated number get their salary; genuinely-negotiable
ads stay توافقی. Also improves the baseSalary in JobPosting rich results.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:21:32 +03:30
soroush.asadi 219207ad68 SEO polish: facility structured data + trim homepage description
CI/CD / CI · dotnet build (push) Successful in 1m26s
CI/CD / Deploy · hamkadr (push) Successful in 1m57s
- Add SeoJsonLd.MedicalOrganization (Hospital/MedicalClinic schema with address, geo coords, and
  aggregateRating) and emit it on facility detail pages — only for real named facilities (not the
  «نامشخص» placeholder) — so Google can show a rich place result. Facility pages previously had no
  JSON-LD at all.
- Trim the homepage meta description from 193 to ~135 chars so Google doesn''t truncate it.

(Shift-detail canonical was a non-issue: the layout correctly omits canonical only on noindex pages,
which is what the audited past shift was.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:03:28 +03:30
soroush.asadi 410fc86c60 Fix maps not rendering: Neshan SDK URL was a 404
CI/CD / CI · dotnet build (push) Successful in 1m28s
CI/CD / Deploy · hamkadr (push) Successful in 1m24s
The map script loaded https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.js,
which Neshan has removed (returns 404) — so window.L never defined, the init bailed, and NO map
rendered anywhere (detail pages + the facility-register picker). Switch to Neshan''s current SDK
(.../1.4.0/leaflet.js + leaflet.css, both 200). The init API is unchanged (new L.Map with the
maptype option), so no other code changes needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:13:29 +03:30
soroush.asadi b223d3af2d Collapse the sprawling role taxonomy (dedupe/compound/typo merge)
CI/CD / CI · dotnet build (push) Successful in 2m46s
CI/CD / Deploy · hamkadr (push) Successful in 2m5s
The dynamic taxonomy minted ~150 roles incl. exact triplicates («پرستار کودک» x3), multi-role
compounds («پرستار و بهیار»، «ماما / پرستار»، «پزشک و پرستار و بهیار»), and typos («بیهیار»، «بیار»).

Creation hardening: ResolveOrCreateRole now collapses a compound to its FIRST base role when that
segment is a known role (so «پرستار و بهیار»→«پرستار», but specialty names like «قلب و عروق»/«پوست
و مو» are left whole), and new aliases fold typos/synonyms (بیهیار/بیار→بهیار، فیزیوتراپ→فیزیوتراپیست،
نسخه پیچ→تکنسین داروخانه، پرستار بچه/اطفال→پرستار کودک).

Cleanup: MergeDuplicateRolesAsync (+ admin button) maps every role to a canonical form and merges
same-canonical roles into one keeper, repointing all shifts/jobs/talent/preferences/alerts/profiles
first (mirrors the manual /Admin/Roles merge). Combined with the no-fan-out change this should cut
the dropdown to a clean base set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:35:43 +03:30
soroush.asadi 2b7ac96472 Fix cramped job cards on facility detail page
CI/CD / CI · dotnet build (push) Successful in 1m33s
CI/CD / Deploy · hamkadr (push) Successful in 1m20s
The facility detail page used .layout-2 (sidebar-first, 270px + 1fr), but its MAIN content (the
shift/job cards) is the first child — so it was forced into the 270px column while the facility-info
sidebar took the wide 1fr, squeezing job cards into a one-word-per-line strip. Switch to
.detail-grid (content 1fr first, sidebar 340px second), matching the shift/job detail pages, so the
cards get the wide column. Became visible once facilities started carrying many openings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:32:16 +03:30
soroush.asadi 0334cac3dc Make source-link listings reachable + skip uncontactable applicants
CI/CD / CI · dotnet build (push) Successful in 1m33s
CI/CD / Deploy · hamkadr (push) Has been cancelled
(1) The contact modal only offered a click-through link for Divar sources, so medboom/
iranestekhdam/channel listings with no inline phone looked uncontactable. Offer the source link
for ANY source («مشاهده آگهی در منبع»), for talent, shifts, and jobs alike — rescuing the dead
applicant cards that actually have a source URL.
(2) At publish, skip an applicant («آماده به کار») that has NO contact path at all — no phone, no
contact channel, and no source URL. Such a card cannot reach anyone. Existing ones drop out when
the talent reprocess button rebuilds the board.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:28:04 +03:30
soroush.asadi 98fc01be8e Reject filler/verb words as applicant names
CI/CD / CI · dotnet build (push) Successful in 1m42s
CI/CD / Deploy · hamkadr (push) Successful in 2m25s
The person-name extractor was grabbing the word after a title even when it was a verb/filler/
availability/role word, producing garbage headings like «خانم هستم»، «دکتر ام»، «دکتر داروساز
آماده». Stop collecting at a NameNoise word (هستم/ام/آماده/جویای/role words…), so a real name
(«دکتر سپیده علیزاده») still works but these fall back to the role heading. New ingests only;
existing rebuild via the talent reprocess button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:20:07 +03:30
soroush.asadi 33450a37ea Filter out home childcare / babysitter ads (not کادر درمان)
CI/CD / CI · dotnet build (push) Successful in 2m0s
CI/CD / Deploy · hamkadr (push) Successful in 3m10s
Divar «پرستار کودک/خانم شبانه‌روزی» ads are often a family hiring an in-home babysitter («پدر
کودک ۴ ساله هستم … نگهداری و مراقبت تمام‌وقت»), not clinical nursing. Add ChildcareMarkers
(نگهداری/بچه‌داری/«پدر|مادر کودک»/پرستار بچه …) and discard such ads as out of scope, alongside the
existing housekeeping filter. Clinical pediatric roles («بخش اطفال/کودکان/NICU») are unaffected.
New ingests are filtered at crawl; run «بایگانیِ درجا» to re-screen existing rows that have the
full text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:04:57 +03:30
soroush.asadi 17da713a35 Stop job/shift role fan-out: one aggregated ad = one listing
CI/CD / CI · dotnet build (push) Successful in 40s
CI/CD / Deploy · hamkadr (push) Successful in 2m18s
A single ad naming several role-ish words («استخدام بهیار جهت دستیار پزشک و تزریقات») was
fanning out into one listing PER extracted role — 5 near-duplicate cards with different and even
typo roles (پزشک عمومی، پرستار، دستیار پزشک، بهیار، «بیهیار»). Publish now creates ONE listing
with the primary (guard-corrected) role; other role words stay findable via the full description.
DedupeJobsAsync no longer keys on role, so existing fan-out copies collapse — preferring to keep a
non-«پزشک عمومی» copy, then the newest. Run the «حذفِ تکراری» + «اصلاح نقش» buttons to clean the
already-published fan-out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:47:19 +03:30
soroush.asadi 92802d0da0 Show a Persian added-X-ago timestamp on listing cards
CI/CD / CI · dotnet build (push) Successful in 46s
CI/CD / Deploy · hamkadr (push) Successful in 58s
Add JalaliDate.TimeAgo(utc) returning «همین حالا»/«۲ ساعت پیش»/«۳ روز پیش»/«۲ هفته پیش»/«۴ ماه
پیش»/«۱ سال پیش», and display it (🕒) on the talent, job, and shift cards from their CreatedAt so
users can see how recent each listing is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:35:30 +03:30
soroush.asadi c778b87e79 Capture the full Divar ad description, not just the search-row summary
CI/CD / CI · dotnet build (push) Successful in 1m28s
CI/CD / Deploy · hamkadr (push) Successful in 2m22s
Divar listings showed only a one-line summary («پرستار کودک ۳ روز … — پرداخت توافقی — … در
شادمان») because the scraper stored the search-result row text and only pulled phone + coords from
the post detail. Now FetchDetailAsync also extracts the full ad body (the longest free-text string
in the detail JSON, skipping Divar safety boilerplate that mentions «دیوار») and appends it, so the
listing carries the rich description users see on Divar. Applies to new crawls; existing rows keep
their short text until re-ingested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:04:30 +03:30
soroush.asadi b1d0d0d4fd Fix empty hrefs on nav and homepage «مشاهده همه» links
CI/CD / CI · dotnet build (push) Successful in 1m49s
CI/CD / Deploy · hamkadr (push) Successful in 3m40s
The SEO routes added a required slug («شیفت/{roleSlug}»), which made asp-page=/Shifts/Index
and /Jobs/Index generate an EMPTY href whenever no slug was supplied — so the nav «شیفت‌ها/
استخدام» and the homepage «مشاهده همه» links did nothing (Talent, which has no custom route, worked).
Fix: make the slug optional ({roleSlug?}) so URL generation succeeds, and point the nav + homepage
view-all links at the plain /Shifts and /Jobs routes as a guaranteed fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:54:59 +03:30
soroush.asadi cdb58eeb86 Paginate the admin review queue (and flagged list)
CI/CD / CI · dotnet build (push) Successful in 1m59s
CI/CD / Deploy · hamkadr (push) Successful in 3m3s
The «صف بررسی» loaded every New/Flagged RawListing at once — endless scroll once a crawl fills
it. Page both at 20/row with «قبلی/بعدی» controls (independent q & f query params); the header
now shows the true totals, not the page size.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:42:36 +03:30
soroush.asadi 7bbb4e385e Add in-place role-fix for existing «پزشک عمومی»-mislabeled listings
CI/CD / CI · dotnet build (push) Successful in 45s
CI/CD / Deploy · hamkadr (push) Successful in 2m5s
RecorrectDoctorRolesAsync (+ admin button «اصلاح نقش»): re-runs the keyword parser + doctor-role
guard over the stored text of existing aggregated listings currently labeled «پزشک عمومی», and
corrects RoleId + the generic title in place when the text actually names a more specific role
(dentist, «متخصص», lab, …). No AI call, no delete/recreate — IDs and indexed URLs unchanged, only
GP-labeled rows touched. Cleans up the dentist/ENT/«متخصص غدد» mislabels already published.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:06:22 +03:30
soroush.asadi fbf8deaa8c Generalize doctor-role correction: trust the keyword parser over the AI default
CI/CD / CI · dotnet build (push) Successful in 53s
CI/CD / Deploy · hamkadr (push) Successful in 2m39s
Replace the per-role (dentist/specialist) patches with one rule: «پزشک عمومی» is the AI fallback,
so whenever the keyword parser already extracted a more specific role from the same text, use that
(dentist, lab, OR tech, mislabeled nurse, …). Falls back to «پزشک متخصص» when the text says
specialist but the parser found nothing more specific. Only ever overrides the weak GP default, so
genuine GP ads are untouched. Applies to new ingests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:01:58 +03:30
soroush.asadi d39546389e Correct dentist ads the AI labeled as general physician
CI/CD / CI · dotnet build (push) Successful in 1m57s
CI/CD / Deploy · hamkadr (push) Has been cancelled
Extends the doctor-role guard: the AI defaults unclear (and even clearly non-GP) doctor ads to
«پزشک عمومی», so a dentist ad («دعوت به همکاری دندانپزشک») published as «استخدام پزشک عمومی».
When the chosen role is a generic doctor but the ad text says «دندانپزشک», correct it to
دندانپزشک (specialist correction stays for «متخصص/فوق تخصص/فلوشیپ»). Applies to new ingests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:59:24 +03:30
soroush.asadi 5c04658faf Unify recommendations across shifts AND jobs
CI/CD / CI · dotnet build (push) Successful in 38s
CI/CD / Deploy · hamkadr (push) Successful in 3m35s
Recommendations only scored open shifts, but almost all roles — doctors especially — exist as
استخدام (jobs), not dated shifts, and the only shifts are a handful of nurse shifts. So a visitor
who prefers «پزشک» got nurse-shift recommendations (scored by city/freshness) because there were
no doctor shifts to surface.

Now the engine scores BOTH shifts and job openings: role/city/facility/pay/freshness apply to
each, behavioral affinities are derived from shift AND job interest events, and the merged top-N
is returned. Recommendation can now carry a Shift or a JobOpening; the card renders either
(job → /Jobs/Details with employment type + salary; shift → unchanged with hour-bar). Cold start
interleaves the freshest of both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:47:15 +03:30
soroush.asadi 845d0c9013 Show job counts, not shifts-only, on public pages
CI/CD / CI · dotnet build (push) Successful in 1m55s
CI/CD / Deploy · hamkadr (push) Successful in 1m18s
The platform has ~1600 open استخدام but only ~4 dated شیفت (the VPN-free sources are hiring
boards, not shift channels), so the shifts-only counters read misleadingly low:
- Homepage stat pill «۴ شیفت باز» -> «موقعیت استخدام» (open job count).
- Facility cards «۰ شیفت باز» -> «N آگهی فعال» = open shifts + open (fresh) jobs, so a facility
  that is hiring no longer reads zero.
Also hide the «نامشخص / ثبت نشده» placeholder from the facilities list and sort active
facilities (then verified, then name) first, so real hiring centers surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:21:50 +03:30
soroush.asadi 3e65c88765 Strip generic facility descriptors so distinctive names dont false-merge
CI/CD / CI · dotnet build (push) Successful in 49s
CI/CD / Deploy · hamkadr (push) Successful in 1m1s
FacilityMatcher treated «شبانه روزی»/«خیریه»/«دولتی»/«خصوصی» as part of a name, so a real
facility merged into a generic one when they shared a descriptor — «درمانگاه شبانه‌روزی اسفند»
collapsed into the existing «پلی کلینیک شبانه روزی», losing «اسفند». Add these descriptors to
the stripped type-words so matching compares the distinctive core («اسفند») instead. Side
benefit: bare descriptor-only names («پلی کلینیک شبانه روزی») now resolve to junk and get
folded into the placeholder by the cleanup, rather than masquerading as a real facility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:00:00 +03:30
soroush.asadi 1c580e0f7a Fix role + contact mislabels seen on a live iranestekhdam ad
CI/CD / CI · dotnet build (push) Successful in 33s
CI/CD / Deploy · hamkadr (push) Successful in 44s
(1) Specialist guard: the AI sometimes labels a clearly-specialist ad («پزشک متخصص گوش و
حلق و بینی»، «فلوشیپ»، «فوق تخصص») as «پزشک عمومی», so an ENT post published as
«استخدام پزشک عمومی». When the primary role is GP but the ad text names a specialist, swap
it to «پزشک متخصص» (the subspecialty stays as a tag).

(2) Phone type: the landline regex 0\d{2,3} also matched 09xx MOBILE numbers and labeled them
«تلفن ثابت». Iranian landline area codes are 0[1-8]xx (021/026/…), never 09 — restrict it so
mobiles are no longer mislabeled as landlines.

Both apply to new ingests; existing mislabeled rows correct on turnover/reprocess.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:29:43 +03:30
soroush.asadi b48e7dbc65 Auto-clean the board after every crawl (no manual cleanup clicks)
CI/CD / CI · dotnet build (push) Successful in 2m34s
CI/CD / Deploy · hamkadr (push) Successful in 2m4s
RunAsync now calls a new RunPostIngestCleanupAsync at the end of each crawl: archive
out-of-scope/duplicate listings, merge duplicate + fold junk facilities, and backfill missing
Tehran coords. All in-place, reversible for listings, guarded for facilities, and pure DB+CPU
(no AI/network) so it is cheap to run every ingest. The cleanup counts are appended to the
run-log detail. This keeps legacy + freshly-arrived junk from accumulating without the admin
having to click the cleanup buttons after each run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 13:19:11 +03:30
soroush.asadi bb8c6c3be5 Add medboom.ir as an ingestion source (doctor/dentist-heavy, VPN-free)
CI/CD / CI · dotnet build (push) Successful in 31s
CI/CD / Deploy · hamkadr (push) Successful in 3m15s
New MedboomListingSource: a WordPress medical-classifieds board crawled like medjobs
(wp-sitemap.xml -> posts-post-N.xml, newest first), filtered to clinical-role slugs and
Tehran-only for launch. medboom skews toward doctors/dentists/pharmacists and carries both
hiring and availability posts, so it directly broadens the role mix the nurse-heavy Divar
content lacks. Iranian-hosted -> no proxy/VPN needed (relevant now that Telegram is off).

Wired like the other sources: AppSetting toggles (MedboomEnabled/MaxAds/UseProxy) + EF
migration, SettingsService persistence, admin Settings UI, DI registration. Off by default.
Validated against live data: Tehran clinical ads at named clinics (pharmacy/dental/etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:18:56 +03:30
soroush.asadi 7740d9f8d7 iranestekhdam: restrict to Tehran for launch
CI/CD / CI · dotnet build (push) Successful in 2m0s
CI/CD / Deploy · hamkadr (push) Successful in 1m7s
Keep only ads located in Tehran: pre-drop slugs naming other major cities to save fetches,
then authoritatively keep ads whose text states «تهران» (the og:description reliably says
«شهر تهران»). Pool 5x candidates so the Tehran filter still yields a full batch. Validated
against live data: ~16/18 clinical candidates are Tehran. Nationwide expansion later becomes
a per-source city setting once the engine is proven.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 09:56:25 +03:30
soroush.asadi f118db55ef Add iranestekhdam.ir as an ingestion source (clinical job ads at named facilities)
CI/CD / CI · dotnet build (push) Successful in 1m43s
CI/CD / Deploy · hamkadr (push) Successful in 1m55s
New IranEstekhdamListingSource: reads the site monthly ad sitemaps
(sitemap-ads.xml -> sitemap-ads-YYYY-M.xml), keeps only ad URLs whose Persian slug names a
clinical role (veterinary/non-clinical excluded), then extracts each ad title + description
(+ phone). These are employer ads at NAMED facilities, so they directly improve the
unknown-facility problem the classifieds content has.

Wired in like Medjobs: AppSetting toggles (IranEstekhdamEnabled/MaxAds/UseProxy) + EF
migration, SettingsService persistence, admin Settings UI, and DI registration. Off by
default; the medical-gate validator + AI auditor + junk filters screen results downstream.

Note: e-estekhdam / jobinja / jobvision are JS-rendered SPAs whose ad lists are not in static
HTML, so they need API reverse-engineering (a separate effort), not this static-scrape path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:39:39 +03:30
soroush.asadi da55f82c6c Fix facility junk-fold: match the real placeholder by «نامشخص» marker
CI/CD / CI · dotnet build (push) Successful in 30s
CI/CD / Deploy · hamkadr (push) Successful in 1m0s
The junk-removal half of the facility cleanup silently no-op'd because it looked up the
shared placeholder by the exact UnknownFacilityName constant («نامشخص / ثبت نشده»), but
production data uses an older wording («مرکز درمانی (نامشخص)»), so the lookup returned null
and the whole junk pass was skipped (only the duplicate-merge half ran).

Now resolve the placeholder by the «نامشخص» marker and pick the bucket with the most
listings (the real one), and exclude it from the merge pass by id. Re-running the cleanup
will fold «بیمارستان هستم», «... از مدجابز», bare type-word facilities, etc. into it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 07:17:24 +03:30
soroush.asadi 88eca92333 Facility data hygiene: merge duplicates, drop junk-named facilities
CI/CD / CI · dotnet build (push) Successful in 1m51s
CI/CD / Deploy · hamkadr (push) Successful in 2m17s
Cleans up the crawl-generated facility table that surfaced garbage on /Facilities
(«بیمارستان هستم», «... از مدجابز», bare «کلینیک», «سازمان برنامه جنوبی» x3):

- FacilityMatcher.IsJunkName: shared detector for non-names — bare type words, cores
  made only of filler/verb tokens, and leaked crawl-source/placeholder text. Added
  داروخانه/آسایشگاه to the generic type words so bare ones are caught and dedupe better.
- HeuristicListingParser.ExtractFacilityName now rejects junk candidates (and emoji), so
  new ingests fall back to the shared placeholder instead of forging a fake facility.
- IngestionService.MergeAndCleanFacilitiesAsync (+ admin button): folds junk facilities
  into the placeholder and merges Persian-fuzzy duplicates into one keeper, repointing
  their shifts/jobs first. Hard guard: only purely crawl-generated, unmanaged facilities
  are removed — employer-owned and verified facilities are never touched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:40:29 +03:30
soroush.asadi 8be275596b Make the listing purge SEO-standard: archive (not delete) + 410 Gone
CI/CD / CI · dotnet build (push) Successful in 49s
CI/CD / Deploy · hamkadr (push) Successful in 2m13s
Per the project archive-not-delete convention, the in-place purge now sets out-of-scope
and duplicate aggregated jobs/shifts to ShiftStatus.Archived instead of hard-deleting:
- The row is retained for analysis and the change is reversible.
- The listing drops out of every public screen and the sitemap (which filter Status == Open).
- Its detail page now returns 410 Gone (the standard permanent-removal signal) so search
  engines deindex it cleanly, instead of leaving the off-topic page live at 200 or hard-404ing.
Dedupe of job reposts archives the older copies the same way. Coordinate backfill now also
skips non-Open rows. Valid listings are untouched, so IDs/URLs stay stable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:25:51 +03:30
soroush.asadi e2011d335e Ingestion data-quality + map fixes: AI salary, geocode coverage, in-place backfill & purge
CI/CD / CI · dotnet build (push) Successful in 30s
CI/CD / Deploy · hamkadr (push) Successful in 1m11s
- Jobs now keep the AI-extracted salary (d.PayAmount ?? parsed.PayAmount); they
  previously used only the parser figure, so every aggregated opening showed «توافقی».
- Geocoder also scans the ad body, so Tehran ads that name a neighbourhood only in
  free text («… در سهروردی») get an approximate map point.
- New BackfillCoordsAsync (+ admin button): fills missing coords on existing aggregated
  listings from their stored text, in place — no ID/URL churn, SEO-safe.
- New PurgeInvalidAggregatedAsync + DedupeJobsAsync (+ admin button): in-place removal of
  out-of-scope (domestic/promo/spam) aggregated jobs/shifts and duplicate job reposts,
  keeping valid listings' IDs.
- Jobs detail page always renders the location card (matches Shifts) instead of hiding it
  when coords are missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:09:39 +03:30
soroush.asadi a16a805869 Hide facility/location when it's the «نامشخص» placeholder — omit, don't print it
CI/CD / CI · dotnet build (push) Successful in 1m6s
CI/CD / Deploy · hamkadr (push) Successful in 1m33s
When a listing's facility is the unknown placeholder, don't show «مرکز درمانی
(نامشخص)» anywhere — just leave the location out. Gated on HasRealEmployer:
- cards (shift/job/recommendation): the 🏥 facility line is omitted
- shift detail: H1 drops the «— نامشخص» suffix; title/description use city only;
  «شیفت‌های دیگر این مرکز» hidden; report label generic
- job detail: subtitle drops 🏥, keeps 📍 city; title/description city-only

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:57:36 +03:30
soroush.asadi baa617daa9 Strip «آماده به کار» from role names + reject domestic-helper ads
CI/CD / CI · dotnet build (push) Successful in 2m3s
CI/CD / Deploy · hamkadr (push) Successful in 3m14s
Re-check of live applicants found two gaps:
- «کمک بهیار آماده به کار» — the availability phrase glued onto the role. StripRoleModifiers
  now removes «آماده به کار / آماده همکاری / جویای کار / جهت همکاری» phrases before
  token-stripping, so the role collapses to «کمک بهیار».
- «خانم امورسبک منزل» — light-housework domestic helpers (not کادر درمان). Validator
  now discards ads with «امور منزل / نظافت منزل / خدمتکار / مستخدم …» markers.

Both take effect for existing data on the next applicant reprocess.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:58:06 +03:30
soroush.asadi 7e17e7ccb3 Stop leaking the shared placeholder facility's phone onto unrelated shifts/jobs
CI/CD / CI · dotnet build (push) Successful in 1m32s
CI/CD / Deploy · hamkadr (push) Successful in 1m21s
Shift/Job 426-style pages showed 09910540686 — the «نامشخص / ثبت نشده» placeholder
facility's phone, set once and shown on every unnamed-facility listing (and in the
contact modal), even though it isn't that ad's number. Now the facility phone/Bale
is only used as a fallback when the facility is a REAL named employer
(SeoJsonLd.HasRealEmployer); otherwise fall back to the Divar source link (if any)
or «شماره ثبت نشده». Fixed in the /contact modal endpoint and both detail-page
inline reveals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:50:12 +03:30
soroush.asadi f1a00cb955 Remove the call CTA from listing cards — contact only on the detail page
CI/CD / CI · dotnet build (push) Successful in 2m3s
CI/CD / Deploy · hamkadr (push) Successful in 46s
Cards had a 📞 contact-trigger that opened the call modal straight from the list.
Per request, calling should happen only on the post's detail page. Reverted each
card's CTA to a plain «جزئیات»/«مشاهده و تماس» button that just navigates to the
detail page (the whole card is already a link to it); the contact modal/trigger
now lives only on the shift/job/talent detail pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:44:07 +03:30
soroush.asadi cdca4ad264 Admin: role merge tool + usage list (taxonomy hygiene)
CI/CD / CI · dotnet build (push) Successful in 2m38s
CI/CD / Deploy · hamkadr (push) Successful in 2m7s
New /Admin/Roles screen lists every role with its shift/job/talent usage and lets
an admin merge a duplicate role into another — reassigns all listings (the Restrict
FKs) plus preferences/alerts/profiles to the target, then deletes the source — or
toggle a role's visibility. Linked from the admin panel nav (🏷️ نقش‌ها). Lets you
clean up dynamic-ingestion sprawl («کمک‌یار»→«کمک بهیار») without DB surgery.

Improvement 7 of the backlog (data).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:21:23 +03:30
soroush.asadi 5e1b2ee979 ItemList JSON-LD on Jobs/Shifts list & landing pages
CI/CD / CI · dotnet build (push) Successful in 2m43s
CI/CD / Deploy · hamkadr (push) Successful in 1m24s
Mark up the result list as a schema.org ItemList (ordered listing URLs) so Google
reads the landing/list pages as a curated collection. Emitted alongside the
breadcrumb JSON-LD when there are results.

Improvement 6 of the backlog (SEO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:15:12 +03:30
soroush.asadi 3edd21d2b6 Breadcrumbs: visible trail + BreadcrumbList JSON-LD
CI/CD / CI · dotnet build (push) Successful in 2m8s
CI/CD / Deploy · hamkadr (push) Has been cancelled
Add SeoJsonLd.Breadcrumb + Crumb record + _Breadcrumbs partial, and wire a trail
into the Jobs/Shifts list (landing) and detail pages: خانه › استخدام/شیفت › {نقش}
› {شهر|عنوان}. The role crumb links to the role landing page (more internal
links), and Google can show the breadcrumb path in results. Detail pages emit it
alongside the existing JobPosting JSON-LD.

Improvement 5 of the backlog (SEO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:12:38 +03:30
soroush.asadi 142136ebc9 Landing pages: unique intro paragraph (avoid thin content)
CI/CD / CI · dotnet build (push) Successful in 2m0s
CI/CD / Deploy · hamkadr (push) Successful in 2m33s
Role/city landing pages were heading + list only — thin-content risk that hurts
ranking. Add a short, unique-per-page intro (built from the dynamic heading) on
the Jobs/Shifts landing pages, with internal-link guidance. Generic /Jobs and
/Shifts stay as-is.

Improvement 4 of the backlog (SEO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:07:32 +03:30
soroush.asadi 9bc3fdec79 Google for Jobs: only emit JobPosting JSON-LD for a real named employer
CI/CD / CI · dotnet build (push) Successful in 43s
CI/CD / Deploy · hamkadr (push) Successful in 1m16s
JobPosting requires a valid hiringOrganization; emitting «نامشخص / ثبت نشده» (the
placeholder for aggregated ads with no named center) makes Google reject the
posting and can flag invalid structured data across the site. Add
SeoJsonLd.HasRealEmployer and gate the JobPosting/ShiftPosting <script> on it, so
only listings with a genuine employer get marked up (those are the Jobs-eligible
ones anyway).

Improvement 3 of the backlog (SEO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 18:03:14 +03:30
soroush.asadi a432fce858 Internal links to SEO landing pages (role quick-links on list pages)
CI/CD / CI · dotnet build (push) Successful in 46s
CI/CD / Deploy · hamkadr (push) Successful in 56s
The /استخدام/{role}/{city} and /شیفت/{role} landing pages were only reachable via
the sitemap — no internal links, which is weak for ranking. Add a role quick-link
chip strip to the Jobs and Shifts list pages linking to the per-role landing URLs.
Since those list pages ARE the landing pages, this also cross-links every landing
page to all the others, building an internal-link mesh that passes authority and
aids crawl far more than the sitemap alone.

Improvement 2 of the backlog (SEO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:59:01 +03:30
soroush.asadi 8d0a403b36 Near-duplicate applicant detection (collapse source reposts)
CI/CD / CI · dotnet build (push) Successful in 1m57s
CI/CD / Deploy · hamkadr (push) Successful in 1m9s
Exact ContentHash dedup misses the same ad reposted with slightly different text
(e.g. the ~18 repeated «کمک‌یار آقا»). DedupeTalentAsync collapses open aggregated
applicants by two high-precision signals — identical phone, or identical
(role, city, normalized description core with digits/«… پیش» time-phrases
stripped) — keeping the newest of each group. Runs at the end of both RunAsync
and ReprocessAsync; removed count surfaces in the run log.

Improvement 1 of the data-quality/SEO backlog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:54:26 +03:30
soroush.asadi 21befd5b1e Display timestamps in Tehran time, not UTC
CI/CD / CI · dotnet build (push) Successful in 1m35s
CI/CD / Deploy · hamkadr (push) Successful in 3m1s
The server clock is correct (UTC); the app rendered UTC wall-clock directly, so
the run log showed ~3.5h behind Tehran. Add JalaliDate.ToTehran (flat UTC+3:30 —
Iran dropped DST in 2022) + DateTimeLabel, and convert the UTC-stored timestamp
displays (ingestion run log, RawListing FetchedAt, report CreatedAt). Shift
start/end inputs are TimeOnly, left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:16:57 +03:30
soroush.asadi fb7bfad9ce Reprocess: SEO-safe applicants-only default (don't churn indexed shift/job URLs)
CI/CD / CI · dotnet build (push) Successful in 2m11s
CI/CD / Deploy · hamkadr (push) Successful in 2m10s
Reprocess deletes+rebuilds aggregated listings, which changes their IDs. Shift/Job
detail pages are indexed and in the sitemap, so churning them would 404 ranked
URLs. «آماده به کار» pages are NoIndex + Disallow, so rebuilding them has zero SEO
impact — and that's where all the duplicate/sprawl problems were.

ReprocessAsync(talentOnly: true) now only deletes/rebuilds TalentListings and
skips non-talent raws (leaving shift/job listings + their RawListing links
untouched). Admin button relabelled «پردازش مجددِ آماده به کارها (امن برای SEO)».
Shifts/jobs self-clean via normal ingestion turnover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 16:08:20 +03:30
soroush.asadi e582597b20 Geocoding fallback: use the registered AI model when the table can't resolve
CI/CD / CI · dotnet build (push) Successful in 1m15s
CI/CD / Deploy · hamkadr (push) Successful in 1m1s
Where deterministic geocoding gives up (neighborhood not in the TehranGeo table),
fall back to the registered AI model: the auditor now also returns approximate
lat/lng for a recognized Tehran neighborhood (folded into the existing single
audit call — no extra requests), and Publish uses it only after the source ad and
the local table, and only when it falls inside greater Tehran (InTehran bbox
guard rejects hallucinated points). Coords order: Divar point → TehranGeo → AI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:48:42 +03:30
soroush.asadi 85a5191c45 AI qualify round 2: strip gender/seniority from roles, aide synonyms, more tag noise
CI/CD / CI · dotnet build (push) Successful in 1m17s
CI/CD / Deploy · hamkadr (push) Successful in 1m39s
Re-checked live data and found cases the first pass missed:
- Gender baked into roles («پرستار آقا», «کمک بهیار آقا») → StripRoleModifiers
  removes آقا/خانم/مرد/زن/کارآموز/ارشد… from role names (none of the real roles
  contain these), collapsing the sprawl; gender still lives on the Gender field.
- «کمک‌یار» vs «کمک بهیار» forking → alias maps them to one role.
- Personality words («خوش‌اخلاق», «دلسوز», «منظم»…) added to the tag stop-list.
- Prompt: gender goes to the gender field, not the role.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:41:06 +03:30