Commit Graph

138 Commits

Author SHA1 Message Date
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
soroush.asadi 993c34758f Geocode neighborhood names to an approximate location (no source coords)
CI/CD / CI · dotnet build (push) Successful in 2m4s
CI/CD / Deploy · hamkadr (push) Successful in 1m54s
Many Medjobs/Telegram ads name a Tehran neighborhood («ونک», «تهرانپارس»…) but
carry no coordinates. New TehranGeo geocoder maps ~45 neighborhood names to a
rough center; Publish falls back to it (from the resolved district / AI district
/ area note) when the source ad has no point. Shown via the existing «محدودهٔ
تقریبی» circle + disclaimer — never a precise pin. Tehran-only; extends the
existing approx-coords feature so non-Divar listings can show a map too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:31:27 +03:30
soroush.asadi 4ab6ce29c9 Approximate-location map on aggregated listings (Divar coords)
CI/CD / CI · dotnet build (push) Successful in 1m59s
CI/CD / Deploy · hamkadr (push) Successful in 1m49s
We captured Divar's privacy-fuzzed coords on RawListing but discarded them for
the listings that need them: unnamed-facility shifts/jobs dropped them (to avoid
piling on the shared placeholder) and applicants had no coordinate field at all.

- Add Lat/Lng to Shift, JobOpening, TalentListing (migration ListingApproxCoords).
- Publish stores the source ad's approx coords on each aggregated listing.
- Detail pages render the map from the listing's own coords (fallback: facility),
  and aggregated coords show as a shaded «محدودهٔ تقریبی» circle (not a precise
  pin) via _NeshanMap data-approx, with a disclaimer. Applicants get a map card
  (they had none) + the page now loads the Neshan key.

Only Divar provides coords; the map needs NeshanMapKey set in admin settings.
Existing rows get coords once reprocessed (RawListing already has them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:10:05 +03:30
soroush.asadi 704b68be16 Search typeahead: show total found count in the dropdown
CI/CD / CI · dotnet build (push) Successful in 33s
CI/CD / Deploy · hamkadr (push) Successful in 1m29s
The /search/suggest endpoint now returns { items, total } — each filtered query
is reused for both the Take(5) preview and a CountAsync — and the dropdown's
footer link reads «مشاهده همه N نتیجه برای «q»» (Persian digits) instead of a
bare «همه نتایج». The /Search page already showed counts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:30:08 +03:30
soroush.asadi d62929ca0d AI qualify: de-dupe applicants, base roles, closed categories, tag hygiene + reprocess-stored action
CI/CD / CI · dotnet build (push) Successful in 2m35s
CI/CD / Deploy · hamkadr (push) Successful in 1m23s
Qualified live applicants and found three problems, all fixed:
- Duplicate cards: one ad fanned out into «پرستار» + «پرستار کودک» (same person).
  Applicants now publish ONE listing (no role fan-out); secondary roles → tags.
- Role sprawl: modifiers became roles. Prompt now returns the BASE profession
  and pushes age-group/ward/seniority to tags; new roles only for a genuinely
  new base profession (تکنسین داروخانه ✓, پرستار کودک ✗).
- Tag/category noise: categories pinned to the 5 fixed groups (+سایر, never
  invented); BuildTags drops pay/contact/location/fragment words.

Reprocess action: IngestionService.ReprocessAsync re-runs the current pipeline
over every stored RawListing WITHOUT re-fetching (keeps the raw text, so nothing
is lost to sources only exposing recent posts), deleting the old aggregated
posts and republishing cleanly. Admin dashboard button «پردازش مجددِ آیتم‌های
ذخیره‌شده» runs it on a background scope; result lands in the run-log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:24:20 +03:30
soroush.asadi 4c0b29addf Contact reveal modal: click phone/contact on cards and detail pages
CI/CD / CI · dotnet build (push) Successful in 2m26s
CI/CD / Deploy · hamkadr (push) Successful in 58s
Adds a lazy-loaded contact modal. Any element with data-contact-type +
data-contact-id (the «📞 تماس» button on shift/job/talent/recommendation cards,
and the contact CTA on the three detail pages) opens a modal that fetches the
listing's numbers from a new GET /contact endpoint and renders them with click-
to-call links. Numbers are loaded only on click, so they never sit in list-page
HTML (privacy / anti-scrape). The endpoint logs the same Apply interest signal
for shift/job that the old inline-reveal POST did, and falls back to the
facility phone (or Divar source link for talent) when an ad has no own contacts.

Verified locally: GET /contact?type=shift&id=1 → {title, contacts:[{value:
'021-82032000', href:'tel:...'}]}, and the modal opens and renders on the shift
detail page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:04:08 +03:30
soroush.asadi 0cf5b30dd8 SEO landing pages: dynamic role+city titles, pretty URLs, sitemap combos
CI/CD / CI · dotnet build (push) Successful in 4m5s
CI/CD / Deploy · hamkadr (push) Successful in 3m36s
Google Search Console shows all top queries are «استخدام [نقش] [شهر]», but the
filtered index pages all shared the generic title «موقعیت‌های استخدامی» and
weren't in the sitemap, so nothing ranked for those exact searches.

- Jobs/Shifts/Talent index pages now set a dynamic <title>/<h1>/meta from the
  active role+city (e.g. «استخدام پزشک عمومی در تهران»).
- Pretty SEO routes /استخدام/{role}/{city?} and /شیفت/{role}/{city?} (via
  AddPageRoute) resolve slugs → filters; unknown slug → 404. The layout already
  derives the canonical from the path, so each pretty URL is its own canonical
  and the query-string forms canonicalize to /Jobs (no duplicate content).
- sitemap.xml now lists role-only and role×city landing URLs for every combo
  with live listings (URL-encoded), so Google discovers them.
- New SeoSlug helper (Persian-tolerant: ي/ك, ZWNJ, hyphen/space).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:03:57 +03:30
soroush.asadi 38031cb189 Per-ad contacts for shifts/jobs, stale-applicant filter, review source link
CI/CD / CI · dotnet build (push) Successful in 1m3s
CI/CD / Deploy · hamkadr (push) Successful in 1m18s
Phone fix: shifts/jobs showed Facility.Phone, but unnamed ads all share one
placeholder facility, so every such listing displayed the same stale number
while the ad's real phone sat unused in the description. ContactMethod is now
attachable to a Shift/JobOpening (not just talent); ingestion stores the ad's
own number(s) on each listing and the detail pages render them (new
_ContactList partial), falling back to the facility phone only when the ad had
none. Migration ShiftJobContacts (nullable owner FKs) — auto-applies on deploy.

Stale applicants: skip «آماده به کار» posts older than 7 days at ingest, by the
source's real timestamp (Telegram <time>, Bale date) or a Persian time-ago
phrase in the text (Divar «۲ هفته پیش»). Recorded as Discarded; shifts/jobs
are not aged out.

Admin: Review page now shows a «مشاهده آگهی در منبع» link (RawListing.SourceUrl)
so the source post can be checked before publishing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:28:12 +03:30
soroush.asadi b71d8b362b Recommendation card: lead with the role, not the facility name
CI/CD / CI · dotnet build (push) Successful in 47s
CI/CD / Deploy · hamkadr (push) Successful in 1m11s
Same inversion as the shift card — the «پیشنهادهای ویژه شما» box headlined
Facility.Name («مرکز درمانی (نامشخص)»). Role is now the headline; facility
moves to the second line with 🏥 alongside the city.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:52:40 +03:30
soroush.asadi 337b510540 Shift card: lead with the role, not the facility name
CI/CD / CI · dotnet build (push) Successful in 6m7s
CI/CD / Deploy · hamkadr (push) Successful in 1m8s
The headline showed Facility.Name (often «مرکز درمانی (نامشخص)» for ingested
shifts) while the actual role was a tiny badge. Match _JobCard/_TalentCard:
role becomes the headline; facility moves to the second line with 🏥.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:10:33 +03:30
soroush.asadi efbf998caf Admin/Ingested: per-source breakdown (published vs total crawled)
CI/CD / CI · dotnet build (push) Successful in 2m42s
CI/CD / Deploy · hamkadr (push) Successful in 2m39s
Group RawListings by SourceChannel, fold per-channel/per-host labels into
source families (تلگرام/x → تلگرام, وب‌سایت (host) → وب‌سایت), and show a
published-vs-total table so it's clear which sources are actually producing
(e.g. why everything is coming from دیوار when Telegram's proxy is down).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 08:46:01 +03:30
soroush.asadi a03dcb1157 Divar geo-coords to facility map + medical gate + RawListing FK/geo migrations 2
CI/CD / CI · dotnet build (push) Successful in 1m15s
CI/CD / Deploy · hamkadr (push) Successful in 3m59s
2026-06-09 22:01:04 +03:30
soroush.asadi 380243b669 Divar geo-coords to facility map + medical gate + RawListing FK/geo migrations
CI/CD / CI · dotnet build (push) Successful in 2m6s
CI/CD / Deploy · hamkadr (push) Successful in 2m3s
2026-06-09 21:38:55 +03:30
soroush.asadi cf5e0011c4 AI ingestion: dynamic role/category creation + tags, hardcoded read-only prompt
CI/CD / CI · dotnet build (push) Successful in 2m19s
CI/CD / Deploy · hamkadr (push) Successful in 2m12s
- Unknown roles from the AI are now resolved-or-CREATED (Persian-normalized dedupe) instead of dropped/fallback; new role gets the AI's category, assigned to the applicant.
- AI output gains category + tags; AI-detected skills/requirements (ICU, MMT, پروانه‌دار…) now fold into the applicant's searchable Tags.
- System prompt is hardcoded in AppSetting.DefaultPrompt and used directly by the auditor; admin sees it read-only (cannot edit/break it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:04:24 +03:30
soroush.asadi 59fb30ac77 AI auditor: surface the real connection error instead of swallowing it
The Test-AI button called AuditAsync, which caught every exception and returned
null, and used EnsureSuccessStatusCode() (discarding the response body). So a
failing AI service only ever produced a generic 'no response' message with no
detail — impossible to diagnose.

- Add IAiAuditor.TestAsync: runs the real call and returns a detailed Persian
  diagnostic — HTTP status + response body on non-2xx, raw body when the shape
  isn't OpenAI-compatible, and network/proxy/timeout specifics on exceptions.
- AuditAsync now logs the actual HTTP status + response body (and proxy state)
  instead of a bare warning, so server logs show why a call failed.
- ExtractContent / ParseVerdict no longer throw on unexpected JSON; they return
  null so the caller can show the raw body.
- Settings 'Test AI' button uses TestAsync; result box renders multi-line and
  switches to alert-error styling when the test fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:30:12 +03:30
soroush.asadi 753a14286f Mobile hero search: compact magnify button + dropdown under the input
CI/CD / CI · dotnet build (push) Successful in 4m17s
CI/CD / Deploy · hamkadr (push) Successful in 4m30s
- Submit button is now a 44x44 magnify icon inside the search pill on mobile instead of a full-width stacked button (desktop keeps the جستجو text).
- Anchor the typeahead dropdown to the search pill so results appear directly under the input rather than below the popular-search chips; full pill width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 07:39:23 +03:30
soroush.asadi 62e9bf1353 Nav: replace inline search box with a «🔎 جستجو» link to /Search
CI/CD / CI · dotnet build (push) Successful in 2m39s
CI/CD / Deploy · hamkadr (push) Successful in 2m36s
The hero is now the primary search; the navbar just links to the search
page (cleaner header, less clutter on mobile). Typeahead remains on the
hero (form[data-suggest]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:26:37 +03:30
soroush.asadi c92744fb50 Mobile: smaller hero/heading typography so titles aren't oversized
Reduce hero h1/p, page/section headings, stat pills and the hero search
font sizes on phones (<=560px); tighter hero padding. Desktop unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:21:32 +03:30
soroush.asadi 69e2a12a3a Home hero search: polish mobile layout (stacked bordered input + full-width button)
CI/CD / CI · dotnet build (push) Successful in 3m4s
CI/CD / Deploy · hamkadr (push) Successful in 2m26s
On small screens the pill now stacks cleanly: a bordered, padded input above
a full-width جستجو button; icon hidden; chips centered. Shorter placeholder so
it never overflows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 23:14:25 +03:30
soroush.asadi bcf90f2437 Home hero: replace filter dropdowns with a search-engine box (+ live typeahead)
CI/CD / CI · dotnet build (push) Successful in 2m32s
CI/CD / Deploy · hamkadr (push) Successful in 2m0s
The hero is now a single big search box → /Search (the rich, ranked,
highlighted search across shifts/jobs/applicants), with popular-search
chips. Typeahead is generalized to any form[data-suggest], so the hero box
shows the same instant highlighted dropdown as the header pill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:34:31 +03:30
soroush.asadi 6cf7c6b573 Typeahead: search descriptions + show highlighted body snippet (fixes empty mmt dropdown)
CI/CD / CI · dotnet build (push) Successful in 2m6s
CI/CD / Deploy · hamkadr (push) Successful in 2m34s
The suggest endpoint only matched role/city/tags/facility, so a term that
lives only in the ad body (e.g. mmt) returned nothing and the dropdown
never opened — even though /Search found it. Now each type also ILIKEs the
description, and the dropdown's sub-line is a snippet windowed around the
match (client highlights it). Title is bold; body wraps to 2 lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:06:15 +03:30
soroush.asadi 1e96526bd9 Review/publish: multi-select roles → one listing per role
An ad can cover several roles (e.g. «پرستار سالمند و کودک و همراه بیمار»).
The role dropdown is now a checkbox multi-select; on publish we fan out and
create one Shift/Job/Talent per selected role (mirrors the auto-ingest
fan-out). Jobs get a per-role title when multiple are chosen; talent
listings each get their own contact rows; all created items notify matches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 22:03:09 +03:30
soroush.asadi 5e5d7f80ef Admin queue: show fetched time (HH:mm) alongside date on review + ingested rows
CI/CD / CI · dotnet build (push) Successful in 2m20s
CI/CD / Deploy · hamkadr (push) Successful in 2m3s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:55:03 +03:30
soroush.asadi 8b0b21f24d Search: Elasticsearch-style highlighted match snippets (results + typeahead)
CI/CD / CI · dotnet build (push) Successful in 6m9s
CI/CD / Deploy · hamkadr (push) Has been cancelled
- SearchHighlight.Snippet: extracts a ±70-char window around the first
  matching term and marks it (with ellipses) — the ES "highlight" fragment.
- Result cards (shift/job/talent) now show that snippet from the matched
  description/tags when a query is present, so you SEE where the term hit
  (e.g. «…دارای مدرک <mark>mmt</mark>…») instead of just the role.
- Typeahead suggestions gain a highlighted "sub" line (talent→tags,
  shift→city·specialty, job→facility·city) so matches show in the dropdown too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:43:50 +03:30
soroush.asadi bd8d754ee8 NuGet: drop Liara from root nuget.config too (Nexus-only everywhere)
CI/CD / CI · dotnet build (push) Successful in 9m26s
CI/CD / Deploy · hamkadr (push) Successful in 5m44s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:17:34 +03:30
soroush.asadi 69a630d185 CI/Docker NuGet: Nexus-only (drop Liara fallback)
NuGet loads the service index of EVERY listed source, so a 500 from the
Liara fallback aborted the whole restore (NU1301) even though Nexus was
healthy. Mirror cert chain is fixed now, so use our Nexus mirror as the
single source of truth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 21:10:56 +03:30
soroush.asadi 3d1d72ed9b ci: rerun after mirror cert fix
CI/CD / CI · dotnet build (push) Failing after 36s
CI/CD / Deploy · hamkadr (push) Has been skipped
2026-06-08 21:07:52 +03:30