Compare commits

..

121 Commits

Author SHA1 Message Date
soroush.asadi f0e0b82375 Fix service worker serving the homepage when clicking a job/applicant card
CI/CD / CI · dotnet build (push) Successful in 38s
CI/CD / Deploy · hamkadr (push) Successful in 1m55s
Navigations now always hit the network (fresh, never stale/archived) and fall back only to an offline notice, never the cached homepage. HTML is no longer cached so 410s cannot poison the cache; static assets stay cache-first. CACHE bumped to v2 to force-replace the broken worker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:03:37 +03:30
soroush.asadi 923a3fb90e Stop the CDN serving a stale homepage (archived listings showing as live cards)
CI/CD / CI · dotnet build (push) Successful in 2m13s
CI/CD / Deploy · hamkadr (push) Successful in 4m57s
The homepage query already filters Status==Open, but the wcdn CDN was caching the rendered HTML.
A listing archived by the post-crawl cleanup AFTER caching still appeared as a card on the stale
copy — clicking it hit the correct 410. Force HTML revalidation (no-cache) so listing pages are
always fresh; mark logged-in pages private/no-store and Vary on Cookie so the CDN never mixes
anonymous and authenticated copies. Static assets keep their long cache headers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:41:09 +03:30
soroush.asadi aaeb37e1af Make dedupe stricter to avoid wrongly archiving distinct placeholder-facility ads
CI/CD / CI · dotnet build (push) Successful in 46s
CI/CD / Deploy · hamkadr (push) Successful in 58s
The job/talent duplicate-detector compared only the first 100–120 chars of the normalized
description. Since ~82% of jobs share the «نامشخص» placeholder facility, two genuinely different
ads that merely open the same way could collapse — wrongly archiving a valid listing (→ a 410 like
/Jobs/Details/7032). Compare a 400-char slice instead, so only near-identical full texts dedupe;
true reposts/fan-out (identical text) are still caught.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:26:25 +03:30
soroush.asadi a97c556770 Show two rows of applicants on the homepage
CI/CD / CI · dotnet build (push) Successful in 3m49s
CI/CD / Deploy · hamkadr (push) Successful in 5m7s
Bump the homepage «کادر درمان آماده به کار» section from 3 to 6 latest talent listings, so it fills
two rows of the grid-3 instead of one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:05:41 +03:30
soroush.asadi 5fcdb8599f Add pagination to the Jobs / Shifts / Talent list pages
CI/CD / CI · dotnet build (push) Successful in 2m50s
CI/CD / Deploy · hamkadr (push) Successful in 5m42s
The list pages loaded EVERY matching listing into one page (/Jobs was a ~2.6MB page with 1000+
cards) — no pagination at all. Add server-side paging (24/page, DB Skip/Take; near-me still sorts
all by distance then paginates in memory). The header count now shows the true total, and a shared
_Pager partial renders prev/next + a windowed page list that preserves all active filters in the URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:27:41 +03:30
soroush.asadi ccc5a954dd Fold secondary nav links into a «بیشتر» dropdown
CI/CD / CI · dotnet build (push) Successful in 5m51s
CI/CD / Deploy · hamkadr (push) Successful in 3m9s
Move مراکز درمانی + تقویم هفتگی into a native <details> «بیشتر» dropdown, leaving the bar with just
the primary items: خانه · استخدام · شیفت‌ها · آماده به کار · بیشتر ▾ · 🔎. Desktop shows a floating
menu; in the mobile burger panel it expands inline. Closes on outside click.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:05:29 +03:30
soroush.asadi e3750b7d43 Declutter nav: browse items in the bar, personal items in the profile menu
CI/CD / CI · dotnet build (push) Successful in 2m38s
CI/CD / Deploy · hamkadr (push) Has been cancelled
The top nav had grown to 9 flat, unordered items (browse + personal + utility mixed). Reorganize:
- Main nav = browse only, in a logical order: خانه · استخدام · شیفت‌ها · آماده به کار · مراکز درمانی · تقویم · 🔎 جستجو.
- Personal items ( پیشنهادها, ❤️ پسندیده‌ها) move into the profile dropdown for logged-in users
  (پیشنهادها still shows in the bar for anonymous visitors, who have no profile menu).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:00:02 +03:30
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
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
soroush.asadi 36612b6bf0 CI/Docker NuGet: Nexus nuget-group primary + Liara fallback
CI/CD / CI · dotnet build (push) Failing after 42s
CI/CD / Deploy · hamkadr (push) Has been skipped
Both the CI restore (/tmp/nuget.ci.config) and the Docker image build
(nuget.docker.config) now use https://mirror.soroushasadi.com/repository/
nuget-group/ as the primary source with Liara as fallback, so a single
mirror returning 500 no longer breaks restore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 19:44:57 +03:30
soroush.asadi eb7d0f6559 Fix: search suggestions dropdown was clipped by the pill's overflow:hidden
CI/CD / CI · dotnet build (push) Failing after 14s
CI/CD / Deploy · hamkadr (push) Has been skipped
Moved overflow:hidden onto an inner .nav-search-pill so the rounded corners
still clip the input/button, but the absolutely-positioned suggestions box
(a child of the non-clipped .nav-search) is no longer hidden. Dropdown given
a readable min-width.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:42:07 +03:30
soroush.asadi 61afc957aa Search: fix header UI + instant typeahead (5 highlighted matches) + ranking
CI/CD / CI · dotnet build (push) Successful in 1m38s
CI/CD / Deploy · hamkadr (push) Successful in 2m6s
- Header search restyled as one clean RTL pill (input + button flush).
- Google-style autocomplete: typing ≥2 chars fetches /search/suggest and
  shows up to 5 live matches (round-robin across shifts/jobs/applicants)
  with the query highlighted, plus a «همه نتایج» link. Debounced, closes on
  outside-click/Escape.
- Search results page now RANKS by relevance (term hits in role/title/
  facility/city/tags weighted ×3, description ×1) instead of date-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:58:30 +03:30
soroush.asadi 9db4deafbc Site-wide rich search with keyword highlighting + header search box
CI/CD / CI · dotnet build (push) Successful in 1m53s
CI/CD / Deploy · hamkadr (push) Successful in 2m47s
- /Search: searches shifts, hiring openings, and applicants together via
  Postgres ILIKE (every term must match across role/city/facility/title/
  description/tags/person). Results grouped per type.
- Keyword highlighting (<mark>) extended to shift & job cards (was talent-only),
  so matches stand out everywhere.
- Persistent header search box (.nav-search) → /Search; big hero box on the
  page itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:40:26 +03:30
soroush.asadi 234bcd1f88 Polished animated contact-reveal box (shift/job/talent details)
CI/CD / CI · dotnet build (push) Successful in 3m46s
CI/CD / Deploy · hamkadr (push) Has been cancelled
Replaced the plain "interest recorded" alert with a styled .contact-reveal
card that fades/slides in and lists each channel as its own row (icon +
label + value + action button). Shift/job show facility phone + Bale;
talent shows all its ContactMethods in the same table style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:33:41 +03:30
soroush.asadi 6b657c7795 Applicants: auto-tags + deep search w/ highlight; never delete (archive instead)
CI/CD / CI · dotnet build (push) Successful in 2m1s
CI/CD / Deploy · hamkadr (push) Successful in 2m36s
- Tags: parser extracts cert/skill keywords (mmt, ICU/CCU, دیالیز, اتاق عمل,
  اورژانس, مسئول فنی, پروانه‌دار…) + role + city into TalentListing.Tags
  (+ migration); shown as chips on cards.
- Deep search on /Talent: «جستجوی عمیق» box does Postgres ILIKE across
  tags, description, person, area, role, city (every term must match);
  matches are highlighted with <mark> via SearchHighlight.
- Never delete: ShiftStatus.Archived + the admin «بایگانی گروهی» action now
  ARCHIVES aggregated posts (hidden from site, kept in DB) and leaves the
  raw crawl rows intact — a permanent archive for future analytics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:25:32 +03:30
soroush.asadi e4dc5180ad Applicants: 1→N contact methods with types (phone/email/Instagram/Telegram/Bale/site)
CI/CD / CI · dotnet build (push) Successful in 1m32s
CI/CD / Deploy · hamkadr (push) Successful in 1m31s
- ContactMethod entity (Type + Value + SortOrder) 1→N on TalentListing (+ migration).
- Parser extracts ALL contacts: multiple phones + landlines, email, and
  socials (Instagram/Telegram/Bale/WhatsApp/website) via URLs and Persian
  keyword cues; primary Phone kept for cards.
- ContactInfo helper: per-type label/icon/clickable href (tel:/mailto:/t.me/…).
- Ingestion attaches contacts to each (fanned-out) talent listing; manual
  Review re-parses to attach them + the admin-typed phone.
- Talent details renders the full contact list as buttons; falls back to the
  single phone, then the Divar source link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:10:19 +03:30
soroush.asadi 48760c4e83 Multi-role ads: parse all roles + fan-out publish one listing per role
CI/CD / CI · dotnet build (push) Successful in 2m16s
CI/CD / Deploy · hamkadr (push) Has been cancelled
An ad like «استخدام پرستار سالمند و کودک و همراه بیمار» names several roles;
we kept only the first. Now:
- Parser collects ALL roles (ParsedListing.RoleNames): exact taxonomy
  matches (substring-deduped so پرستار⊂پرستار سالمندان) plus synonyms
  (سالمند→پرستار سالمندان, کودک/همراه بیمار→پرستار, اتاق عمل→تکنسین اتاق عمل…),
  capped at 4.
- Ingestion publishes one Shift/Job/Talent per resolved role (AI role +
  parser roles, distinct, capped), so each role is independently
  browsable and filterable. RawListing dedupe is unchanged (one raw → N posts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 10:58:29 +03:30
soroush.asadi 13e00ec011 Validator: phone optional for applicants (publish + redirect to Divar)
CI/CD / CI · dotnet build (push) Successful in 3m10s
CI/CD / Deploy · hamkadr (push) Successful in 4m8s
A Divar applicant whose number is behind the login-gated reveal should
still publish — the detail page already links back to Divar for the phone.
Talent now scores role(40)+medical(10)=50, so role+medical alone passes
without a phone; phone just adds confidence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:32:48 +03:30
soroush.asadi 386e25c8fd Validator: discard promotional/training ads (workshops, courses)
CI/CD / CI · dotnet build (push) Has been cancelled
CI/CD / Deploy · hamkadr (push) Has been cancelled
Medical-flavored ads like «کارگاه بوتاکس و فیلر… ویژه پزشکان ۱۰٪» passed the
medical gate and got misclassified as a پزشک عمومی shift with a bogus 10%
share. Now: if a course/event/product marker is present and there's no
staffing intent (hiring/shift/availability), the item is auto-discarded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:30:23 +03:30
soroush.asadi 70c048a37b Add دندانپزشک + پرستار سالمندان roles (idempotent ensure on startup)
CI/CD / CI · dotnet build (push) Successful in 2m11s
CI/CD / Deploy · hamkadr (push) Has been cancelled
Roles were only seeded on a fresh DB, so existing deployments never got
new ones. Introduced a canonical role list + EnsureRolesAsync that runs on
every startup and inserts any missing role — so production picks up the two
new roles without a manual step. Original 7 keep their order/ids; the two
new roles are appended (sort 8-9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:27:34 +03:30
soroush.asadi fb02c81830 Social auto-posting (phase 1): daily applicant digest to Telegram/Bale + Instagram caption
CI/CD / CI · dotnet build (push) Successful in 1m51s
CI/CD / Deploy · hamkadr (push) Successful in 2m51s
Adds a «شبکه‌های اجتماعی» admin section + scheduler that publishes a daily
«کادر آماده‌به‌کار امروز» digest:

- AppSetting: social toggles, posts-per-day, editable header/footer,
  per-channel bot token + chat id (Telegram, Bale), Instagram enable +
  extra hashtags, proxy toggle, last-posted timestamp (+ migration).
- SocialPostService: builds today's talent digest as text, posts to
  Telegram and Bale via their bot sendMessage APIs (proxy-aware), and
  produces an Instagram caption + auto hashtags (role/city based).
- SocialPostWorker: posts N times/day, evenly spaced, self-paced; reads
  settings live so it's togglable without redeploy.
- /Admin/Social: credentials + header/footer + posts/day, live preview of
  today's message, «ارسال اکنون» button, and an Instagram caption pack
  with copy button (semi-automatic — you post the image manually).
- Nav link added.

Telegram/Bale post as TEXT (per request). The Vazirmatn image card for
Instagram is phase 2 (needs SkiaSharp+HarfBuzz + a TTF font).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:20:49 +03:30
soroush.asadi 2bb8771ade Normalize ریال→تومان pricing; stop exposing crawl source (medjobs/telegram)
CI/CD / CI · dotnet build (push) Successful in 29s
CI/CD / Deploy · hamkadr (push) Successful in 42s
- Parser now reads the currency: ریال amounts (incl. «میلیون ریال» and
  numbers with no تومان unit but ≥200M) are converted to تومان (÷10), so
  «۴۰۰٬۰۰۰٬۰۰۰ ریال» shows as ۴۰٬۰۰۰٬۰۰۰ تومان instead of 400M.
- Aggregated facility fallback name no longer embeds the source
  («مرکز درمانی (از مدجابز)» → «مرکز درمانی (نامشخص)»).
- Talent details only ever names Divar as a fallback source (when the
  number couldn't be extracted); medjobs/telegram are never shown publicly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 09:05:34 +03:30
soroush.asadi 490821a637 Talent lifecycle (21-day expiry) + noindex expired job/shift details
CI/CD / CI · dotnet build (push) Successful in 2m24s
CI/CD / Deploy · hamkadr (push) Successful in 2m47s
- Talent «آماده به کار» now has its own freshness window (21 days, vs 30
  for jobs) since availability goes stale fast; archiver, browse, and home
  use TalentCutoffUtc.
- Expired/filled job openings and past/filled shifts now emit
  robots noindex so Google drops dead listings instead of keeping
  soft-404 pages. (Talent details were already noindex.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 08:59:54 +03:30
soroush.asadi f9d7c48d88 Admin settings: give each ingestion source its own card
CI/CD / CI · dotnet build (push) Successful in 2m17s
CI/CD / Deploy · hamkadr (push) Successful in 1m57s
The sources panel (Telegram/Bale/Divar/Medjobs/Websites/Proxy) ran
together as one flat list. Each is now wrapped in a bordered .source-box
with an icon + hint, so it's clear where one source's settings end and the
next begins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 08:49:27 +03:30
soroush.asadi 0622270cd2 Fix: site-wide phone on every Medjobs ad + phone mistaken for price
CI/CD / CI · dotnet build (push) Successful in 2m7s
CI/CD / Deploy · hamkadr (push) Successful in 1m59s
- HarvestPhones was run over the whole page, so Medjobs' own header/footer
  number (09101016110) was appended to every ad. Now harvest only the ad's
  description region in Medjobs + Website sources; the protected number
  still comes from the reveal call. No more duplicate number across ads.
- The amount extractor read phone digits as a Toman price
  (۹,۱۰۱,۰۱۶,۱۱۰ تومان). The parser now strips «شماره تماس…» lines and
  mobile/landline numbers before extracting money, and only accepts 6–10
  digit numbers with no leading zero (phones/ids start with 0 or are 11+).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 08:42:21 +03:30
soroush.asadi b092a5cfe5 Admin: bulk-delete published ingested posts; talent: point to source when no phone
CI/CD / CI · dotnet build (push) Successful in 1m52s
CI/CD / Deploy · hamkadr (push) Successful in 2m41s
- /Admin/Ingested: "حذف گروهی همه‌ی منتشرشده‌ها" button removes, in one
  transaction, every aggregated Shift/Job/Talent published from ingestion
  plus the approved (Normalized) raw items that produced them. Confirms
  first and reports counts. Raw rows deleted before the posts (they hold
  the FKs); DB cascade clears applications/interest events.
- Talent details: when the contact number couldn't be extracted (e.g.
  Divar's login-gated reveal), show a prominent "مشاهده شماره در دیوار/مدجابز ↗"
  link to the original ad instead of the call button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 08:36:12 +03:30
soroush.asadi a5d6e212e2 Divar: capture post token + harvest phone from full ad detail
CI/CD / CI · dotnet build (push) Successful in 2m4s
CI/CD / Deploy · hamkadr (push) Successful in 2m18s
- Harvest now keeps each post's token, so we build a real post URL
  (divar.ir/v/{token}) instead of a generic link.
- For each post we fetch the detail JSON (posts-v2/web/{token}) and
  harvest any contact number from it — covering the very common case
  where the poster writes the phone into the ad description. Divar's
  click-to-reveal is login-gated, so this gets the in-text numbers
  without auth; fails soft (blocking/errors → skip).
- HarvestPhones hardened with digit-boundary guards so it can't grab a
  slice of a longer numeric id/timestamp inside JSON.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 08:28:37 +03:30
soroush.asadi d238888710 Medjobs: reveal hidden contact number via admin-ajax during crawl
CI/CD / CI · dotnet build (push) Successful in 1m16s
CI/CD / Deploy · hamkadr (push) Successful in 2m14s
The contact phone on medjobs.ir is loaded by JS only after clicking
«تماس با این آگهی» — it isn't in the page HTML, so scanning the markup
found nothing. We now replay that exact reveal request server-side:

- POST https://medjobs.ir/wp-admin/admin-ajax.php with
  action=isatis_protect_contact & id=<listingId> (no nonce needed),
  then harvest the tel: numbers from the returned HTML table.
- Listing id is pulled from the page via the WP shortlink (?p=ID),
  postid-/data-id, or the visible «کد آگهی» as a fallback.
- Numbers are appended to the ad text so the parser/AI capture them and
  they reach the published listing. Wrapped in try/catch so a failed
  reveal never breaks ingestion; uses the same (proxy-aware, brotli-
  decompressing) client as the page fetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 08:21:24 +03:30
soroush.asadi 213af9db48 AI tag/category assignment + phone extraction from web ads
CI/CD / CI · dotnet build (push) Successful in 2m37s
CI/CD / Deploy · hamkadr (push) Successful in 1m11s
AI (when enabled, now that the server proxy is up):
- AiStructured gains phone, personName, yearsExperience, isLicensed.
- The auditor appends an authoritative output-schema to the admin prompt
  so classification stays correct even with an older stored prompt — it
  now classifies kind as shift|job|talent and extracts the contact phone
  and talent details.
- Ingestion publish prefers the AI's tags (kind/role/city/facility/phone +
  talent fields) over the heuristic parser when present.
- Default prompt updated to describe the three kinds + new fields.

Phone extraction from websites (Medjobs / generic sites), where the
number sits behind a "تماس با این آگهی" reveal:
- HtmlUtil.HarvestPhones scans the full markup for tel: links, JSON-LD
  "telephone", data-*phone* attributes, and inline Iranian mobile/landline
  numbers (Persian digits folded), normalized (mobiles 09…, landlines 0…).
- Medjobs + Website sources append harvested numbers to the ad text so the
  parser/AI capture them; manual review then prefills the phone too.
- Parser phone extraction now also captures a landline as a fallback.

Note: if a site loads the number purely via XHR (not in HTML), a
per-source reveal endpoint would be a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 08:11:14 +03:30
soroush.asadi 4e5df73cf7 Add «آماده به کار» (talent) listing type — workers offering themselves
CI/CD / CI · dotnet build (push) Successful in 1m41s
CI/CD / Deploy · hamkadr (push) Has been cancelled
Adds a third listing kind alongside Shift/Job for healthcare staff who
advertise their own availability (very common in Iranian medical
channels, e.g. "دندانپزشک آماده همکاری… ۵۰٪ تسویه"). These have no
facility; the contact phone is the key field.

- Model: TalentListing (role, person name, years, licensed, city/district,
  area note, availability, gender, comp, phone) + ListingKind.Talent +
  RawListing.LinkedTalentId + DbSet/relations/indexes + EF migration.
- Parser: detect آماده‌به‌کار/جویای کار → Kind=Talent; extract person name,
  years of experience, licensed flag, area («منطقه ۱»), phone. Facility
  name extraction now skipped for talent.
- Validator: talent path scores role + phone + medical (no facility/pay
  required).
- Ingestion auto-publish: creates a TalentListing for talent kind.
- Review (manual publish): Talent option + talent fields; publishes a
  TalentListing without a facility. Shift/Job facility now falls back to a
  shared «نامشخص / ثبت نشده» record when the ad names none — publishing
  never fails on a missing facility.
- Browse /Talent (indexable, filters: city/district/role/gender),
  details /Talent/Details (noindex — personal contact, tel: call button),
  _TalentCard, badge-talent, nav link, home section.
- Sitemap includes /Talent; robots disallows /Talent/Details. Archiver
  expires stale talent listings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 08:01:12 +03:30
soroush.asadi bdcca5e548 Redesign header menu: separate account dropdown from dashboard nav
CI/CD / CI · dotnet build (push) Successful in 1m7s
CI/CD / Deploy · hamkadr (push) Successful in 1m57s
The profile dropdown was doing three jobs at once (account actions, the
job-seeker panel menu, and the admin panel menu) and a stray inline @if
for the notification badge leaked into the markup as literal text.

- Profile dropdown is now account-only: identity card (avatar + name +
  phone), one role-aware dashboard entry, edit profile, logout. This
  removes the leaked @if and de-clutters the menu.
- Dashboard menu is centralized in _PanelNav and auto-rendered by the
  layout on every logged-in panel page (/Admin, /Me, /Employer,
  /Preferences) instead of being duplicated in the dropdown and pages.
- Drop the now-duplicate manual <partial name="_PanelNav" /> from
  Overview, Ingested, Me/Index, Employer/Index.
- CSS: identity-card (.pd-id) styles + mobile tweaks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 07:33:22 +03:30
soroush.asadi e6a796ab27 Match crawled listings to existing facilities (fuzzy) before creating new
CI/CD / CI · dotnet build (push) Successful in 1m28s
CI/CD / Deploy · hamkadr (push) Successful in 2m24s
When publishing a scraped listing we now look for a facility we already
have that is exactly or closely the same, and only create a new one when
there is no match — avoiding duplicates like «بیمارستان میلاد» vs «میلاد».

- ListingParser: extract a facility name (keyword + distinctive words) from
  the post and surface it in the parser notes.
- FacilityMatcher: Persian-aware normalization (ي/ك, ZWNJ, punctuation),
  type-word stripping for a "core" name, contains + Levenshtein similarity,
  and FindBest (same-city exact → any-city exact → same-city fuzzy → fuzzy).
- Review (manual publish): auto-select a matching facility or prefill the
  new-facility name; resolve-or-create uses fuzzy match; dropdown preselects.
- IngestionService (auto-publish): reuse FacilityMatcher against a run-wide
  facility list (grows as new ones are created) instead of exact-name only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 07:14:48 +03:30
soroush.asadi a2fc70ae57 Fix FK violation when publishing a crawled listing without a facility
CI/CD / CI · dotnet build (push) Successful in 1m31s
CI/CD / Deploy · hamkadr (push) Successful in 1m44s
OnPostPublishAsync inserted a Shift/Job with FacilityId=0 when no
facility was selected (e.g. the dropdown is empty because no facilities
exist yet), throwing FK_Shifts_Facilities_FacilityId and surfacing the
production error page.

- Resolve-or-create the facility before insert: use the picked one, else
  create an unverified Facility from a typed name (reusing same-named).
- Guard the role too; on missing facility/role redirect back with a
  Persian error message instead of 500.
- Review form: add "new facility name" input + "— none —" option +
  error alert; add .alert-error style.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 07:09:18 +03:30
soroush.asadi 5f769b0293 [Proxy] Don't track xray config.json (survives deploys); add config.json.example
CI/CD / CI · dotnet build (push) Successful in 1m55s
CI/CD / Deploy · hamkadr (push) Failing after 34s
The real Xray VPN config held credentials and was overwritten by git checkout on every deploy. Untrack it + gitignore it + ship config.json.example as the template, so the server-side config persists across redeploys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 06:45:01 +03:30
soroush.asadi da6e86fa7f [Ingest] Full results page (all statuses) + inline quick-reject in queue
CI/CD / CI · dotnet build (push) Successful in 2m13s
CI/CD / Deploy · hamkadr (push) Has been cancelled
New /Admin/Ingested page lists every crawled item with its outcome, filterable by status (همه/در صف/پرچم‌خورده/منتشرشده/ردشده) with per-status counts and a link to the published shift or the review page. Linked from the run-history header and the admin panel nav. Plus an inline ✕رد (quick-discard) button on each queue/flagged row so admins can audit without opening the review page; full accept/reject stays on /Admin/Review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 06:41:17 +03:30
soroush.asadi 3d128ea051 [Brand] Branded favicon + icon links in <head>
CI/CD / CI · dotnet build (push) Successful in 1m43s
CI/CD / Deploy · hamkadr (push) Failing after 1m35s
Replace the default ASP.NET favicon.ico with one generated from the همکادر brand icon (multi-size 16/32/48/64), add favicon-32.png, and wire <link rel=icon> (ico + png 32/192) in the layout head so browsers and Google show the brand mark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 06:28:01 +03:30
soroush.asadi 487c7ca82f [Ingest] Persistent crawl run-log + per-source breakdown on admin queue
CI/CD / CI · dotnet build (push) Has been cancelled
CI/CD / Deploy · hamkadr (push) Has been cancelled
Each ingestion run now records an IngestionRun row (found/queued/published/flagged/spam/duplicates + a per-source detail string). Admin → صف آگهی‌ها shows a «تاریخچه جمع‌آوری» table of the last 15 runs (hover a row for the per-source breakdown), so admins can see how much each source found vs added over time. IngestionSummary gains TotalFetched. Migration: IngestionRuns table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 06:23:58 +03:30
soroush.asadi 524c66e25e [Admin] VPN/proxy + AI test buttons; fix AI JSON parse crash on null fields
CI/CD / CI · dotnet build (push) Successful in 2m41s
CI/CD / Deploy · hamkadr (push) Failing after 2m56s
Add «تست اتصال VPN/پروکسی» (reaches a filtered site through the proxy and reports connected/latency) and «تست هوش مصنوعی» (sends a sample post through the configured model and shows the verdict + extracted fields) to admin Settings. Fix OpenAiCompatibleAuditor.ParseVerdict: TryGetInt32/64 threw on null/string JSON values (the model commonly returns payAmount/sharePercent as null), which silently failed every audit — now guarded on ValueKind==Number. Verified the real OpenAI key extracts perfectly (approve / role=پرستار / city=تهران / shift=night).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 23:23:02 +03:30
soroush.asadi 0c49b89891 [AI] Route AI calls through the Xray/V2Ray proxy (reach OpenAI from Iran)
CI/CD / CI · dotnet build (push) Successful in 1m46s
CI/CD / Deploy · hamkadr (push) Failing after 1m58s
Add AiUseProxy setting + a toggle in the AI settings section. ScrapeHttpClients.ForAi(settings) returns a proxied HttpClient (reusing IngestProxyUrl, 100s timeout) when AiUseProxy is on, otherwise direct; AI-cache keys are protected from the scrape-client cleanup. OpenAiCompatibleAuditor now uses it, so the AI auditor (e.g. api.openai.com) is reachable through the same Xray sidecar that serves Telegram. Migration adds the column.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:55:07 +03:30
soroush.asadi 018c0f0286 [Ingest] Tune parser/validator for real Divar+Medjobs data
CI/CD / CI · dotnet build (push) Successful in 2m53s
CI/CD / Deploy · hamkadr (push) Failing after 2m39s
Analyzed live Divar (POST search) and Medjobs (ad_listing sitemaps) data — both are free Persian text. Tighten the medical-relevance gate (drop generic «استخدام»/«شیفت» that match retail/restaurant ads; add clinical terms: بهیار/اتاق عمل/بیهوشی/رادیولوژی/آزمایشگاه/دیالیز/فوریت/تریاژ/… ) so off-topic Divar jobs get flagged, not treated as medical. Add clinical role synonyms in the heuristic parser (بهیار/کمک‌پرستار/سالمند→پرستار, اتاق عمل→تکنسین اتاق عمل, فوریت→فوریت‌های پزشکی, آزمایشگاه→کارشناس آزمایشگاه, فوق‌تخصص→پزشک متخصص…). Result on live data: Medjobs now yields ~9/30 queue-ready healthcare listings; Divar correctly flags ~72/75 noise for manual review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:34:05 +03:30
soroush.asadi 33c13ec524 [Dashboard] Panel sub-nav menu + clearer profile button (name, not phone digit)
CI/CD / CI · dotnet build (push) Successful in 2m11s
CI/CD / Deploy · hamkadr (push) Failing after 2m37s
Logged-in panels (admin/employer/job-seeker) now show a sticky role-based dashboard menu (_PanelNav) on Employer/Index, Me/Index and Admin/Overview, with the active section highlighted — so users have an obvious menu and dashboard, not just a hidden avatar. Profile button: avatar fallback shows a 👤 glyph instead of the phone's first digit (the confusing '0'), and the desktop button now shows the user's name (or «حساب من») so it reads as a profile menu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:12:51 +03:30
soroush.asadi 69e4f305e9 [Nav] Add ثبت آگهی CTA, streamline menu, active-link highlight, role dashboards
CI/CD / CI · dotnet build (push) Successful in 3m26s
CI/CD / Deploy · hamkadr (push) Failing after 2m41s
Header gets a prominent accent +ثبت آگهی CTA → /Employer/Index (auth redirect handles login → register/post). Main nav trimmed to the 5 core public links (خانه/شیفت‌ها/استخدام/مراکز/تقویم); دریافت اپ + راهنما live in the footer and علاقه‌مندی‌ها in the profile menu, so the bar is far less crowded. Added active-page highlight (accent underline on desktop, soft background on mobile). Login now sends admins to /Admin/Overview (dashboard) instead of the ingestion queue; employers→/Employer/Index, job-seekers→/Me already in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:38:08 +03:30
soroush.asadi 2485173aad [Ingest] Fix Divar: use POST search API (GET was anti-bot blocked)
CI/CD / CI · dotnet build (push) Successful in 1m51s
CI/CD / Deploy · hamkadr (push) Successful in 2m8s
Divar's /v8/web-search GET returns a BLOCKING_VIEW (anti-bot), so the old source pulled nothing useful and could scrape the block message. Switch to the working POST /v8/postlist/w/search with a browser User-Agent and a city-id map (numeric id passthrough; tehran=1 default). Skip responses that are non-2xx or contain BLOCKING_VIEW so the block page is never ingested. Verified locally: fetched 25 real Tehran job posts into the review queue, 0 block messages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:23:36 +03:30
soroush.asadi 6af6a026a1 [SEO] JobPosting structured data, canonical/OG meta, noindex private pages, fuller sitemap
CI/CD / CI · dotnet build (push) Successful in 59s
CI/CD / Deploy · hamkadr (push) Successful in 2m27s
Strategy = Google-for-Jobs + clean indexing. Add schema.org JobPosting JSON-LD to shift & job detail pages (title, description, datePosted, validThrough, employmentType, hiringOrganization, jobLocation, baseSalary) plus Organization + WebSite JSON-LD on the home page (SeoJsonLd helper; System.Text.Json => valid, script-safe). Layout emits per-page canonical, Open Graph + Twitter cards, and applies robots noindex,nofollow to all private/applicant areas (/Admin,/Me,/Employer,/Account,/Preferences) so applicant data is never indexed. robots.txt now disallows those + /resume,/avatar,/report,/push,/notifications and points at the sitemap; sitemap.xml adds facility pages + content pages (Download/Help/Privacy/Rules/Terms).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 08:16:30 +03:30
soroush.asadi aa61efd46f [Applicant+Admin] Withdraw application, delete account, admin analytics dashboard
CI/CD / CI · dotnet build (push) Successful in 47s
CI/CD / Deploy · hamkadr (push) Successful in 1m28s
Applicant: 'انصراف از درخواست' on /Me removes the Apply event for that shift/job. Account: 'حذف حساب من' on /Me/Profile permanently deletes the user + cascades (profile, alerts, reviews, applications), detaches anonymous visitor history, and signs out (per privacy policy). Admin: /Admin/Analytics dashboard — totals (users, facilities/verified, open shifts/jobs, applications, reviews), 7-day deltas, and a 14-day applications bar chart; linked from Overview alongside the new نظرات moderation page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:52:49 +03:30
soroush.asadi d87afb577c [Facilities] Public facility pages + ratings & reviews
New /Facilities/Details public page: verified badge, info, Neshan map + directions, the facility's open shifts & jobs, and a complaint form; facility cards on /Facilities link to it. Ratings & reviews: Review model (1-5 stars + comment, one per user/facility, unique index, migration); logged-in users rate/review on the facility page; average + count shown in the header and the review list; admins moderate (hide/delete) at /Admin/Reviews.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:44:25 +03:30
soroush.asadi 437258294b [Infra] Persist DataProtection keys in the DB (fixes logout/antiforgery on deploy)
Add Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; AppDbContext implements IDataProtectionKeyContext with a DataProtectionKeys set; PersistKeysToDbContext + SetApplicationName(hamkadr). Now the key ring is shared across restarts/replicas, so auth cookies, antiforgery tokens and the captcha no longer break on every deploy (the root cause of the earlier admin lock-out). Migration: DataProtectionKeys table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:33:20 +03:30
142 changed files with 31826 additions and 673 deletions
+6 -8
View File
@@ -39,20 +39,18 @@ jobs:
git fetch --depth=1 origin "${REF}" git fetch --depth=1 origin "${REF}"
git checkout FETCH_HEAD git checkout FETCH_HEAD
- name: Write NuGet config (Liara primary; Nexus optional) - name: Write NuGet config (Nexus only)
# NOTE: mirror.soroushasadi.com currently serves an incomplete TLS chain # Single source = our Nexus mirror. We do NOT list Liara as a fallback: NuGet loads
# (leaf only, no intermediate). .NET on Linux does NOT auto-fetch the # the service index of EVERY configured source, so a 500 from a fallback aborts the
# intermediate via AIA the way Windows does, so it fails with PartialChain. # whole restore (NU1301). Nexus is the source of truth.
# Liara serves a complete chain, so it is the deterministic source here.
# Re-add Nexus once nginx points ssl_certificate at fullchain.pem.
run: | run: |
cat > /tmp/nuget.ci.config << 'EOF' cat > /tmp/nuget.ci.config << 'EOF'
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<add key="liara" <add key="nexus"
value="https://package-mirror.liara.ir/repository/nuget/index.json" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3" /> protocolVersion="3" />
</packageSources> </packageSources>
<config> <config>
+3
View File
@@ -21,3 +21,6 @@ Thumbs.db
# local dev run logs # local dev run logs
/run.log /run.log
/run.err /run.err
# Xray VPN config holds real credentials — keep it server-only.
deploy/xray/config.json
+6 -3
View File
@@ -11,9 +11,12 @@ pointed at that proxy from the admin panel, and only ingestion traffic goes thro
## Setup ## Setup
1. **Put your config** in `deploy/xray/config.json`. Replace the `proxy` outbound with your 1. **Create your config** from the example (it is git-ignored, so deploys never overwrite it):
own vmess / vless / trojan outbound (templates below). Keep the `inbounds` and `routing` ```bash
sections as-is so the local SOCKS/HTTP ports stay the same. cp deploy/xray/config.json.example deploy/xray/config.json
nano deploy/xray/config.json # replace the `proxy` outbound with your vmess/vless/trojan
```
Keep the `inbounds` and `routing` sections as-is so the local SOCKS/HTTP ports stay the same.
2. **Start the sidecar** (it's behind a compose profile so normal deploys don't run it): 2. **Start the sidecar** (it's behind a compose profile so normal deploys don't run it):
```bash ```bash
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.0",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+2 -2
View File
@@ -2,9 +2,9 @@
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<!-- Soroush Nexus mirror (primary) + Liara mirror (fallback)nuget.org is filtered. --> <!-- Single source: Soroush Nexus mirror. No Liara fallback — NuGet probes every
listed source's index, so a dead fallback (500) aborts the whole restore. -->
<add key="nexus" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json" protocolVersion="3" /> <add key="nexus" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json" protocolVersion="3" />
<add key="liara" value="https://package-mirror.liara.ir/repository/nuget/index.json" protocolVersion="3" />
</packageSources> </packageSources>
<config> <config>
<add key="http_retry_count" value="6" /> <add key="http_retry_count" value="6" />
+6 -7
View File
@@ -1,17 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
NuGet source for the Docker image build (Linux .NET — runs `dotnet restore` NuGet source for the Docker image build (Linux .NET — runs `dotnet restore`
inside the SDK container). Uses the Liara mirror because it serves a complete inside the SDK container). Single source = our Nexus mirror. We deliberately
TLS chain. mirror.soroushasadi.com currently serves a leaf-only chain, which do NOT list a fallback: NuGet loads the service index of every configured
.NET on Linux rejects with PartialChain (Windows auto-fetches the missing source, so a 500 from a fallback would abort the whole restore. nuget.org is
intermediate via AIA; Linux does not). Re-add Nexus once nginx serves filtered in Iran and is intentionally absent — Nexus is the source of truth.
fullchain.pem for mirror.soroushasadi.com.
--> -->
<configuration> <configuration>
<packageSources> <packageSources>
<clear /> <clear />
<add key="liara" <add key="nexus"
value="https://package-mirror.liara.ir/repository/nuget/index.json" value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
protocolVersion="3" /> protocolVersion="3" />
</packageSources> </packageSources>
<config> <config>
+55 -1
View File
@@ -1,12 +1,16 @@
using JobsMedical.Web.Models; using JobsMedical.Web.Models;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Data; namespace JobsMedical.Web.Data;
public class AppDbContext : DbContext public class AppDbContext : DbContext, IDataProtectionKeyContext
{ {
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
/// <summary>DataProtection key ring — persisted so antiforgery/cookies survive deploys & replicas.</summary>
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
public DbSet<City> Cities => Set<City>(); public DbSet<City> Cities => Set<City>();
public DbSet<District> Districts => Set<District>(); public DbSet<District> Districts => Set<District>();
public DbSet<Role> Roles => Set<Role>(); public DbSet<Role> Roles => Set<Role>();
@@ -15,6 +19,8 @@ public class AppDbContext : DbContext
public DbSet<Facility> Facilities => Set<Facility>(); public DbSet<Facility> Facilities => Set<Facility>();
public DbSet<Shift> Shifts => Set<Shift>(); public DbSet<Shift> Shifts => Set<Shift>();
public DbSet<JobOpening> JobOpenings => Set<JobOpening>(); public DbSet<JobOpening> JobOpenings => Set<JobOpening>();
public DbSet<TalentListing> TalentListings => Set<TalentListing>();
public DbSet<ContactMethod> ContactMethods => Set<ContactMethod>();
public DbSet<Application> Applications => Set<Application>(); public DbSet<Application> Applications => Set<Application>();
public DbSet<RawListing> RawListings => Set<RawListing>(); public DbSet<RawListing> RawListings => Set<RawListing>();
public DbSet<Visitor> Visitors => Set<Visitor>(); public DbSet<Visitor> Visitors => Set<Visitor>();
@@ -26,6 +32,9 @@ public class AppDbContext : DbContext
public DbSet<Report> Reports => Set<Report>(); public DbSet<Report> Reports => Set<Report>();
public DbSet<FacilityDocument> FacilityDocuments => Set<FacilityDocument>(); public DbSet<FacilityDocument> FacilityDocuments => Set<FacilityDocument>();
public DbSet<JobAlert> JobAlerts => Set<JobAlert>(); public DbSet<JobAlert> JobAlerts => Set<JobAlert>();
public DbSet<IngestionRun> IngestionRuns => Set<IngestionRun>();
public DbSet<Review> Reviews => Set<Review>();
public DbSet<Like> Likes => Set<Like>();
protected override void OnModelCreating(ModelBuilder b) protected override void OnModelCreating(ModelBuilder b)
{ {
@@ -93,6 +102,13 @@ public class AppDbContext : DbContext
.HasForeignKey(a => a.RoleId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(a => a.RoleId).OnDelete(DeleteBehavior.SetNull);
b.Entity<JobAlert>().HasIndex(a => a.IsActive); b.Entity<JobAlert>().HasIndex(a => a.IsActive);
// Reviews: one per (facility, user); remove with either.
b.Entity<Review>().HasIndex(r => new { r.FacilityId, r.UserId }).IsUnique();
b.Entity<Review>().HasOne(r => r.Facility).WithMany()
.HasForeignKey(r => r.FacilityId).OnDelete(DeleteBehavior.Cascade);
b.Entity<Review>().HasOne(r => r.User).WithMany()
.HasForeignKey(r => r.UserId).OnDelete(DeleteBehavior.Cascade);
// Don't delete shifts/profiles just because a Role is removed. // Don't delete shifts/profiles just because a Role is removed.
b.Entity<Shift>() b.Entity<Shift>()
.HasOne(s => s.Role).WithMany(r => r.Shifts) .HasOne(s => s.Role).WithMany(r => r.Shifts)
@@ -129,6 +145,35 @@ public class AppDbContext : DbContext
b.Entity<JobOpening>().HasIndex(j => j.Status); b.Entity<JobOpening>().HasIndex(j => j.Status);
b.Entity<JobOpening>().HasIndex(j => j.FacilityId); b.Entity<JobOpening>().HasIndex(j => j.FacilityId);
// Talent listings («آماده به کار») — no facility; keep role/city but don't cascade from them.
b.Entity<TalentListing>()
.HasOne(t => t.Role).WithMany()
.HasForeignKey(t => t.RoleId).OnDelete(DeleteBehavior.Restrict);
b.Entity<TalentListing>()
.HasOne(t => t.City).WithMany()
.HasForeignKey(t => t.CityId).OnDelete(DeleteBehavior.Restrict);
b.Entity<TalentListing>()
.HasOne(t => t.District).WithMany()
.HasForeignKey(t => t.DistrictId).OnDelete(DeleteBehavior.SetNull);
b.Entity<TalentListing>().HasIndex(t => t.Status);
b.Entity<TalentListing>().HasIndex(t => new { t.CityId, t.RoleId });
// A ContactMethod belongs to exactly one of talent / shift / job (all optional FKs).
b.Entity<ContactMethod>()
.HasOne(c => c.TalentListing).WithMany(t => t.Contacts)
.HasForeignKey(c => c.TalentListingId).OnDelete(DeleteBehavior.Cascade);
b.Entity<ContactMethod>()
.HasOne(c => c.Shift).WithMany(s => s.Contacts)
.HasForeignKey(c => c.ShiftId).OnDelete(DeleteBehavior.Cascade);
b.Entity<ContactMethod>()
.HasOne(c => c.JobOpening).WithMany(j => j.Contacts)
.HasForeignKey(c => c.JobOpeningId).OnDelete(DeleteBehavior.Cascade);
// One like per user per listing; fast count by target.
b.Entity<Like>().HasIndex(l => new { l.UserId, l.TargetType, l.TargetId }).IsUnique();
b.Entity<Like>().HasIndex(l => new { l.TargetType, l.TargetId });
b.Entity<Like>().HasOne(l => l.User).WithMany()
.HasForeignKey(l => l.UserId).OnDelete(DeleteBehavior.Cascade);
b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique(); b.Entity<WebPushSubscription>().HasIndex(s => s.Endpoint).IsUnique();
b.Entity<Notification>() b.Entity<Notification>()
@@ -140,5 +185,14 @@ public class AppDbContext : DbContext
// Dedupe ingested listings by content hash. // Dedupe ingested listings by content hash.
b.Entity<RawListing>().HasIndex(r => r.ContentHash); b.Entity<RawListing>().HasIndex(r => r.ContentHash);
b.Entity<RawListing>().HasIndex(r => r.Status); b.Entity<RawListing>().HasIndex(r => r.Status);
// A RawListing only LINKS to the post it produced — it must outlive that post (it's the
// dedupe cache). So deleting a Shift/Talent NULLs the back-reference rather than orphaning a
// dangling FK or blocking the delete. LinkedTalentId previously had no FK at all (orphan risk).
b.Entity<RawListing>()
.HasOne(r => r.LinkedShift).WithMany()
.HasForeignKey(r => r.LinkedShiftId).OnDelete(DeleteBehavior.SetNull);
b.Entity<RawListing>()
.HasOne(r => r.LinkedTalent).WithMany()
.HasForeignKey(r => r.LinkedTalentId).OnDelete(DeleteBehavior.SetNull);
} }
} }
+31 -8
View File
@@ -10,12 +10,42 @@ namespace JobsMedical.Web.Data;
/// </summary> /// </summary>
public static class SeedData public static class SeedData
{ {
/// <summary>Canonical role taxonomy (name, category, sort). Add new roles here; they're
/// inserted on every startup if missing, so existing DBs pick them up too.</summary>
private static readonly (string Name, string Category, int SortOrder)[] CanonicalRoles =
{
("پزشک عمومی", "پزشک", 1),
("پزشک متخصص", "پزشک", 2),
("پرستار", "پرستار", 3),
("ماما", "ماما", 4),
("تکنسین اتاق عمل", "تکنسین", 5),
("تکنسین فوریت‌های پزشکی", "تکنسین", 6),
("کارشناس آزمایشگاه", "تکنسین", 7),
("دندانپزشک", "دندانپزشک", 8),
("پرستار سالمندان", "پرستار", 9),
};
public static async Task EnsureSeededAsync(AppDbContext db, bool includeDemo = true) public static async Task EnsureSeededAsync(AppDbContext db, bool includeDemo = true)
{ {
await SeedReferenceAsync(db); await SeedReferenceAsync(db);
await EnsureRolesAsync(db);
if (includeDemo) await SeedDemoAsync(db); if (includeDemo) await SeedDemoAsync(db);
} }
/// <summary>Idempotently add any canonical role missing from the DB (no-op when all present).</summary>
public static async Task EnsureRolesAsync(AppDbContext db)
{
var existing = await db.Roles.Select(r => r.Name).ToListAsync();
var added = false;
foreach (var (name, category, sort) in CanonicalRoles)
if (!existing.Contains(name))
{
db.Roles.Add(new Role { Name = name, Category = category, SortOrder = sort, IsActive = true });
added = true;
}
if (added) await db.SaveChangesAsync();
}
// ---------- Reference data (always) ---------- // ---------- Reference data (always) ----------
public static async Task SeedReferenceAsync(AppDbContext db) public static async Task SeedReferenceAsync(AppDbContext db)
{ {
@@ -29,14 +59,7 @@ public static class SeedData
new City { Name = "شیراز", Province = "فارس", IsActive = false }); new City { Name = "شیراز", Province = "فارس", IsActive = false });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
db.Roles.AddRange( // Roles are seeded by EnsureRolesAsync (idempotent, runs every startup).
new Role { Name = "پزشک عمومی", Category = "پزشک", SortOrder = 1 },
new Role { Name = "پزشک متخصص", Category = "پزشک", SortOrder = 2 },
new Role { Name = "پرستار", Category = "پرستار", SortOrder = 3 },
new Role { Name = "ماما", Category = "ماما", SortOrder = 4 },
new Role { Name = "تکنسین اتاق عمل", Category = "تکنسین", SortOrder = 5 },
new Role { Name = "تکنسین فوریت‌های پزشکی", Category = "تکنسین", SortOrder = 6 },
new Role { Name = "کارشناس آزمایشگاه", Category = "تکنسین", SortOrder = 7 });
foreach (var n in new[] { "سعادت‌آباد", "شهرک غرب", "ولیعصر / پارک‌وی", "نارمک", foreach (var n in new[] { "سعادت‌آباد", "شهرک غرب", "ولیعصر / پارک‌وی", "نارمک",
"تهرانپارس", "گیشا / برج میلاد", "ونک", "تجریش" }) "تهرانپارس", "گیشا / برج میلاد", "ونک", "تجریش" })
@@ -11,6 +11,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="WebPush" Version="1.0.12" /> <PackageReference Include="WebPush" Version="1.0.12" />
</ItemGroup> </ItemGroup>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class DataProtectionKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DataProtectionKeys",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
FriendlyName = table.Column<string>(type: "text", nullable: true),
Xml = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DataProtectionKeys");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class Reviews : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Reviews",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
FacilityId = table.Column<int>(type: "integer", nullable: false),
UserId = table.Column<int>(type: "integer", nullable: false),
Stars = table.Column<int>(type: "integer", nullable: false),
Comment = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
IsApproved = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Reviews", x => x.Id);
table.ForeignKey(
name: "FK_Reviews_Facilities_FacilityId",
column: x => x.FacilityId,
principalTable: "Facilities",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Reviews_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Reviews_FacilityId_UserId",
table: "Reviews",
columns: new[] { "FacilityId", "UserId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Reviews_UserId",
table: "Reviews",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Reviews");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class AiUseProxy : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AiUseProxy",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AiUseProxy",
table: "AppSettings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class IngestionRunLog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "IngestionRuns",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RunAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
Fetched = table.Column<int>(type: "integer", nullable: false),
Queued = table.Column<int>(type: "integer", nullable: false),
Published = table.Column<int>(type: "integer", nullable: false),
Flagged = table.Column<int>(type: "integer", nullable: false),
Spam = table.Column<int>(type: "integer", nullable: false),
Duplicates = table.Column<int>(type: "integer", nullable: false),
Detail = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_IngestionRuns", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "IngestionRuns");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class AddTalentListing : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "LinkedTalentId",
table: "RawListings",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "TalentListings",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
RoleId = table.Column<int>(type: "integer", nullable: false),
PersonName = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: true),
YearsExperience = table.Column<int>(type: "integer", nullable: true),
IsLicensed = table.Column<bool>(type: "boolean", nullable: false),
CityId = table.Column<int>(type: "integer", nullable: false),
DistrictId = table.Column<int>(type: "integer", nullable: true),
AreaNote = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: true),
Availability = table.Column<int>(type: "integer", nullable: true),
Gender = table.Column<int>(type: "integer", nullable: false),
PayType = table.Column<int>(type: "integer", nullable: false),
PayAmount = table.Column<long>(type: "bigint", nullable: true),
SharePercent = table.Column<int>(type: "integer", nullable: true),
Phone = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: true),
Description = table.Column<string>(type: "character varying(2000)", maxLength: 2000, nullable: true),
Status = table.Column<int>(type: "integer", nullable: false),
Source = table.Column<int>(type: "integer", nullable: false),
SourceUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TalentListings", x => x.Id);
table.ForeignKey(
name: "FK_TalentListings_Cities_CityId",
column: x => x.CityId,
principalTable: "Cities",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_TalentListings_Districts_DistrictId",
column: x => x.DistrictId,
principalTable: "Districts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_TalentListings_Roles_RoleId",
column: x => x.RoleId,
principalTable: "Roles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_TalentListings_CityId_RoleId",
table: "TalentListings",
columns: new[] { "CityId", "RoleId" });
migrationBuilder.CreateIndex(
name: "IX_TalentListings_DistrictId",
table: "TalentListings",
column: "DistrictId");
migrationBuilder.CreateIndex(
name: "IX_TalentListings_RoleId",
table: "TalentListings",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "IX_TalentListings_Status",
table: "TalentListings",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TalentListings");
migrationBuilder.DropColumn(
name: "LinkedTalentId",
table: "RawListings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class SocialPosting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "InstagramHashtags",
table: "AppSettings",
type: "character varying(1000)",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SocialBaleBotToken",
table: "AppSettings",
type: "character varying(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SocialBaleChatId",
table: "AppSettings",
type: "character varying(120)",
maxLength: 120,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "SocialBaleEnabled",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SocialEnabled",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "SocialFooter",
table: "AppSettings",
type: "character varying(1000)",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SocialHeader",
table: "AppSettings",
type: "character varying(1000)",
maxLength: 1000,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "SocialInstagramEnabled",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "SocialLastPostedAt",
table: "AppSettings",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "SocialPostsPerDay",
table: "AppSettings",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "SocialTelegramBotToken",
table: "AppSettings",
type: "character varying(200)",
maxLength: 200,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SocialTelegramChatId",
table: "AppSettings",
type: "character varying(120)",
maxLength: 120,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "SocialTelegramEnabled",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SocialUseProxy",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "InstagramHashtags",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialBaleBotToken",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialBaleChatId",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialBaleEnabled",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialEnabled",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialFooter",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialHeader",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialInstagramEnabled",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialLastPostedAt",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialPostsPerDay",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialTelegramBotToken",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialTelegramChatId",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialTelegramEnabled",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "SocialUseProxy",
table: "AppSettings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class ContactMethods : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ContactMethods",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
TalentListingId = table.Column<int>(type: "integer", nullable: false),
Type = table.Column<int>(type: "integer", nullable: false),
Value = table.Column<string>(type: "character varying(250)", maxLength: 250, nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ContactMethods", x => x.Id);
table.ForeignKey(
name: "FK_ContactMethods_TalentListings_TalentListingId",
column: x => x.TalentListingId,
principalTable: "TalentListings",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ContactMethods_TalentListingId",
table: "ContactMethods",
column: "TalentListingId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ContactMethods");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class TalentTags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Tags",
table: "TalentListings",
type: "character varying(500)",
maxLength: 500,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Tags",
table: "TalentListings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class RawListingLinkFks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RawListings_Shifts_LinkedShiftId",
table: "RawListings");
// LinkedTalentId never had an FK before, so existing rows may point at deleted talent.
// Null those orphans first, otherwise AddForeignKey below fails on a populated DB.
migrationBuilder.Sql(
"UPDATE \"RawListings\" r SET \"LinkedTalentId\" = NULL " +
"WHERE r.\"LinkedTalentId\" IS NOT NULL " +
"AND NOT EXISTS (SELECT 1 FROM \"TalentListings\" t WHERE t.\"Id\" = r.\"LinkedTalentId\");");
migrationBuilder.CreateIndex(
name: "IX_RawListings_LinkedTalentId",
table: "RawListings",
column: "LinkedTalentId");
migrationBuilder.AddForeignKey(
name: "FK_RawListings_Shifts_LinkedShiftId",
table: "RawListings",
column: "LinkedShiftId",
principalTable: "Shifts",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_RawListings_TalentListings_LinkedTalentId",
table: "RawListings",
column: "LinkedTalentId",
principalTable: "TalentListings",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RawListings_Shifts_LinkedShiftId",
table: "RawListings");
migrationBuilder.DropForeignKey(
name: "FK_RawListings_TalentListings_LinkedTalentId",
table: "RawListings");
migrationBuilder.DropIndex(
name: "IX_RawListings_LinkedTalentId",
table: "RawListings");
migrationBuilder.AddForeignKey(
name: "FK_RawListings_Shifts_LinkedShiftId",
table: "RawListings",
column: "LinkedShiftId",
principalTable: "Shifts",
principalColumn: "Id");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class RawListingGeo : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "Lat",
table: "RawListings",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lng",
table: "RawListings",
type: "double precision",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Lat",
table: "RawListings");
migrationBuilder.DropColumn(
name: "Lng",
table: "RawListings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class ShiftJobContacts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "TalentListingId",
table: "ContactMethods",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AddColumn<int>(
name: "JobOpeningId",
table: "ContactMethods",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShiftId",
table: "ContactMethods",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ContactMethods_JobOpeningId",
table: "ContactMethods",
column: "JobOpeningId");
migrationBuilder.CreateIndex(
name: "IX_ContactMethods_ShiftId",
table: "ContactMethods",
column: "ShiftId");
migrationBuilder.AddForeignKey(
name: "FK_ContactMethods_JobOpenings_JobOpeningId",
table: "ContactMethods",
column: "JobOpeningId",
principalTable: "JobOpenings",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ContactMethods_Shifts_ShiftId",
table: "ContactMethods",
column: "ShiftId",
principalTable: "Shifts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_ContactMethods_JobOpenings_JobOpeningId",
table: "ContactMethods");
migrationBuilder.DropForeignKey(
name: "FK_ContactMethods_Shifts_ShiftId",
table: "ContactMethods");
migrationBuilder.DropIndex(
name: "IX_ContactMethods_JobOpeningId",
table: "ContactMethods");
migrationBuilder.DropIndex(
name: "IX_ContactMethods_ShiftId",
table: "ContactMethods");
migrationBuilder.DropColumn(
name: "JobOpeningId",
table: "ContactMethods");
migrationBuilder.DropColumn(
name: "ShiftId",
table: "ContactMethods");
migrationBuilder.AlterColumn<int>(
name: "TalentListingId",
table: "ContactMethods",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class ListingApproxCoords : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "Lat",
table: "TalentListings",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lng",
table: "TalentListings",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lat",
table: "Shifts",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lng",
table: "Shifts",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lat",
table: "JobOpenings",
type: "double precision",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "Lng",
table: "JobOpenings",
type: "double precision",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Lat",
table: "TalentListings");
migrationBuilder.DropColumn(
name: "Lng",
table: "TalentListings");
migrationBuilder.DropColumn(
name: "Lat",
table: "Shifts");
migrationBuilder.DropColumn(
name: "Lng",
table: "Shifts");
migrationBuilder.DropColumn(
name: "Lat",
table: "JobOpenings");
migrationBuilder.DropColumn(
name: "Lng",
table: "JobOpenings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class IranEstekhdamSource : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IranEstekhdamEnabled",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "IranEstekhdamMaxAds",
table: "AppSettings",
type: "integer",
nullable: false,
defaultValue: 40);
migrationBuilder.AddColumn<bool>(
name: "IranEstekhdamUseProxy",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IranEstekhdamEnabled",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "IranEstekhdamMaxAds",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "IranEstekhdamUseProxy",
table: "AppSettings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class MedboomSource : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "MedboomEnabled",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "MedboomMaxAds",
table: "AppSettings",
type: "integer",
nullable: false,
defaultValue: 40);
migrationBuilder.AddColumn<bool>(
name: "MedboomUseProxy",
table: "AppSettings",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MedboomEnabled",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "MedboomMaxAds",
table: "AppSettings");
migrationBuilder.DropColumn(
name: "MedboomUseProxy",
table: "AppSettings");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace JobsMedical.Web.Migrations
{
/// <inheritdoc />
public partial class Likes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Likes",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<int>(type: "integer", nullable: false),
TargetType = table.Column<int>(type: "integer", nullable: false),
TargetId = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Likes", x => x.Id);
table.ForeignKey(
name: "FK_Likes_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Likes_TargetType_TargetId",
table: "Likes",
columns: new[] { "TargetType", "TargetId" });
migrationBuilder.CreateIndex(
name: "IX_Likes_UserId_TargetType_TargetId",
table: "Likes",
columns: new[] { "UserId", "TargetType", "TargetId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Likes");
}
}
}
@@ -53,6 +53,9 @@ namespace JobsMedical.Web.Migrations
.HasMaxLength(4000) .HasMaxLength(4000)
.HasColumnType("character varying(4000)"); .HasColumnType("character varying(4000)");
b.Property<bool>("AiUseProxy")
.HasColumnType("boolean");
b.Property<bool>("AutoIngestEnabled") b.Property<bool>("AutoIngestEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -96,6 +99,28 @@ namespace JobsMedical.Web.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<string>("InstagramHashtags")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<bool>("IranEstekhdamEnabled")
.HasColumnType("boolean");
b.Property<int>("IranEstekhdamMaxAds")
.HasColumnType("integer");
b.Property<bool>("IranEstekhdamUseProxy")
.HasColumnType("boolean");
b.Property<bool>("MedboomEnabled")
.HasColumnType("boolean");
b.Property<int>("MedboomMaxAds")
.HasColumnType("integer");
b.Property<bool>("MedboomUseProxy")
.HasColumnType("boolean");
b.Property<bool>("MedjobsEnabled") b.Property<bool>("MedjobsEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -130,6 +155,51 @@ namespace JobsMedical.Web.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
b.Property<string>("SocialBaleBotToken")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SocialBaleChatId")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<bool>("SocialBaleEnabled")
.HasColumnType("boolean");
b.Property<bool>("SocialEnabled")
.HasColumnType("boolean");
b.Property<string>("SocialFooter")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<string>("SocialHeader")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<bool>("SocialInstagramEnabled")
.HasColumnType("boolean");
b.Property<DateTime?>("SocialLastPostedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("SocialPostsPerDay")
.HasColumnType("integer");
b.Property<string>("SocialTelegramBotToken")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("SocialTelegramChatId")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<bool>("SocialTelegramEnabled")
.HasColumnType("boolean");
b.Property<bool>("SocialUseProxy")
.HasColumnType("boolean");
b.Property<string>("TelegramChannels") b.Property<string>("TelegramChannels")
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("character varying(2000)"); .HasColumnType("character varying(2000)");
@@ -233,6 +303,45 @@ namespace JobsMedical.Web.Migrations
b.ToTable("Cities"); b.ToTable("Cities");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.ContactMethod", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("JobOpeningId")
.HasColumnType("integer");
b.Property<int?>("ShiftId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<int?>("TalentListingId")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(250)
.HasColumnType("character varying(250)");
b.HasKey("Id");
b.HasIndex("JobOpeningId");
b.HasIndex("ShiftId");
b.HasIndex("TalentListingId");
b.ToTable("ContactMethods");
});
modelBuilder.Entity("JobsMedical.Web.Models.District", b => modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -418,6 +527,44 @@ namespace JobsMedical.Web.Migrations
b.ToTable("FacilityDocuments"); b.ToTable("FacilityDocuments");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.IngestionRun", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Detail")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int>("Duplicates")
.HasColumnType("integer");
b.Property<int>("Fetched")
.HasColumnType("integer");
b.Property<int>("Flagged")
.HasColumnType("integer");
b.Property<int>("Published")
.HasColumnType("integer");
b.Property<int>("Queued")
.HasColumnType("integer");
b.Property<DateTime>("RunAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Spam")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("IngestionRuns");
});
modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b => modelBuilder.Entity("JobsMedical.Web.Models.InterestEvent", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -535,6 +682,12 @@ namespace JobsMedical.Web.Migrations
b.Property<int>("GenderRequirement") b.Property<int>("GenderRequirement")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<double?>("Lat")
.HasColumnType("double precision");
b.Property<double?>("Lng")
.HasColumnType("double precision");
b.Property<string>("Requirements") b.Property<string>("Requirements")
.HasMaxLength(1000) .HasMaxLength(1000)
.HasColumnType("character varying(1000)"); .HasColumnType("character varying(1000)");
@@ -574,6 +727,36 @@ namespace JobsMedical.Web.Migrations
b.ToTable("JobOpenings"); b.ToTable("JobOpenings");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.Like", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("TargetId")
.HasColumnType("integer");
b.Property<int>("TargetType")
.HasColumnType("integer");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("TargetType", "TargetId");
b.HasIndex("UserId", "TargetType", "TargetId")
.IsUnique();
b.ToTable("Likes");
});
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b => modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -629,9 +812,18 @@ namespace JobsMedical.Web.Migrations
b.Property<DateTime>("FetchedAt") b.Property<DateTime>("FetchedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<double?>("Lat")
.HasColumnType("double precision");
b.Property<int?>("LinkedShiftId") b.Property<int?>("LinkedShiftId")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int?>("LinkedTalentId")
.HasColumnType("integer");
b.Property<double?>("Lng")
.HasColumnType("double precision");
b.Property<string>("ParsedJson") b.Property<string>("ParsedJson")
.HasColumnType("text"); .HasColumnType("text");
@@ -661,6 +853,8 @@ namespace JobsMedical.Web.Migrations
b.HasIndex("LinkedShiftId"); b.HasIndex("LinkedShiftId");
b.HasIndex("LinkedTalentId");
b.HasIndex("Status"); b.HasIndex("Status");
b.ToTable("RawListings"); b.ToTable("RawListings");
@@ -709,6 +903,43 @@ namespace JobsMedical.Web.Migrations
b.ToTable("Reports"); b.ToTable("Reports");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.Review", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Comment")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FacilityId")
.HasColumnType("integer");
b.Property<bool>("IsApproved")
.HasColumnType("boolean");
b.Property<int>("Stars")
.HasColumnType("integer");
b.Property<int>("UserId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("FacilityId", "UserId")
.IsUnique();
b.ToTable("Reviews");
});
modelBuilder.Entity("JobsMedical.Web.Models.Role", b => modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -765,6 +996,12 @@ namespace JobsMedical.Web.Migrations
b.Property<int>("GenderRequirement") b.Property<int>("GenderRequirement")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<double?>("Lat")
.HasColumnType("double precision");
b.Property<double?>("Lng")
.HasColumnType("double precision");
b.Property<long?>("PayAmount") b.Property<long?>("PayAmount")
.HasColumnType("bigint"); .HasColumnType("bigint");
@@ -809,6 +1046,96 @@ namespace JobsMedical.Web.Migrations
b.ToTable("Shifts"); b.ToTable("Shifts");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AreaNote")
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<int?>("Availability")
.HasColumnType("integer");
b.Property<int>("CityId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<int?>("DistrictId")
.HasColumnType("integer");
b.Property<int>("Gender")
.HasColumnType("integer");
b.Property<bool>("IsLicensed")
.HasColumnType("boolean");
b.Property<double?>("Lat")
.HasColumnType("double precision");
b.Property<double?>("Lng")
.HasColumnType("double precision");
b.Property<long?>("PayAmount")
.HasColumnType("bigint");
b.Property<int>("PayType")
.HasColumnType("integer");
b.Property<string>("PersonName")
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("Phone")
.HasMaxLength(30)
.HasColumnType("character varying(30)");
b.Property<int>("RoleId")
.HasColumnType("integer");
b.Property<int?>("SharePercent")
.HasColumnType("integer");
b.Property<int>("Source")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("Tags")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<int?>("YearsExperience")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DistrictId");
b.HasIndex("RoleId");
b.HasIndex("Status");
b.HasIndex("CityId", "RoleId");
b.ToTable("TalentListings");
});
modelBuilder.Entity("JobsMedical.Web.Models.User", b => modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -970,6 +1297,25 @@ namespace JobsMedical.Web.Migrations
b.ToTable("WebPushSubscriptions"); b.ToTable("WebPushSubscriptions");
}); });
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.HasColumnType("text");
b.Property<string>("Xml")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("JobsMedical.Web.Models.Application", b => modelBuilder.Entity("JobsMedical.Web.Models.Application", b =>
{ {
b.HasOne("JobsMedical.Web.Models.User", "Doctor") b.HasOne("JobsMedical.Web.Models.User", "Doctor")
@@ -989,6 +1335,30 @@ namespace JobsMedical.Web.Migrations
b.Navigation("Shift"); b.Navigation("Shift");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.ContactMethod", b =>
{
b.HasOne("JobsMedical.Web.Models.JobOpening", "JobOpening")
.WithMany("Contacts")
.HasForeignKey("JobOpeningId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("JobsMedical.Web.Models.Shift", "Shift")
.WithMany("Contacts")
.HasForeignKey("ShiftId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("JobsMedical.Web.Models.TalentListing", "TalentListing")
.WithMany("Contacts")
.HasForeignKey("TalentListingId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("JobOpening");
b.Navigation("Shift");
b.Navigation("TalentListing");
});
modelBuilder.Entity("JobsMedical.Web.Models.District", b => modelBuilder.Entity("JobsMedical.Web.Models.District", b =>
{ {
b.HasOne("JobsMedical.Web.Models.City", "City") b.HasOne("JobsMedical.Web.Models.City", "City")
@@ -1127,6 +1497,17 @@ namespace JobsMedical.Web.Migrations
b.Navigation("Role"); b.Navigation("Role");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.Like", b =>
{
b.HasOne("JobsMedical.Web.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("JobsMedical.Web.Models.Notification", b => modelBuilder.Entity("JobsMedical.Web.Models.Notification", b =>
{ {
b.HasOne("JobsMedical.Web.Models.User", "User") b.HasOne("JobsMedical.Web.Models.User", "User")
@@ -1142,9 +1523,36 @@ namespace JobsMedical.Web.Migrations
{ {
b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift") b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift")
.WithMany() .WithMany()
.HasForeignKey("LinkedShiftId"); .HasForeignKey("LinkedShiftId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("JobsMedical.Web.Models.TalentListing", "LinkedTalent")
.WithMany()
.HasForeignKey("LinkedTalentId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LinkedShift"); b.Navigation("LinkedShift");
b.Navigation("LinkedTalent");
});
modelBuilder.Entity("JobsMedical.Web.Models.Review", b =>
{
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
.WithMany()
.HasForeignKey("FacilityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("JobsMedical.Web.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Facility");
b.Navigation("User");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
@@ -1166,6 +1574,32 @@ namespace JobsMedical.Web.Migrations
b.Navigation("Role"); b.Navigation("Role");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
{
b.HasOne("JobsMedical.Web.Models.City", "City")
.WithMany()
.HasForeignKey("CityId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("JobsMedical.Web.Models.District", "District")
.WithMany()
.HasForeignKey("DistrictId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("JobsMedical.Web.Models.Role", "Role")
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("City");
b.Navigation("District");
b.Navigation("Role");
});
modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b => modelBuilder.Entity("JobsMedical.Web.Models.UserPreferences", b =>
{ {
b.HasOne("JobsMedical.Web.Models.City", "City") b.HasOne("JobsMedical.Web.Models.City", "City")
@@ -1216,6 +1650,11 @@ namespace JobsMedical.Web.Migrations
b.Navigation("Shifts"); b.Navigation("Shifts");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
{
b.Navigation("Contacts");
});
modelBuilder.Entity("JobsMedical.Web.Models.Role", b => modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
{ {
b.Navigation("Shifts"); b.Navigation("Shifts");
@@ -1224,6 +1663,13 @@ namespace JobsMedical.Web.Migrations
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b => modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
{ {
b.Navigation("Applications"); b.Navigation("Applications");
b.Navigation("Contacts");
});
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
{
b.Navigation("Contacts");
}); });
modelBuilder.Entity("JobsMedical.Web.Models.User", b => modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
+84 -11
View File
@@ -32,6 +32,10 @@ public class AppSetting
/// <summary>If AI approves AND Mode is Automatic, publish without human review.</summary> /// <summary>If AI approves AND Mode is Automatic, publish without human review.</summary>
public bool AiAutoApprove { get; set; } = false; public bool AiAutoApprove { get; set; } = false;
/// <summary>Route AI calls through the ingestion proxy (IngestProxyUrl) — needed when the AI
/// endpoint (e.g. api.openai.com) is blocked in Iran.</summary>
public bool AiUseProxy { get; set; } = false;
// --- Channel scraping sources (configured here, NOT in env) --- // --- Channel scraping sources (configured here, NOT in env) ---
/// <summary>Run the ingestion worker on a timer.</summary> /// <summary>Run the ingestion worker on a timer.</summary>
public bool AutoIngestEnabled { get; set; } = false; public bool AutoIngestEnabled { get; set; } = false;
@@ -77,6 +81,18 @@ public class AppSetting
/// <summary>Max ads to fetch per ingestion run (be polite; dedupe skips already-seen).</summary> /// <summary>Max ads to fetch per ingestion run (be polite; dedupe skips already-seen).</summary>
public int MedjobsMaxAds { get; set; } = 40; public int MedjobsMaxAds { get; set; } = 40;
/// <summary>Scrape iranestekhdam.ir clinical job ads (crawled via its monthly ad sitemaps;
/// employer ads at named facilities, filtered to clinical-role slugs).</summary>
public bool IranEstekhdamEnabled { get; set; } = false;
public int IranEstekhdamMaxAds { get; set; } = 40;
public bool IranEstekhdamUseProxy { get; set; } = false;
/// <summary>Scrape medboom.ir clinical ads (WordPress board; doctor/dentist-heavy, hiring +
/// availability; crawled via its WP sitemap, Tehran-only for launch).</summary>
public bool MedboomEnabled { get; set; } = false;
public int MedboomMaxAds { get; set; } = 40;
public bool MedboomUseProxy { get; set; } = false;
// --- SMS OTP (Kavenegar). When off, the code is shown on screen (dev only). --- // --- SMS OTP (Kavenegar). When off, the code is shown on screen (dev only). ---
public bool SmsEnabled { get; set; } = false; public bool SmsEnabled { get; set; } = false;
[MaxLength(200)] public string? SmsApiKey { get; set; } [MaxLength(200)] public string? SmsApiKey { get; set; }
@@ -100,6 +116,32 @@ public class AppSetting
[MaxLength(200)] public string? VapidPrivateKey { get; set; } [MaxLength(200)] public string? VapidPrivateKey { get; set; }
[MaxLength(120)] public string? VapidSubject { get; set; } = "mailto:admin@hamkadr.ir"; [MaxLength(120)] public string? VapidSubject { get; set; } = "mailto:admin@hamkadr.ir";
// --- Social auto-posting: a daily «کادر آماده به کار» digest to Telegram/Bale (text) + an
// Instagram caption/hashtags pack (you post the image manually). ---
public bool SocialEnabled { get; set; } = false;
/// <summary>How many digests to publish per day (evenly spaced).</summary>
public int SocialPostsPerDay { get; set; } = 3;
/// <summary>Lines added above/below the auto-generated body (your branding, links, etc.).</summary>
[MaxLength(1000)] public string? SocialHeader { get; set; }
[MaxLength(1000)] public string? SocialFooter { get; set; }
/// <summary>Route the bot calls through the ingestion proxy (Telegram is filtered in Iran).</summary>
public bool SocialUseProxy { get; set; } = true;
public bool SocialTelegramEnabled { get; set; } = false;
[MaxLength(200)] public string? SocialTelegramBotToken { get; set; }
/// <summary>Channel/chat to post to — «@channelusername» or a numeric chat id.</summary>
[MaxLength(120)] public string? SocialTelegramChatId { get; set; }
public bool SocialBaleEnabled { get; set; } = false;
[MaxLength(200)] public string? SocialBaleBotToken { get; set; }
[MaxLength(120)] public string? SocialBaleChatId { get; set; }
public bool SocialInstagramEnabled { get; set; } = false;
/// <summary>Extra hashtags appended to the generated Instagram caption (space/line separated).</summary>
[MaxLength(1000)] public string? InstagramHashtags { get; set; }
public DateTime? SocialLastPostedAt { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Split a textarea (newline/comma separated) into trimmed non-empty items.</summary> /// <summary>Split a textarea (newline/comma separated) into trimmed non-empty items.</summary>
@@ -108,17 +150,48 @@ public class AppSetting
: s.Split(new[] { '\n', '\r', ',', '،' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) : s.Split(new[] { '\n', '\r', ',', '،' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList(); .ToList();
/// <summary>The fixed, code-owned system prompt the AI follows. It is hardcoded (shown read-only
/// in admin) so it can't drift or be broken by an edit. The authoritative output-key schema is
/// appended automatically by <c>OpenAiCompatibleAuditor</c>, so this text stays behavioral.</summary>
public const string DefaultPrompt = """ public const string DefaultPrompt = """
تو دستیار بررسی آگهیهای کاری حوزه درمان برای پلتفرم «همکادر» هستی. تو دستیار دستهبندی آگهیهای کادر درمان تهران برای پلتفرم «همکادر» هستی. هر ورودی یک متن خام از
هر آگهی خام را بخوان و تصمیم بگیر: کانالهای تلگرام/بله/دیوار است. وظیفه: (۱) تشخیص نوع، (۲) استخراج دقیق فیلدها و دستهبندی فرد،
- approve: آگهی واقعی و مرتبط با شیفت/استخدام کادر درمان است و اطلاعات کافی دارد. (۳) تصمیم تأیید/رد/بررسی. فقط یک شیء JSON معتبر برگردان؛ هیچ متن اضافهای ننویس.
- reject: تبلیغ، اسپم، نامرتبط، یا فاقد اطلاعات حداقلی است.
- review: مرتبط است اما ناقص/مبهم و نیاز به بررسی انسانی دارد. نوع (kind):
نقش، شهر/محله، نوع شیفت، نوع همکاری، مبلغ یا درصد سهم، و عنوان را در صورت وجود استخراج کن. shift = مرکز درمانی برای بازهٔ زمانی مشخص نیرو میخواهد.
فقط با یک شیء JSON پاسخ بده با کلیدهای: job = مرکز درمانی استخدام دائم/قراردادی دارد.
decision (approve|reject|review)، confidence (0-100)، reason (فارسی کوتاه)، talent= فردی از کادر درمان خودش را «آماده به کار / آماده همکاری» معرفی میکند
kind (shift|job)، role، city، district، shiftType (day|evening|night|oncall)، (سمت عرضه؛ مرکز ندارد و شماره تماسِ خودِ فرد مهمترین فیلد است).
employmentType (fulltime|parttime|contract|plan)، payAmount (عدد تومان یا null)،
sharePercent (0-100 یا null)، title، facilityName. نقش (role) و گروه (category):
اول سعی کن نقش را با یکی از نقشهای رایج تطبیق دهی: پزشک عمومی، پزشک متخصص، پرستار،
پرستار سالمندان، ماما، تکنسین اتاق عمل، تکنسین فوریتهای پزشکی، کارشناس آزمایشگاه، دندانپزشک.
نقش را به «حرفهٔ پایه» بنویس، نه با پیشوند/پسوندِ توصیفی. گروهِ سنی، بخش، سطح، یا جنسیت را در
نقش نیاور و بهجایش در tags (و جنسیت را در فیلد gender) بگذار:
«پرستار کودک» نقش «پرستار» + تگ «کودک»
«پرستار آقا» نقش «پرستار» + جنسیت «آقا»
«پرستار اورژانس» نقش «پرستار» + تگ «اورژانس»
«کارآموز تکنسین داروخانه» نقش «تکنسین داروخانه» + تگ «کارآموز»
فقط وقتی نقشِ جدید بساز که یک «حرفهٔ پایهٔ متفاوت» باشد که در فهرست نیست (مثل «تکنسین داروخانه»،
«کارشناس رادیولوژی»، «شنواییسنج»). نقش جدید را کوتاه و رسمی بنویس، نه جمله.
category را فقط یکی از این پنج گروه بگذار: پزشک | پرستار | ماما | تکنسین | دندانپزشک.
اگر نقش در هیچکدام نگنجید، category = «سایر». هرگز گروهِ جدید نساز.
مهارتها/الزامات (tags): فقط کلیدواژههای بالینی و مرتبط را بهصورت آرایه برگردان مهارت،
بخش، گواهی، گروه سنی، سطح، یا شرط (مثل "ICU"، "NICU"، "دیالیز"، "اتاق عمل"، "کودک"، "سالمند",
"MMT"، "CPR"، "پروانه‌دار"، "خانم"، "آقا"). هرگز مبلغ/پرداخت/توافقی، شماره تماس، شهر/محله، یا
جملهٔ ناقص را بهعنوان تگ نگذار. اگر چیزی نبود [].
شهر (city): فقط نام شهر (مثل «تهران»). محله/منطقه را در district بگذار.
تصمیم (decision):
approve = آگهیِ واقعیِ مرتبط با کادر درمان تهران با اطلاعات کافی.
reject = اسپم/تبلیغ/نامرتبط/خارج از کادر درمانِ تهران.
review = مرتبط ولی مبهم/ناقص.
confidence را ۰ تا ۱۰۰ بده و reason را کوتاه و فارسی بنویس.
برای talent: personName، yearsExperience، isLicensed (پروانهدار) و phone (ارقام لاتین)
را در صورت ذکر پر کن. هر فیلدِ نامشخص = null.
"""; """;
} }
@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>
/// One contact channel for a listing — an applicant («آماده به کار»), a <see cref="Shift"/>, or a
/// <see cref="JobOpening"/>. A listing can carry several — e.g. three phones + an email + an
/// Instagram page. <see cref="Value"/> holds the raw handle / number / address; <see cref="Type"/>
/// decides how it's linked (tel:, mailto:, t.me/…, etc.). Exactly one owner FK is set.
/// </summary>
public class ContactMethod
{
public int Id { get; set; }
// Owner — exactly one of these is non-null.
public int? TalentListingId { get; set; }
public TalentListing? TalentListing { get; set; }
public int? ShiftId { get; set; }
public Shift? Shift { get; set; }
public int? JobOpeningId { get; set; }
public JobOpening? JobOpening { get; set; }
public ContactType Type { get; set; }
[Required, MaxLength(250)]
public string Value { get; set; } = "";
public int SortOrder { get; set; }
}
+23 -3
View File
@@ -27,7 +27,8 @@ public enum ShiftStatus
Open = 0, // باز Open = 0, // باز
Filled = 1, // پر شده Filled = 1, // پر شده
Expired = 2, // منقضی Expired = 2, // منقضی
Cancelled = 3 // لغو شده Cancelled = 3, // لغو شده
Archived = 4 // بایگانی‌شده (پنهان از سایت، نگه‌داری برای تحلیل)
} }
public enum ShiftSource public enum ShiftSource
@@ -69,11 +70,13 @@ public enum EmploymentType
Plan = 3 // طرح Plan = 3 // طرح
} }
/// <summary>What an aggregated/raw listing turned out to be — a shift or a hiring opening.</summary> /// <summary>What an aggregated/raw listing turned out to be — a shift, a hiring opening, or a
/// worker advertising themselves as available («آماده به کار»).</summary>
public enum ListingKind public enum ListingKind
{ {
Shift = 0, Shift = 0,
Job = 1 Job = 1,
Talent = 2
} }
/// <summary>Which listing types a job alert watches.</summary> /// <summary>Which listing types a job alert watches.</summary>
@@ -102,6 +105,23 @@ public enum IngestionMode
Automatic = 1 // موارد تأییدشده (طبق آستانه/هوش مصنوعی) خودکار منتشر می‌شوند Automatic = 1 // موارد تأییدشده (طبق آستانه/هوش مصنوعی) خودکار منتشر می‌شوند
} }
/// <summary>A way to reach an applicant («آماده به کار»). One listing can have several.</summary>
public enum ContactType
{
Mobile = 0, // موبایل
Phone = 1, // تلفن ثابت
Email = 2, // ایمیل
Telegram = 3, // تلگرام
Bale = 4, // بله
WhatsApp = 5, // واتساپ
Instagram = 6, // اینستاگرام
Website = 7, // وب‌سایت / لینک
Other = 8 // سایر
}
/// <summary>What a <see cref="Like"/> points at.</summary>
public enum LikeTargetType { Shift = 0, Job = 1, Talent = 2 }
public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 } public enum ReportTargetType { Shift = 0, Job = 1, Facility = 2, User = 3 }
public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 } public enum ReportStatus { Open = 0, Resolved = 1, Dismissed = 2 }
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>One ingestion run's outcome — kept so admins see a history of what was crawled,
/// how much was found, queued, published, flagged, etc. (with a per-source breakdown).</summary>
public class IngestionRun
{
public int Id { get; set; }
public DateTime RunAt { get; set; } = DateTime.UtcNow;
public int Fetched { get; set; } // total items pulled from all sources
public int Queued { get; set; } // sent to the review queue
public int Published { get; set; } // auto-published
public int Flagged { get; set; } // needs-review
public int Spam { get; set; } // discarded as spam/irrelevant
public int Duplicates { get; set; } // skipped (already seen)
/// <summary>Human-readable per-source breakdown, e.g. "دیوار: یافت ۱۲…؛ مدجابز: یافت ۴۰…".</summary>
[MaxLength(2000)] public string? Detail { get; set; }
}
+8
View File
@@ -40,8 +40,16 @@ public class JobOpening
[MaxLength(500)] [MaxLength(500)]
public string? SourceUrl { get; set; } public string? SourceUrl { get; set; }
// APPROXIMATE coords from the source ad (Divar) for aggregated openings without a facility address.
public double? Lat { get; set; }
public double? Lng { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Contact channels harvested from the source ad (aggregated openings). When empty, the
/// detail page falls back to the facility's phone.</summary>
public ICollection<ContactMethod> Contacts { get; set; } = new List<ContactMethod>();
// Transient: distance (km) when "near me" is active. Not persisted. // Transient: distance (km) when "near me" is active. Not persisted.
[NotMapped] public double? DistanceKm { get; set; } [NotMapped] public double? DistanceKm { get; set; }
} }
+19
View File
@@ -0,0 +1,19 @@
namespace JobsMedical.Web.Models;
/// <summary>
/// A logged-in user's «پسندیدن» of a listing (shift / job / talent). One row per (user, listing);
/// toggling removes it. Polymorphic by <see cref="TargetType"/> + <see cref="TargetId"/> so one table
/// covers all three listing kinds. The count of rows for a target is the public "likes" number.
/// </summary>
public class Like
{
public int Id { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
public LikeTargetType TargetType { get; set; }
public int TargetId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+8
View File
@@ -24,9 +24,17 @@ public class RawListing
public int? LinkedShiftId { get; set; } // شیفت ساخته‌شده از این آگهی public int? LinkedShiftId { get; set; } // شیفت ساخته‌شده از این آگهی
public Shift? LinkedShift { get; set; } public Shift? LinkedShift { get; set; }
public int? LinkedTalentId { get; set; } // آگهی «آماده به کار» ساخته‌شده از این متن
public TalentListing? LinkedTalent { get; set; }
[MaxLength(500)] [MaxLength(500)]
public string? SourceUrl { get; set; } public string? SourceUrl { get; set; }
/// <summary>Approximate coordinates harvested from the source (e.g. Divar's fuzzed map center).
/// Carried through the review queue so a manual publish can still place the facility on the map.</summary>
public double? Lat { get; set; }
public double? Lng { get; set; }
/// <summary>SHA-256 of the normalized text — used to dedupe across ingestion runs.</summary> /// <summary>SHA-256 of the normalized text — used to dedupe across ingestion runs.</summary>
[MaxLength(64)] [MaxLength(64)]
public string? ContentHash { get; set; } public string? ContentHash { get; set; }
+22
View File
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace JobsMedical.Web.Models;
/// <summary>A کادر درمان's rating + review of a facility they worked with (15 stars + comment).
/// One review per user per facility. Shown immediately; an admin can hide/delete.</summary>
public class Review
{
public int Id { get; set; }
public int FacilityId { get; set; }
public Facility Facility { get; set; } = null!;
public int UserId { get; set; }
public User User { get; set; } = null!;
public int Stars { get; set; } // 1..5
[MaxLength(1000)] public string? Comment { get; set; }
public bool IsApproved { get; set; } = true; // admin can hide
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+9
View File
@@ -40,10 +40,19 @@ public class Shift
[MaxLength(500)] [MaxLength(500)]
public string? SourceUrl { get; set; } // لینک منبع در صورت جمع‌آوری از کانال public string? SourceUrl { get; set; } // لینک منبع در صورت جمع‌آوری از کانال
// APPROXIMATE coords from the source ad (Divar's privacy-fuzzed center) for aggregated shifts
// whose facility has no address. Shown as a «محدودهٔ تقریبی» circle, never a precise pin.
public double? Lat { get; set; }
public double? Lng { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<Application> Applications { get; set; } = new List<Application>(); public ICollection<Application> Applications { get; set; } = new List<Application>();
/// <summary>Contact channels harvested from the source ad (aggregated shifts). When empty, the
/// detail page falls back to the facility's phone.</summary>
public ICollection<ContactMethod> Contacts { get; set; } = new List<ContactMethod>();
// Transient: distance (km) from the visitor when "near me" is active. Not persisted. // Transient: distance (km) from the visitor when "near me" is active. Not persisted.
[System.ComponentModel.DataAnnotations.Schema.NotMapped] [System.ComponentModel.DataAnnotations.Schema.NotMapped]
public double? DistanceKm { get; set; } public double? DistanceKm { get; set; }
@@ -0,0 +1,71 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace JobsMedical.Web.Models;
/// <summary>
/// «آماده به کار» — a healthcare worker advertising *themselves* as available for work
/// (the supply side), as opposed to a <see cref="Shift"/>/<see cref="JobOpening"/> posted by a
/// facility (the demand side). Very common in Iranian medical channels ("پرستار آماده همکاری…").
/// There is no facility; the valuable field is the contact <see cref="Phone"/>.
/// </summary>
public class TalentListing
{
public int Id { get; set; }
public int RoleId { get; set; }
public Role Role { get; set; } = null!;
[MaxLength(150)]
public string? PersonName { get; set; } // «دکتر سپیده علیزاده» (best-effort)
public int? YearsExperience { get; set; } // سابقه (سال)
public bool IsLicensed { get; set; } // پروانه‌دار / دارای پروانه
public int CityId { get; set; }
public City City { get; set; } = null!;
public int? DistrictId { get; set; }
public District? District { get; set; }
[MaxLength(150)]
public string? AreaNote { get; set; } // «فقط منطقه ۱» وقتی محله دقیق نگاشت نشد
/// <summary>Searchable keyword tags (space-separated): certs/skills (mmt, icu…), پروانه‌دار,
/// role, city. Drives deep search + tag chips.</summary>
[MaxLength(500)]
public string? Tags { get; set; }
public EmploymentType? Availability { get; set; } // تمام‌وقت/پاره‌وقت/قراردادی...
public Gender Gender { get; set; } = Gender.Any; // جنسیت فرد
// Expected compensation — reuses the shift/job comp model.
public PayType PayType { get; set; } = PayType.Negotiable;
public long? PayAmount { get; set; } // مبلغ مدنظر (تومان)
public int? SharePercent { get; set; } // درصد/سهم درآمد مدنظر («۵۰٪ تسویه»)
[MaxLength(30)]
public string? Phone { get; set; } // primary phone (kept for cards/back-compat)
/// <summary>All contact channels (phones, email, Instagram, Telegram, Bale, website…).</summary>
public ICollection<ContactMethod> Contacts { get; set; } = new List<ContactMethod>();
[MaxLength(2000)]
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; }
// APPROXIMATE coords from the source ad (Divar) — an applicant has no facility, so this is the
// only location we have. Shown as a «محدودهٔ تقریبی» circle (the area they're available in).
public double? Lat { get; set; }
public double? Lng { 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; }
}
@@ -106,7 +106,7 @@ public class LoginModel : PageModel
// Route to the right panel for the account type. // Route to the right panel for the account type.
return user.Role switch return user.Role switch
{ {
UserRole.Admin => RedirectToPage("/Admin/Index"), UserRole.Admin => RedirectToPage("/Admin/Overview"),
UserRole.FacilityAdmin => RedirectToPage("/Employer/Index"), UserRole.FacilityAdmin => RedirectToPage("/Employer/Index"),
_ => RedirectToPage("/Me/Index"), _ => RedirectToPage("/Me/Index"),
}; };
@@ -0,0 +1,45 @@
@page
@model JobsMedical.Web.Pages.Admin.AnalyticsModel
@{
ViewData["Title"] = "آمار و تحلیل";
string Fa(int n) => JalaliDate.ToPersianDigits(n.ToString());
}
<div class="page-head">
<div class="container">
<h1>📊 آمار و تحلیل</h1>
<p class="muted"><a asp-page="/Admin/Overview">← پنل مدیریت</a></p>
</div>
</div>
<div class="container section">
<div class="grid grid-4">
<div class="card card-pad"><div class="muted">کاربران</div><div style="font-size:26px; font-weight:800;">@Fa(Model.Users)</div><div class="muted" style="font-size:12px;">+@Fa(Model.NewUsers7) در ۷ روز</div></div>
<div class="card card-pad"><div class="muted">مراکز</div><div style="font-size:26px; font-weight:800;">@Fa(Model.Facilities)</div><div class="muted" style="font-size:12px;">@Fa(Model.VerifiedFacilities) تأییدشده</div></div>
<div class="card card-pad"><div class="muted">شیفت‌های باز</div><div style="font-size:26px; font-weight:800; color:var(--primary-dark);">@Fa(Model.OpenShifts)</div></div>
<div class="card card-pad"><div class="muted">استخدام‌های باز</div><div style="font-size:26px; font-weight:800; color:var(--primary-dark);">@Fa(Model.OpenJobs)</div></div>
<div class="card card-pad"><div class="muted">اعلام تمایل‌ها</div><div style="font-size:26px; font-weight:800; color:var(--accent);">@Fa(Model.Applications)</div><div class="muted" style="font-size:12px;">+@Fa(Model.NewApps7) در ۷ روز</div></div>
<div class="card card-pad"><div class="muted">نظرات</div><div style="font-size:26px; font-weight:800;">@Fa(Model.Reviews)</div></div>
</div>
<div class="card card-pad" style="margin-top:18px;">
<h3 style="margin-top:0;">اعلام تمایل — ۱۴ روز اخیر</h3>
<div style="display:flex; align-items:flex-end; gap:6px; height:140px; padding-top:10px;">
@foreach (var b in Model.ApplyByDay)
{
var h = (int)(b.Count / (double)Model.MaxBar * 120) + 2;
<div style="flex:1; display:flex; flex-direction:column; align-items:center; gap:4px;">
<div style="width:100%; height:@(h)px; background:var(--primary); border-radius:6px 6px 0 0;" title="@Fa(b.Count)"></div>
<span class="muted" style="font-size:10px;">@Fa(b.Day.Day)</span>
</div>
}
</div>
</div>
<div class="card card-pad" style="margin-top:18px; display:flex; gap:10px; flex-wrap:wrap;">
<a class="btn btn-outline" asp-page="/Admin/Index">صف آگهی‌ها</a>
<a class="btn btn-outline" asp-page="/Admin/Facilities">مراکز</a>
<a class="btn btn-outline" asp-page="/Admin/Reviews">نظرات</a>
<a class="btn btn-outline" asp-page="/Admin/Reports">گزارش‌ها</a>
<a class="btn btn-outline" asp-page="/Admin/Users">کاربران</a>
</div>
</div>
@@ -0,0 +1,56 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Admin;
[Authorize(Roles = "Admin")]
public class AnalyticsModel : PageModel
{
private readonly AppDbContext _db;
public AnalyticsModel(AppDbContext db) => _db = db;
public int Users { get; private set; }
public int Facilities { get; private set; }
public int VerifiedFacilities { get; private set; }
public int OpenShifts { get; private set; }
public int OpenJobs { get; private set; }
public int Applications { get; private set; }
public int Reviews { get; private set; }
public int NewUsers7 { get; private set; }
public int NewApps7 { get; private set; }
public record DayBar(DateOnly Day, int Count);
public List<DayBar> ApplyByDay { get; private set; } = new();
public int MaxBar { get; private set; } = 1;
public async Task OnGetAsync()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
Users = await _db.Users.CountAsync();
Facilities = await _db.Facilities.CountAsync();
VerifiedFacilities = await _db.Facilities.CountAsync(f => f.IsVerified);
OpenShifts = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today);
OpenJobs = await _db.JobOpenings.CountAsync(j => j.Status == ShiftStatus.Open);
Applications = await _db.InterestEvents.CountAsync(e => e.EventType == InterestEventType.Apply);
Reviews = await _db.Reviews.CountAsync();
var since7 = DateTime.UtcNow.AddDays(-7);
NewUsers7 = await _db.Users.CountAsync(u => u.CreatedAt >= since7);
NewApps7 = await _db.InterestEvents.CountAsync(e => e.EventType == InterestEventType.Apply && e.CreatedAt >= since7);
var since14 = DateTime.UtcNow.Date.AddDays(-13);
var stamps = await _db.InterestEvents
.Where(e => e.EventType == InterestEventType.Apply && e.CreatedAt >= since14)
.Select(e => e.CreatedAt).ToListAsync();
var byDay = stamps.GroupBy(d => DateOnly.FromDateTime(d.Date)).ToDictionary(g => g.Key, g => g.Count());
for (var i = 0; i < 14; i++)
{
var day = DateOnly.FromDateTime(since14).AddDays(i);
ApplyByDay.Add(new DayBar(day, byDay.GetValueOrDefault(day)));
}
MaxBar = Math.Max(1, ApplyByDay.Count > 0 ? ApplyByDay.Max(b => b.Count) : 1);
}
}
+131 -3
View File
@@ -9,8 +9,8 @@
<h1>پنل مدیریت — جمع‌آوری و صف آگهی‌ها</h1> <h1>پنل مدیریت — جمع‌آوری و صف آگهی‌ها</h1>
<p class="muted"> <p class="muted">
آگهی‌های جمع‌آوری‌شده از منابع را بررسی، ساختارمند و منتشر کن. آگهی‌های جمع‌آوری‌شده از منابع را بررسی، ساختارمند و منتشر کن.
(@JalaliDate.ToPersianDigits(Model.Queue.Count.ToString()) در صف، (@JalaliDate.ToPersianDigits(Model.QueueTotal.ToString()) در صف،
@JalaliDate.ToPersianDigits(Model.Flagged.Count.ToString()) پرچم‌خورده) @JalaliDate.ToPersianDigits(Model.FlaggedTotal.ToString()) پرچم‌خورده)
· <a asp-page="/Admin/Overview">داشبورد</a> · <a asp-page="/Admin/Overview">داشبورد</a>
· <a asp-page="/Admin/Users">کاربران</a> · <a asp-page="/Admin/Users">کاربران</a>
· <a asp-page="/Admin/Facilities">مراکز</a> · <a asp-page="/Admin/Facilities">مراکز</a>
@@ -40,6 +40,77 @@
<p class="muted" style="font-size:11px; margin:8px 0 0;"> <p class="muted" style="font-size:11px; margin:8px 0 0;">
موتور: واکشی ← حذف تکراری ← تجزیه ← اعتبارسنجی ← صف بررسی. موتور: واکشی ← حذف تکراری ← تجزیه ← اعتبارسنجی ← صف بررسی.
</p> </p>
<form method="post" onsubmit="return confirm('⚠ همه‌ی آیتم‌های جمع‌آوری‌شده (کش) و همه‌ی آگهی‌های منتشرشده از جمع‌آوری حذف می‌شوند (آگهی‌های ثبت‌شده توسط مراکز دست‌نخورده می‌مانند)، سپس همه‌چیز با هوش مصنوعی دوباره جمع‌آوری و افزوده می‌شود. این کار بازگشت‌ناپذیر است. ادامه می‌دهی؟');">
<button type="submit" asp-page-handler="PurgeAndReingest" class="btn btn-outline btn-block" style="margin-top:8px; color:var(--danger); border-color:var(--danger);">
🔄 پاک‌سازی کش و جمع‌آوری مجدد با هوش مصنوعی
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
کش حذف تکراری و آگهی‌های جمع‌آوری‌شده پاک و از نو با AI پردازش می‌شوند. (آگهی‌های مراکز حذف نمی‌شوند.)
</p>
<form method="post" onsubmit="return confirm('آگهی‌های «آماده به کار» از روی متنِ خامِ ذخیره‌شده (بدون واکشی) دوباره با هوش مصنوعی پردازش می‌شوند — برای پاک‌سازی (حذف موارد تکراری، اصلاح نقش/گروه/تگ، افزودن موقعیت تقریبی). شیفت/استخدام دست‌نخورده می‌مانند (برای حفظ SEO). هیچ آیتمی از دست نمی‌رود. در پس‌زمینه اجرا می‌شود. ادامه؟');">
<button type="submit" asp-page-handler="ReprocessStored" class="btn btn-primary btn-block" style="margin-top:10px;">
🧹 پردازش مجددِ «آماده به کار»‌ها (امن برای SEO)
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
توصیه‌شده برای پاک‌سازیِ آماده‌به‌کارها: متنِ خام نگه داشته می‌شود و فقط با منطقِ جدید (یک‌نفر=یک‌آگهی، نقش پایه، گروه ثابت، تگ تمیز، موقعیت تقریبی) بازساخته می‌شوند. صفحاتِ «آماده به کار» ایندکس نمی‌شوند، پس آدرسِ ایندکس‌شده‌ای تغییر نمی‌کند؛ شیفت/استخدام به‌مرور با ایمیجستِ تازه پاک می‌شوند.
</p>
<form method="post" onsubmit="return confirm('برای آگهی‌های جمع‌آوری‌شدهٔ تهران که موقعیت روی نقشه ندارند، از روی متنِ آگهی محلهٔ تقریبی پیدا و مختصات تنظیم می‌شود. شناسه و آدرس صفحات تغییر نمی‌کند (امن برای SEO). ادامه؟');">
<button type="submit" asp-page-handler="BackfillCoords" class="btn btn-primary btn-block" style="margin-top:10px;">
📍 تکمیل موقعیتِ نقشه برای آگهی‌های موجود
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
شیفت/استخدام/آماده‌به‌کارِ جمع‌آوری‌شده‌ای که مختصات ندارند، از روی محلهٔ ذکرشده در متنِ آگهی روی نقشه قرار می‌گیرند (محدودهٔ تقریبی). فقط مختصاتِ خالی پر می‌شود؛ موقعیتِ واقعیِ مراکز دست‌نخورده می‌ماند.
</p>
<form method="post">
<button type="submit" asp-page-handler="BackfillPay" class="btn btn-primary btn-block" style="margin-top:10px;">
💰 استخراجِ حقوق برای آگهی‌های «توافقی»
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
آگهی‌هایی که حقوقشان «توافقی» است ولی در متن مبلغ دارند (مثل «۴۰ تا ۵۰ تومان» = میلیون)، مبلغشان استخراج و ثبت می‌شود (درجا، بدون تغییر شناسه/آدرس).
</p>
<form method="post" onsubmit="return confirm('آگهی‌های جمع‌آوری‌شدهٔ شیفت/استخدام که اکنون خارج از حوزه‌اند (خدمات منزل/نظافت، تبلیغاتی/آموزشی، اسپم) و استخدام‌های تکراری «بایگانی» می‌شوند: از سایت پنهان می‌شوند ولی ردیفشان نگه داشته می‌شود (قابل بازگشت). آگهی‌های معتبر و شناسه/آدرسشان دست‌نخورده می‌ماند. ادامه؟');">
<button type="submit" asp-page-handler="PurgeInvalid" class="btn btn-outline btn-block" style="margin-top:10px; color:var(--danger); border-color:var(--danger);">
🧽 بایگانیِ درجای آگهی‌های خارج از حوزه و تکراری (شیفت/استخدام)
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
فقط آگهی‌هایی که با صافیِ فعلی «خارج از حوزه» تشخیص داده می‌شوند (نه صرفاً ناقص) و استخدام‌های تکراری بایگانی می‌شوند (وضعیت «بایگانی»، نه حذف). آگهی‌های معتبر دست‌نخورده‌اند، پس آدرسِ ایندکس‌شده‌شان تغییر نمی‌کند؛ صفحهٔ موارد بایگانی‌شده ۴۱۰ Gone می‌دهد تا گوگل تمیز حذفشان کند.
</p>
<form method="post" onsubmit="return confirm('مراکز درمانیِ تکراری ادغام و مراکزِ بی‌نام/نامعتبر (مثل «بیمارستان هستم» یا «از مدجابز») حذف می‌شوند؛ آگهی‌هایشان به مرکزِ معتبر یا «نامشخص» منتقل می‌شود. مراکزِ ثبت‌شده توسط کارفرما یا تأییدشده دست‌نخورده می‌مانند. ادامه؟');">
<button type="submit" asp-page-handler="CleanFacilities" class="btn btn-primary btn-block" style="margin-top:10px;">
🏥 ادغام مراکز تکراری و حذف مراکز بی‌نام
</button>
</form>
<form method="post">
<button type="submit" asp-page-handler="RecorrectRoles" class="btn btn-primary btn-block" style="margin-top:10px;">
🩺 اصلاح نقشِ آگهی‌های «پزشک عمومی» (دندانپزشک/متخصص و …)
</button>
</form>
<form method="post" onsubmit="return confirm('نقش‌های تکراری/ترکیبی/غلط‌املایی (مثل «پرستار کودک» سه‌تایی، «پرستار و بهیار»، «بیهیار») در نقش‌های اصلی ادغام و حذف می‌شوند؛ آگهی‌هایشان به نقشِ معتبر منتقل می‌شود. ادامه؟');">
<button type="submit" asp-page-handler="MergeRoles" class="btn btn-primary btn-block" style="margin-top:10px;">
🏷️ ادغام نقش‌های تکراری/ترکیبی/غلط‌املایی
</button>
</form>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
نقش‌های هم‌معنا (تکراری، ترکیبی مثل «پرستار و بهیار»، یا غلط‌املایی مثل «بیهیار») در یک نقشِ پایه ادغام می‌شوند تا فهرستِ نقش‌ها تمیز شود. مدیریتِ دستی در <a asp-page="/Admin/Roles">نقش‌ها</a>.
</p>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
آگهی‌هایی که هوش مصنوعی به اشتباه «پزشک عمومی» زده ولی متنشان نقش دیگری دارد، از روی متن اصلاح می‌شوند (درجا، بدون تغییر شناسه/آدرس).
</p>
<p class="muted" style="font-size:11px; margin:6px 0 0;">
مراکز تکراری (با تطبیقِ فارسی) در یک رکورد ادغام و مراکزِ بدونِ نامِ واقعی به «نامشخص» منتقل می‌شوند. آگهی‌ها حفظ می‌شوند؛ فقط مراکزِ جمع‌آوری‌شده و مدیریت‌نشده پاک می‌شوند.
</p>
<hr style="border:none; border-top:1px solid var(--line); margin:16px 0;" /> <hr style="border:none; border-top:1px solid var(--line); margin:16px 0;" />
@@ -62,6 +133,43 @@
</aside> </aside>
<div> <div>
@if (Model.Runs.Count > 0)
{
<h2 style="font-size:20px; margin-top:0; display:flex; justify-content:space-between; align-items:center;">
تاریخچه جمع‌آوری
<a class="btn btn-outline" style="padding:5px 12px; font-size:13px;" asp-page="/Admin/Ingested">همه نتایج جمع‌آوری ←</a>
</h2>
<div class="card card-pad" style="margin-bottom:18px; overflow-x:auto;">
<table style="width:100%; border-collapse:collapse; font-size:13px; white-space:nowrap;">
<thead>
<tr style="text-align:start; color:var(--muted);">
<th style="padding:6px 8px;">زمان</th>
<th style="padding:6px 8px;">یافت‌شده</th>
<th style="padding:6px 8px;">صف</th>
<th style="padding:6px 8px;">منتشر</th>
<th style="padding:6px 8px;">پرچم</th>
<th style="padding:6px 8px;">اسپم</th>
<th style="padding:6px 8px;">تکراری</th>
</tr>
</thead>
<tbody>
@foreach (var run in Model.Runs)
{
<tr style="border-top:1px solid var(--line);" title="@run.Detail">
<td style="padding:6px 8px;">@JalaliDate.DateTimeLabel(run.RunAt)</td>
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Fetched.ToString())</td>
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Queued.ToString())</td>
<td style="padding:6px 8px; color:var(--primary-dark); font-weight:700;">@JalaliDate.ToPersianDigits(run.Published.ToString())</td>
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Flagged.ToString())</td>
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Spam.ToString())</td>
<td style="padding:6px 8px;">@JalaliDate.ToPersianDigits(run.Duplicates.ToString())</td>
</tr>
}
</tbody>
</table>
<p class="muted" style="font-size:11px; margin:8px 0 0;">جزئیات هر منبع را با نگه‌داشتن نشانگر روی هر ردیف ببین. لاگ کامل: <code dir="ltr">docker logs hamkadr_api</code></p>
</div>
}
<h2 style="font-size:20px; margin-top:0;">صف بررسی</h2> <h2 style="font-size:20px; margin-top:0;">صف بررسی</h2>
@if (Model.Queue.Count == 0) @if (Model.Queue.Count == 0)
{ {
@@ -73,9 +181,19 @@
{ {
<partial name="_RawListingRow" model="r" /> <partial name="_RawListingRow" model="r" />
} }
@if (Model.QueuePages > 1)
{
<div class="row" style="display:flex; gap:10px; justify-content:center; align-items:center; margin-top:14px;">
@if (Model.QueuePage > 1)
{ <a class="btn btn-outline" asp-route-q="@(Model.QueuePage - 1)" asp-route-f="@Model.FlaggedPage">→ قبلی</a> }
<span class="muted">صفحه @JalaliDate.ToPersianDigits(Model.QueuePage.ToString()) از @JalaliDate.ToPersianDigits(Model.QueuePages.ToString())</span>
@if (Model.QueuePage < Model.QueuePages)
{ <a class="btn btn-outline" asp-route-q="@(Model.QueuePage + 1)" asp-route-f="@Model.FlaggedPage">بعدی ←</a> }
</div>
}
} }
@if (Model.Flagged.Count > 0) @if (Model.FlaggedTotal > 0)
{ {
<h2 style="font-size:20px; margin-top:28px;">پرچم‌خورده (ناقص/مشکوک)</h2> <h2 style="font-size:20px; margin-top:28px;">پرچم‌خورده (ناقص/مشکوک)</h2>
<p class="muted" style="font-size:13px;">اعتبارسنجی این‌ها را کامل ندانست؛ در صورت صحت می‌توانی منتشرشان کنی.</p> <p class="muted" style="font-size:13px;">اعتبارسنجی این‌ها را کامل ندانست؛ در صورت صحت می‌توانی منتشرشان کنی.</p>
@@ -83,6 +201,16 @@
{ {
<partial name="_RawListingRow" model="r" /> <partial name="_RawListingRow" model="r" />
} }
@if (Model.FlaggedPages > 1)
{
<div class="row" style="display:flex; gap:10px; justify-content:center; align-items:center; margin-top:14px;">
@if (Model.FlaggedPage > 1)
{ <a class="btn btn-outline" asp-route-q="@Model.QueuePage" asp-route-f="@(Model.FlaggedPage - 1)">→ قبلی</a> }
<span class="muted">صفحه @JalaliDate.ToPersianDigits(Model.FlaggedPage.ToString()) از @JalaliDate.ToPersianDigits(Model.FlaggedPages.ToString())</span>
@if (Model.FlaggedPage < Model.FlaggedPages)
{ <a class="btn btn-outline" asp-route-q="@Model.QueuePage" asp-route-f="@(Model.FlaggedPage + 1)">بعدی ←</a> }
</div>
}
} }
</div> </div>
</div> </div>
+148 -5
View File
@@ -13,25 +13,37 @@ public class IndexModel : PageModel
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IngestionService _ingest; private readonly IngestionService _ingest;
private readonly IServiceScopeFactory _scopes;
private readonly ILogger<IndexModel> _log;
public IndexModel(AppDbContext db, IngestionService ingest) public IndexModel(AppDbContext db, IngestionService ingest, IServiceScopeFactory scopes, ILogger<IndexModel> log)
{ {
_db = db; _db = db;
_ingest = ingest; _ingest = ingest;
_scopes = scopes;
_log = log;
} }
public List<RawListing> Queue { get; private set; } = new(); public List<RawListing> Queue { get; private set; } = new();
public List<RawListing> Flagged { get; private set; } = new(); public List<RawListing> Flagged { get; private set; } = new();
public const int PageSize = 20;
public int QueuePage { get; private set; } = 1;
public int QueueTotal { get; private set; }
public int FlaggedPage { get; private set; } = 1;
public int FlaggedTotal { get; private set; }
public int QueuePages => Math.Max(1, (int)Math.Ceiling(QueueTotal / (double)PageSize));
public int FlaggedPages => Math.Max(1, (int)Math.Ceiling(FlaggedTotal / (double)PageSize));
public IReadOnlyList<string> SourceNames { get; private set; } = new List<string>(); public IReadOnlyList<string> SourceNames { get; private set; } = new List<string>();
public int PublishedShifts { get; private set; } public int PublishedShifts { get; private set; }
public int PublishedJobs { get; private set; } public int PublishedJobs { get; private set; }
public List<IngestionRun> Runs { get; private set; } = new();
[BindProperty] public string? SourceChannel { get; set; } [BindProperty] public string? SourceChannel { get; set; }
[BindProperty] public string? RawText { get; set; } [BindProperty] public string? RawText { get; set; }
[TempData] public string? IngestMessage { get; set; } [TempData] public string? IngestMessage { get; set; }
public async Task OnGetAsync() => await LoadAsync(); public async Task OnGetAsync(int q = 1, int f = 1) => await LoadAsync(q, f);
public async Task<IActionResult> OnPostAddAsync() public async Task<IActionResult> OnPostAddAsync()
{ {
@@ -48,6 +60,14 @@ public class IndexModel : PageModel
return RedirectToPage(); return RedirectToPage();
} }
/// <summary>Fast triage — reject (discard) a queued/flagged item without opening the review page.</summary>
public async Task<IActionResult> OnPostQuickDiscardAsync(int id)
{
var raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
if (raw is not null) { raw.Status = RawListingStatus.Discarded; await _db.SaveChangesAsync(); }
return RedirectToPage();
}
public async Task<IActionResult> OnPostRunIngestionAsync() public async Task<IActionResult> OnPostRunIngestionAsync()
{ {
var s = await _ingest.RunAsync(); var s = await _ingest.RunAsync();
@@ -56,16 +76,139 @@ public class IndexModel : PageModel
return RedirectToPage(); return RedirectToPage();
} }
private async Task LoadAsync() /// <summary>
/// DESTRUCTIVE rebuild, in two distinct deletes:
/// 1. The DEDUPE CACHE — ALL RawListings, including any added via «افزودن دستی». These are not
/// published content; they're the crawl/staging rows whose ContentHash blocks re-ingesting
/// the same ad. Wiping them lets everything be re-fetched and re-judged by the AI.
/// 2. AGGREGATED listings only — Shifts/JobOpenings/TalentListings with Source==Aggregated, i.e.
/// produced by ingestion. Employer/admin-posted listings (Source==Direct) are left untouched.
/// Then re-fetch everything and re-run it through the (now AI-enabled) pipeline.
/// RawListings are deleted first so their LinkedShift/LinkedTalent FKs (SetNull) don't dangle;
/// DB cascade clears ContactMethods / Applications / InterestEvents when the posts are deleted.
/// </summary>
public async Task<IActionResult> OnPostPurgeAndReingestAsync()
{ {
int rawCount, shifts, jobs, talent;
await using (var tx = await _db.Database.BeginTransactionAsync())
{
rawCount = await _db.RawListings.ExecuteDeleteAsync(); // clear dedupe cache
shifts = await _db.Shifts.Where(s => s.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
jobs = await _db.JobOpenings.Where(j => j.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
talent = await _db.TalentListings.Where(t => t.Source == ShiftSource.Aggregated).ExecuteDeleteAsync();
await tx.CommitAsync();
}
var s = await _ingest.RunAsync(); // fresh fetch → AI audit → publish/queue
IngestMessage = $"پاک‌سازی شد (حذف: {rawCount} آیتم کش، {shifts} شیفت، {jobs} استخدام، {talent} آماده‌به‌کارِ جمع‌آوری‌شده). " +
$"جمع‌آوری مجدد: {s.TotalPublished} منتشر، {s.TotalQueued} در صف، {s.TotalFlagged} پرچم، {s.TotalSpam} اسپم، {s.TotalDuplicates} تکراری.";
return RedirectToPage();
}
/// <summary>
/// Clean up EXISTING aggregated content by re-running the current pipeline over the stored raw
/// text — no re-fetch, so nothing is lost to sources only exposing recent posts. Long-running
/// (one AI call per item), so it runs on a background scope and returns immediately; the result
/// shows up as a new row in the «تاریخچهٔ اجرا» log when it finishes.
/// </summary>
public IActionResult OnPostReprocessStored()
{
_ = Task.Run(async () =>
{
using var scope = _scopes.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<IngestionService>();
var log = scope.ServiceProvider.GetRequiredService<ILogger<IndexModel>>();
// talentOnly: «آماده به کار» is NoIndex/Disallow → rebuilding it doesn't churn any indexed
// URL. Shift/Job detail pages ARE indexed, so they're left to self-clean via turnover.
try { await svc.ReprocessAsync(talentOnly: true); }
catch (Exception ex) { log.LogError(ex, "Background reprocess failed"); }
});
IngestMessage = "پردازش مجدد آیتم‌های ذخیره‌شده در پس‌زمینه آغاز شد. نتیجه پس از اتمام در «تاریخچهٔ اجرا» نمایش داده می‌شود (بسته به تعداد آیتم‌ها و سرعت هوش مصنوعی، چند دقیقه طول می‌کشد).";
return RedirectToPage();
}
/// <summary>
/// Fill missing map coordinates on existing aggregated Tehran listings from their stored ad text
/// (TehranGeo). In place — no AI calls, no re-fetch, and crucially no delete/recreate, so indexed
/// shift/job URLs keep their IDs. Fast (pure DB + string matching), so it runs inline.
/// </summary>
public async Task<IActionResult> OnPostBackfillCoordsAsync()
{
var n = await _ingest.BackfillCoordsAsync();
IngestMessage = $"مختصات تقریبی برای {n} آگهی جمع‌آوری‌شده از روی متن آگهی تکمیل شد (بدون تغییر شناسه یا آدرس صفحه).";
return RedirectToPage();
}
/// <summary>Fill missing salary on existing aggregated listings from the stored text (now reading
/// Iranian «X تومان» = millions shorthand). In place — no AI, no ID/URL change.</summary>
public async Task<IActionResult> OnPostBackfillPayAsync()
{
var n = await _ingest.BackfillPayAsync();
IngestMessage = $"حقوق برای {n} آگهیِ «توافقی» که در متن مبلغ داشت (مثل «۴۰ تا ۵۰ تومان») استخراج و ثبت شد. بدون تغییر شناسه/آدرس.";
return RedirectToPage();
}
/// <summary>
/// In-place cleanup of existing aggregated jobs/shifts: ARCHIVE (hide, keep the row) only the
/// out-of-scope ones (domestic-helper / promotional / spam) per the current validator, plus
/// near-duplicate job reposts. Archived pages drop from lists + sitemap and return 410 Gone.
/// Valid listings keep their IDs/URLs. Reversible, no re-fetch, no AI — runs inline.
/// </summary>
public async Task<IActionResult> OnPostPurgeInvalidAsync()
{
var (archived, deduped) = await _ingest.PurgeInvalidAggregatedAsync();
IngestMessage = $"بایگانیِ درجا: {archived} آگهیِ خارج از حوزه (خدمات منزل/تبلیغاتی/اسپم) و {deduped} استخدامِ تکراری از سایت پنهان شد (وضعیت «بایگانی»؛ ردیف نگه داشته شد و قابل بازگشت است؛ صفحه‌شان ۴۱۰ Gone می‌دهد). آگهی‌های معتبر و شناسه/آدرسشان دست‌نخورده ماند.";
return RedirectToPage();
}
/// <summary>
/// Clean up the crawl-generated facility table: merge Persian-fuzzy duplicate facilities and fold
/// junk-named ones («بیمارستان هستم»، «... از مدجابز»، bare «کلینیک») into the shared placeholder,
/// repointing their listings first. Employer-owned / verified facilities are never touched.
/// </summary>
public async Task<IActionResult> OnPostCleanFacilitiesAsync()
{
var (merged, cleaned) = await _ingest.MergeAndCleanFacilitiesAsync();
IngestMessage = $"پاک‌سازی مراکز: {merged} مرکزِ تکراری ادغام و {cleaned} مرکزِ بی‌نام/نامعتبر حذف شد (آگهی‌هایشان به مرکزِ معتبر یا «نامشخص» منتقل شد). مراکز ثبت‌شده توسط کارفرما/تأییدشده دست‌نخورده ماند.";
return RedirectToPage();
}
/// <summary>Fix existing aggregated listings the AI mislabeled «پزشک عمومی» (dentist/specialist/…)
/// in place from their stored text — no AI, no ID/URL change.</summary>
public async Task<IActionResult> OnPostRecorrectRolesAsync()
{
var n = await _ingest.RecorrectDoctorRolesAsync();
IngestMessage = $"اصلاح نقش: {n} آگهیِ «پزشک عمومی» که در واقع نقش دیگری بود (دندانپزشک، متخصص و …) از روی متن آگهی اصلاح شد. بدون تغییر شناسه یا آدرس صفحه.";
return RedirectToPage();
}
/// <summary>Auto-merge duplicate/compound/typo roles minted by the dynamic taxonomy
/// («پرستار کودک» ×3، «پرستار و بهیار»، «بیهیار»→بهیار), repointing all listings first.</summary>
public async Task<IActionResult> OnPostMergeRolesAsync()
{
var n = await _ingest.MergeDuplicateRolesAsync();
IngestMessage = $"پاک‌سازی نقش‌ها: {n} نقشِ تکراری/ترکیبی/غلط‌املایی در نقش‌های اصلی ادغام شد (آگهی‌هایشان منتقل شد). فهرست نقش‌ها اکنون تمیزتر است.";
return RedirectToPage();
}
private async Task LoadAsync(int q = 1, int f = 1)
{
QueueTotal = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.New);
QueuePage = Math.Clamp(q, 1, QueuePages);
Queue = await _db.RawListings Queue = await _db.RawListings
.Where(r => r.Status == RawListingStatus.New) .Where(r => r.Status == RawListingStatus.New)
.OrderByDescending(r => r.Confidence).ThenByDescending(r => r.FetchedAt).ToListAsync(); .OrderByDescending(r => r.Confidence).ThenByDescending(r => r.FetchedAt)
.Skip((QueuePage - 1) * PageSize).Take(PageSize).ToListAsync();
FlaggedTotal = await _db.RawListings.CountAsync(r => r.Status == RawListingStatus.Flagged);
FlaggedPage = Math.Clamp(f, 1, FlaggedPages);
Flagged = await _db.RawListings Flagged = await _db.RawListings
.Where(r => r.Status == RawListingStatus.Flagged) .Where(r => r.Status == RawListingStatus.Flagged)
.OrderByDescending(r => r.FetchedAt).ToListAsync(); .OrderByDescending(r => r.FetchedAt)
.Skip((FlaggedPage - 1) * PageSize).Take(PageSize).ToListAsync();
SourceNames = _ingest.SourceNames; SourceNames = _ingest.SourceNames;
PublishedShifts = await _db.Shifts.CountAsync(s => s.Source != ShiftSource.Direct); PublishedShifts = await _db.Shifts.CountAsync(s => s.Source != ShiftSource.Direct);
PublishedJobs = await _db.JobOpenings.CountAsync(); PublishedJobs = await _db.JobOpenings.CountAsync();
Runs = await _db.IngestionRuns.OrderByDescending(r => r.RunAt).Take(15).ToListAsync();
} }
} }
@@ -0,0 +1,114 @@
@page
@model JobsMedical.Web.Pages.Admin.IngestedModel
@{
ViewData["Title"] = "نتایج جمع‌آوری";
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
int C(JobsMedical.Web.Models.RawListingStatus s) => Model.Counts.GetValueOrDefault(s);
string Pill(string key, string label, int count) =>
$"<a class=\"ing-pill {(Model.Status == key || (Model.Status is null && key == "all") ? "active" : "")}\" href=\"?status={key}\">{label} ({P(count)})</a>";
}
<div class="page-head">
<div class="container">
<h1>نتایج جمع‌آوری</h1>
<p class="muted"><a asp-page="/Admin/Index">← صف بررسی</a> — همه‌ی آگهی‌های جمع‌آوری‌شده و وضعیت هرکدام.</p>
</div>
</div>
<div class="container section">
@if (Model.Message is not null)
{
<div class="alert alert-success">✓ @Model.Message</div>
}
@{ int publishedCount = Model.Counts.GetValueOrDefault(JobsMedical.Web.Models.RawListingStatus.Normalized); }
@if (publishedCount > 0)
{
<form method="post" asp-page-handler="ArchivePublished"
onsubmit="return confirm('همه آگهی‌های منتشرشده از جمع‌آوری از سایت پنهان (بایگانی) می‌شوند. داده‌ها حذف نمی‌شوند و برای تحلیل باقی می‌مانند. ادامه می‌دهی؟');"
style="margin-bottom:14px;">
<button type="submit" class="btn btn-outline">
🗄 بایگانی گروهی همه‌ی منتشرشده‌ها (@JalaliDate.ToPersianDigits(publishedCount.ToString()))
</button>
<span class="muted" style="font-size:12px; margin-inline-start:8px;">از سایت پنهان می‌کند ولی هیچ‌چیز حذف نمی‌شود (آرشیو برای تحلیل).</span>
</form>
}
@if (Model.SourceBreakdown.Count > 0)
{
<div class="card card-pad" style="margin-bottom:14px;">
<strong style="display:block; margin-bottom:8px;">📊 به تفکیک منبع</strong>
<table style="width:100%; border-collapse:collapse; font-size:13.5px;">
<thead>
<tr style="color:var(--muted);">
<th style="text-align:start; padding:4px 0;">منبع</th>
<th style="text-align:start;">منتشرشده</th>
<th style="text-align:start;">کل دریافت</th>
</tr>
</thead>
<tbody>
@foreach (var s in Model.SourceBreakdown)
{
<tr style="border-top:1px solid var(--line);">
<td style="padding:6px 0;"><strong>@s.Source</strong></td>
<td><span class="badge badge-verified">@P(s.Published)</span></td>
<td class="muted">@P(s.Total)</td>
</tr>
}
</tbody>
</table>
</div>
}
<div class="ing-filters">
@Html.Raw(Pill("all", "همه", Model.Counts.Values.Sum()))
@Html.Raw(Pill("new", "در صف", C(JobsMedical.Web.Models.RawListingStatus.New)))
@Html.Raw(Pill("flagged", "پرچم‌خورده", C(JobsMedical.Web.Models.RawListingStatus.Flagged)))
@Html.Raw(Pill("published", "منتشرشده", C(JobsMedical.Web.Models.RawListingStatus.Normalized)))
@Html.Raw(Pill("discarded", "ردشده/اسپم", C(JobsMedical.Web.Models.RawListingStatus.Discarded)))
</div>
<p class="muted" style="font-size:13px;">@P(Model.Total) نتیجه (نمایش حداکثر ۲۰۰ مورد اخیر).</p>
@if (Model.Items.Count == 0)
{
<div class="card empty-state">موردی با این فیلتر نیست.</div>
}
else
{
foreach (var r in Model.Items)
{
var (cls, label) = r.Status switch
{
JobsMedical.Web.Models.RawListingStatus.New => ("badge-day", "در صف"),
JobsMedical.Web.Models.RawListingStatus.Flagged => ("badge-type", "پرچم‌خورده"),
JobsMedical.Web.Models.RawListingStatus.Normalized => ("badge-verified", "منتشر شد"),
_ => ("badge-gender", "رد شد"),
};
<div class="card card-pad" style="margin-bottom:10px;">
<div class="row" style="display:flex; justify-content:space-between; align-items:center; gap:8px; flex-wrap:wrap;">
<strong>@r.SourceChannel</strong>
<span style="display:flex; gap:6px; align-items:center;">
<span class="badge @cls">@label</span>
<span class="badge badge-type">اطمینان @P(r.Confidence)٪</span>
<span class="muted" style="font-size:12px;">@JalaliDate.DateTimeLabel(r.FetchedAt)</span>
</span>
</div>
<p style="margin:8px 0; white-space:pre-wrap; font-size:13.5px;">@(r.RawText.Length > 320 ? r.RawText.Substring(0,320) + "…" : r.RawText)</p>
@if (!string.IsNullOrEmpty(r.ValidationNotes)) { <p class="muted" style="font-size:12px; margin:0 0 6px;">⚠ @r.ValidationNotes</p> }
@if (r.Status == JobsMedical.Web.Models.RawListingStatus.New || r.Status == JobsMedical.Web.Models.RawListingStatus.Flagged)
{
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Admin/Review" asp-route-id="@r.Id">بررسی و انتشار ←</a>
}
else if (r.LinkedShiftId is int sid)
{
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Shifts/Details" asp-route-id="@sid" target="_blank">مشاهده آگهی منتشرشده</a>
}
else if (r.LinkedTalentId is int tid)
{
<a class="btn btn-outline" style="padding:4px 12px; font-size:13px;" asp-page="/Talent/Details" asp-route-id="@tid" target="_blank">مشاهده «آماده به کار» منتشرشده</a>
}
</div>
}
}
</div>
@@ -0,0 +1,99 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Admin;
/// <summary>Every crawled item with its outcome (queued / published / flagged / discarded),
/// filterable by status and source — the full audit trail of ingestion.</summary>
[Authorize(Roles = "Admin")]
public class IngestedModel : PageModel
{
private readonly AppDbContext _db;
public IngestedModel(AppDbContext db) => _db = db;
public List<RawListing> Items { get; private set; } = new();
public int Total { get; private set; }
public Dictionary<RawListingStatus, int> Counts { get; private set; } = new();
public List<SourceStat> SourceBreakdown { get; private set; } = new();
[TempData] public string? Message { get; set; }
/// <summary>Per-source tally: how many crawled vs how many actually published.</summary>
public record SourceStat(string Source, int Total, int Published);
[BindProperty(SupportsGet = true)] public string? Status { get; set; } // new|flagged|published|discarded|all
[BindProperty(SupportsGet = true)] public string? Source { get; set; }
public async Task OnGetAsync()
{
Counts = await _db.RawListings.GroupBy(r => r.Status)
.Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
// Per-source breakdown — group exact SourceChannel rows then fold into source "families"
// (تلگرام/ch → تلگرام, وب‌سایت (host) → وب‌سایت) so the table reads one row per source.
var bySource = await _db.RawListings.GroupBy(r => r.SourceChannel)
.Select(g => new
{
Source = g.Key,
Total = g.Count(),
Published = g.Count(x => x.Status == RawListingStatus.Normalized),
})
.ToListAsync();
SourceBreakdown = bySource
.GroupBy(x => SourceFamily(x.Source))
.Select(g => new SourceStat(g.Key, g.Sum(x => x.Total), g.Sum(x => x.Published)))
.OrderByDescending(s => s.Published).ThenByDescending(s => s.Total)
.ToList();
var q = _db.RawListings.AsNoTracking().AsQueryable();
var st = Status?.ToLowerInvariant() switch
{
"new" => (RawListingStatus?)RawListingStatus.New,
"flagged" => RawListingStatus.Flagged,
"published" => RawListingStatus.Normalized,
"discarded" => RawListingStatus.Discarded,
_ => null,
};
if (st is not null) q = q.Where(r => r.Status == st);
if (!string.IsNullOrWhiteSpace(Source)) q = q.Where(r => r.SourceChannel.Contains(Source));
Total = await q.CountAsync();
Items = await q.OrderByDescending(r => r.FetchedAt).Take(200).ToListAsync();
}
/// <summary>Collapse a channel label to its source family: "تلگرام/nurses" → "تلگرام",
/// "وب‌سایت (medjobs.ir)" → "وب‌سایت". Divar/Bale/Medjobs already have no suffix.</summary>
private static string SourceFamily(string? channel)
{
if (string.IsNullOrWhiteSpace(channel)) return "نامشخص";
var cut = channel.IndexOfAny(new[] { '/', '(' });
return (cut > 0 ? channel[..cut] : channel).Trim();
}
/// <summary>
/// ARCHIVE (never delete) everything published from ingestion: the aggregated Shift/Job/Talent
/// posts are flipped to Archived (hidden from the site but kept for analytics); the raw crawl
/// rows are retained untouched as the permanent archive.
/// </summary>
public async Task<IActionResult> OnPostArchivePublishedAsync()
{
var shifts = await _db.Shifts
.Where(s => s.Source == ShiftSource.Aggregated && s.Status != ShiftStatus.Archived)
.ExecuteUpdateAsync(u => u.SetProperty(s => s.Status, ShiftStatus.Archived));
var jobs = await _db.JobOpenings
.Where(j => j.Source == ShiftSource.Aggregated && j.Status != ShiftStatus.Archived)
.ExecuteUpdateAsync(u => u.SetProperty(j => j.Status, ShiftStatus.Archived));
var talent = await _db.TalentListings
.Where(t => t.Source == ShiftSource.Aggregated && t.Status != ShiftStatus.Archived)
.ExecuteUpdateAsync(u => u.SetProperty(t => t.Status, ShiftStatus.Archived));
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
Message = $"بایگانی شد (از سایت پنهان، در پایگاه‌داده نگه‌داری شد): {P(shifts)} شیفت، {P(jobs)} استخدام، {P(talent)} آماده‌به‌کار.";
return RedirectToPage(new { Status });
}
}
@@ -14,6 +14,8 @@
<a asp-page="/Admin/Facilities">مراکز</a> · <a asp-page="/Admin/Facilities">مراکز</a> ·
<a asp-page="/Admin/Reports">گزارش‌ها</a> · <a asp-page="/Admin/Reports">گزارش‌ها</a> ·
<a asp-page="/Admin/Broadcast">ارسال اعلان</a> · <a asp-page="/Admin/Broadcast">ارسال اعلان</a> ·
<a asp-page="/Admin/Reviews">نظرات</a> ·
<a asp-page="/Admin/Analytics">آمار</a> ·
<a asp-page="/Admin/Settings">تنظیمات</a> <a asp-page="/Admin/Settings">تنظیمات</a>
</p> </p>
</div> </div>
@@ -35,7 +35,7 @@
<span class="badge @(r.Status == ReportStatus.Open ? "badge-day" : "badge-type")">@StatusLabel(r.Status)</span> <span class="badge @(r.Status == ReportStatus.Open ? "badge-day" : "badge-type")">@StatusLabel(r.Status)</span>
</div> </div>
<p style="margin:8px 0;">«@r.Reason»</p> <p style="margin:8px 0;">«@r.Reason»</p>
<div class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(r.CreatedAt)) · گزارش‌دهنده: @(r.ReporterUserId is not null ? "کاربر #" + r.ReporterUserId : "مهمان")</div> <div class="muted" style="font-size:12px;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(JalaliDate.ToTehran(r.CreatedAt))) · گزارش‌دهنده: @(r.ReporterUserId is not null ? "کاربر #" + r.ReporterUserId : "مهمان")</div>
<div style="display:flex; gap:8px; margin-top:10px;"> <div style="display:flex; gap:8px; margin-top:10px;">
<a class="btn btn-outline" style="padding:6px 12px;" href="@JobsMedical.Web.Pages.Admin.ReportsModel.TargetUrl(r)" target="_blank">مشاهده مورد</a> <a class="btn btn-outline" style="padding:6px 12px;" href="@JobsMedical.Web.Pages.Admin.ReportsModel.TargetUrl(r)" target="_blank">مشاهده مورد</a>
@if (r.Status == ReportStatus.Open) @if (r.Status == ReportStatus.Open)
+75 -9
View File
@@ -10,11 +10,25 @@
</div> </div>
<div class="container section"> <div class="container section">
@if (Model.Error is not null)
{
<div class="alert alert-error" style="margin-bottom:16px;">⚠ @Model.Error</div>
}
<div class="detail-grid"> <div class="detail-grid">
<div> <div>
<div class="card card-pad"> <div class="card card-pad">
<h3 style="margin-top:0;">متن خام</h3> <h3 style="margin-top:0;">متن خام</h3>
<p style="white-space:pre-wrap; margin:0;">@r.RawText</p> <p style="white-space:pre-wrap; margin:0;">@r.RawText</p>
@if (!string.IsNullOrWhiteSpace(r.SourceUrl))
{
<p style="margin:12px 0 0;">
<a class="btn btn-outline" href="@r.SourceUrl" target="_blank" rel="noopener noreferrer">🔗 مشاهده آگهی در منبع (@r.SourceChannel)</a>
</p>
}
else
{
<p class="muted" style="font-size:12px; margin:12px 0 0;">لینک منبع برای این آگهی ثبت نشده است.</p>
}
</div> </div>
@if (Model.Parsed is not null) @if (Model.Parsed is not null)
@@ -42,25 +56,33 @@
<select name="Kind" id="kindSelect"> <select name="Kind" id="kindSelect">
<option value="0" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Shift)">شیفت</option> <option value="0" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Shift)">شیفت</option>
<option value="1" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Job)">استخدام</option> <option value="1" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Job)">استخدام</option>
<option value="2" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Talent)">آماده به کار (معرفی نیرو)</option>
</select> </select>
</div> </div>
<div class="filter-group"> <div class="filter-group" id="facilityGroup">
<label>مرکز درمانی</label> <label>مرکز درمانی</label>
<select name="FacilityId"> <select name="FacilityId">
<option value="0" selected="@(Model.FacilityId == 0)">— انتخاب نشده —</option>
@foreach (var f in Model.Facilities) @foreach (var f in Model.Facilities)
{ {
<option value="@f.Id">@f.Name — @f.City?.Name</option> <option value="@f.Id" selected="@(Model.FacilityId == f.Id)">@f.Name — @f.City?.Name</option>
} }
</select> </select>
<input type="text" name="NewFacilityName" placeholder="یا نام مرکز جدید را وارد کن…" style="margin-top:6px;" />
<p class="muted" style="font-size:11px; margin:4px 0 0;">اگر مرکز در فهرست نیست، نامش را اینجا بنویس تا به‌صورت «تأییدنشده» ساخته شود.</p>
</div> </div>
<div class="filter-group"> <div class="filter-group">
<label>نقش</label> <label>نقش‌ها (می‌توانی چند مورد انتخاب کنی)</label>
<select name="RoleId"> <div class="role-checks">
@foreach (var role in Model.Roles) @foreach (var role in Model.Roles)
{ {
<option value="@role.Id" selected="@(Model.RoleId == role.Id)">@role.Name</option> <label class="role-check">
<input type="checkbox" name="RoleIds" value="@role.Id" checked="@(Model.RoleIds.Contains(role.Id))" />
<span>@role.Name</span>
</label>
} }
</select> </div>
<p class="muted" style="font-size:11px; margin:4px 0 0;">برای آگهی چندتخصصی (مثل «پرستار سالمند و کودک») همه‌ی نقش‌ها را تیک بزن — برای هر نقش یک آگهی جدا ساخته می‌شود.</p>
</div> </div>
<div class="filter-group"> <div class="filter-group">
@@ -116,6 +138,40 @@
</div> </div>
</div> </div>
<div id="talentFields" style="display:none;">
<div class="filter-group">
<label>نام فرد (اختیاری)</label>
<input type="text" name="PersonName" value="@Model.PersonName" placeholder="مثلاً دکتر سپیده علیزاده" />
</div>
<div class="filter-group">
<label>شهر</label>
<select name="TalentCityId">
@foreach (var c in Model.Cities)
{
<option value="@c.Id" selected="@(Model.TalentCityId == c.Id)">@c.Name</option>
}
</select>
</div>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>سابقه (سال)</label><input type="number" name="YearsExperience" value="@Model.YearsExperience" min="0" max="60" dir="ltr" /></div>
<div style="flex:1;"><label>محدوده کاری</label><input type="text" name="AreaNote" value="@Model.AreaNote" placeholder="مثلاً فقط منطقه ۱" /></div>
</div>
<div class="filter-group">
<label>شماره تماس</label>
<input type="text" name="Phone" value="@Model.Phone" placeholder="۰۹۱۲…" dir="ltr" />
</div>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:600;">
<input type="checkbox" name="IsLicensed" value="true" style="width:auto;" checked="@Model.IsLicensed" /> پروانه‌دار
</label>
</div>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:1;"><label>دستمزد مدنظر (تومان)</label><input type="number" name="PayAmount" value="@Model.PayAmount" dir="ltr" /></div>
<div style="flex:1;"><label>یا سهم درآمد (٪)</label><input type="number" name="SharePercent" value="@Model.SharePercent" min="1" max="100" dir="ltr" /></div>
</div>
<p class="muted" style="font-size:11px; margin:4px 0 0;">برای «آماده به کار» نیازی به مرکز نیست؛ شماره تماس مهم‌ترین فیلد است.</p>
</div>
<div class="filter-group"> <div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:600;"> <label style="display:flex; align-items:center; gap:8px; font-weight:600;">
<input type="checkbox" name="Negotiable" value="true" style="width:auto;" checked="@Model.Negotiable" /> توافقی <input type="checkbox" name="Negotiable" value="true" style="width:auto;" checked="@Model.Negotiable" /> توافقی
@@ -136,10 +192,20 @@
@section Scripts { @section Scripts {
<script> <script>
var kind = document.getElementById('kindSelect'); var kind = document.getElementById('kindSelect');
var facilityGroup = document.getElementById('facilityGroup');
// Show one section and DISABLE the hidden ones so duplicate-named inputs
// (PayAmount/SharePercent appear in both shift and talent) aren't submitted.
function setSection(el, on) {
if (!el) return;
el.style.display = on ? 'block' : 'none';
el.querySelectorAll('input,select,textarea').forEach(function (i) { i.disabled = !on; });
}
function toggleKind() { function toggleKind() {
var isJob = kind.value === '1'; var v = kind.value;
document.getElementById('jobFields').style.display = isJob ? 'block' : 'none'; setSection(document.getElementById('shiftFields'), v === '0');
document.getElementById('shiftFields').style.display = isJob ? 'none' : 'block'; setSection(document.getElementById('jobFields'), v === '1');
setSection(document.getElementById('talentFields'), v === '2');
setSection(facilityGroup, v !== '2'); // facility only for shift/job
} }
kind.addEventListener('change', toggleKind); kind.addEventListener('change', toggleKind);
toggleKind(); toggleKind();
+209 -40
View File
@@ -1,6 +1,7 @@
using JobsMedical.Web.Data; using JobsMedical.Web.Data;
using JobsMedical.Web.Models; using JobsMedical.Web.Models;
using JobsMedical.Web.Services; using JobsMedical.Web.Services;
using JobsMedical.Web.Services.Scraping;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
@@ -26,11 +27,16 @@ public class ReviewModel : PageModel
public ParsedListing? Parsed { get; private set; } public ParsedListing? Parsed { get; private set; }
public List<Facility> Facilities { get; private set; } = new(); public List<Facility> Facilities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new(); public List<Role> Roles { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
[TempData] public string? Error { get; set; }
// The editable form (prefilled from the parser, admin can override everything). // The editable form (prefilled from the parser, admin can override everything).
[BindProperty] public ListingKind Kind { get; set; } [BindProperty] public ListingKind Kind { get; set; }
[BindProperty] public int FacilityId { get; set; } [BindProperty] public int FacilityId { get; set; }
[BindProperty] public int RoleId { get; set; } [BindProperty] public string? NewFacilityName { get; set; } // create a facility on the fly if none picked
/// <summary>One or more roles — an ad like «پرستار سالمند و کودک» publishes one listing per role.</summary>
[BindProperty] public int[] RoleIds { get; set; } = Array.Empty<int>();
[BindProperty] public string? Description { get; set; } [BindProperty] public string? Description { get; set; }
// Shift fields // Shift fields
[BindProperty] public DateOnly ShiftDate { get; set; } [BindProperty] public DateOnly ShiftDate { get; set; }
@@ -46,6 +52,13 @@ public class ReviewModel : PageModel
[BindProperty] public EmploymentType EmploymentType { get; set; } [BindProperty] public EmploymentType EmploymentType { get; set; }
[BindProperty] public long? SalaryMin { get; set; } [BindProperty] public long? SalaryMin { get; set; }
[BindProperty] public long? SalaryMax { get; set; } [BindProperty] public long? SalaryMax { get; set; }
// Talent («آماده به کار») fields — no facility; contact phone is key.
[BindProperty] public int TalentCityId { get; set; }
[BindProperty] public string? PersonName { get; set; }
[BindProperty] public int? YearsExperience { get; set; }
[BindProperty] public bool IsLicensed { get; set; }
[BindProperty] public string? AreaNote { get; set; }
[BindProperty] public string? Phone { get; set; }
public async Task<IActionResult> OnGetAsync(int id) public async Task<IActionResult> OnGetAsync(int id)
{ {
@@ -58,7 +71,8 @@ public class ReviewModel : PageModel
// Prefill the form from the parser's best guess. // Prefill the form from the parser's best guess.
Kind = Parsed.Kind; Kind = Parsed.Kind;
RoleId = Roles.FirstOrDefault(r => r.Name == Parsed.RoleName)?.Id ?? Roles.FirstOrDefault()?.Id ?? 0; var matchedRole = Roles.FirstOrDefault(r => r.Name == Parsed.RoleName)?.Id ?? Roles.FirstOrDefault()?.Id ?? 0;
RoleIds = matchedRole > 0 ? new[] { matchedRole } : Array.Empty<int>();
ShiftType = Parsed.ShiftType ?? ShiftType.Day; ShiftType = Parsed.ShiftType ?? ShiftType.Day;
EmploymentType = Parsed.EmploymentType ?? EmploymentType.FullTime; EmploymentType = Parsed.EmploymentType ?? EmploymentType.FullTime;
(StartTime, EndTime) = DefaultTimes(ShiftType); (StartTime, EndTime) = DefaultTimes(ShiftType);
@@ -69,6 +83,34 @@ public class ReviewModel : PageModel
if (Parsed.PayAmount is not null) { PayAmount = Parsed.PayAmount; SalaryMin = Parsed.PayAmount; } if (Parsed.PayAmount is not null) { PayAmount = Parsed.PayAmount; SalaryMin = Parsed.PayAmount; }
Description = Raw.RawText; Description = Raw.RawText;
Title = Parsed.RoleName is not null ? $"استخدام {Parsed.RoleName}" : "موقعیت استخدامی"; Title = Parsed.RoleName is not null ? $"استخدام {Parsed.RoleName}" : "موقعیت استخدامی";
// Talent prefill.
Phone = Parsed.Phone;
PersonName = Parsed.PersonName;
YearsExperience = Parsed.YearsExperience;
IsLicensed = Parsed.IsLicensed;
AreaNote = Parsed.AreaNote;
TalentCityId = Cities.FirstOrDefault(c => c.Name == Parsed.CityName)?.Id
?? Cities.FirstOrDefault()?.Id ?? 0;
// Facility: try to match the listing's facility to one we already have; otherwise
// prefill the "new facility" box so publishing creates it.
if (!string.IsNullOrWhiteSpace(Parsed.FacilityName))
{
var cityId = await _db.Cities.Where(c => c.Name == Parsed.CityName)
.Select(c => (int?)c.Id).FirstOrDefaultAsync();
var match = FacilityMatcher.FindBest(Facilities, Parsed.FacilityName, cityId);
if (match is not null)
{
FacilityId = match.Id;
Parsed.Notes.Add($"مرکز منطبق در سیستم: «{match.Name}» — همین انتخاب شد.");
}
else
{
NewFacilityName = Parsed.FacilityName;
Parsed.Notes.Add($"مرکز جدید پیشنهادی: «{Parsed.FacilityName}» — هنگام انتشار ساخته می‌شود.");
}
}
return Page(); return Page();
} }
@@ -77,59 +119,130 @@ public class ReviewModel : PageModel
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id); Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
if (Raw is null) return NotFound(); if (Raw is null) return NotFound();
Shift? createdShift = null; // One or more roles — publish a separate listing per selected role.
JobOpening? createdJob = null; var validRoles = await _db.Roles.Where(r => RoleIds.Contains(r.Id)).ToListAsync();
if (Kind == ListingKind.Shift) if (validRoles.Count == 0)
{ {
var role = await _db.Roles.FindAsync(RoleId); Error = "حداقل یک نقش معتبر انتخاب کن.";
var shift = new Shift return RedirectToPage(new { id });
}
var payType = Negotiable ? PayType.Negotiable
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift);
var payAmt = Negotiable ? (long?)null : PayAmount;
var sharePct = Negotiable ? (int?)null : SharePercent;
// ---- آماده به کار: no facility; one TalentListing per role ----
if (Kind == ListingKind.Talent)
{ {
FacilityId = FacilityId, var cityId = TalentCityId > 0 && await _db.Cities.AnyAsync(c => c.Id == TalentCityId)
RoleId = RoleId, ? TalentCityId
Date = ShiftDate, : await _db.Cities.OrderByDescending(c => c.IsActive).Select(c => (int?)c.Id).FirstOrDefaultAsync();
StartTime = StartTime, if (cityId is null)
EndTime = EndTime, {
ShiftType = ShiftType, Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
SpecialtyRequired = role?.Name ?? "", return RedirectToPage(new { id });
Description = Description, }
PayType = Negotiable ? PayType.Negotiable var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync();
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift), var reparsed = _parser.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync());
PayAmount = Negotiable ? null : PayAmount, var contactSpecs = reparsed.Contacts.Select((c, i) => (c.Type, c.Value, Order: i)).ToList();
SharePercent = Negotiable ? null : SharePercent, var adminPhone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim();
GenderRequirement = GenderRequirement, var tags = string.Join(" ", reparsed.Tags.Distinct());
Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated, // Fresh ContactMethod instances per listing (EF can't share children across parents).
SourceUrl = Raw.SourceUrl, List<ContactMethod> FreshContacts()
{
var list = contactSpecs.Select(s => new ContactMethod { Type = s.Type, Value = s.Value, SortOrder = s.Order }).ToList();
if (adminPhone is not null)
{
var d = new string(adminPhone.Where(char.IsDigit).ToArray());
if (!list.Any(c => new string(c.Value.Where(char.IsDigit).ToArray()) == d))
list.Insert(0, new ContactMethod { Type = ContactType.Mobile, Value = adminPhone, SortOrder = -1 });
}
return list;
}
TalentListing? firstTalent = null;
foreach (var role in validRoles)
{
var t = new TalentListing
{
RoleId = role.Id, CityId = cityId.Value,
PersonName = string.IsNullOrWhiteSpace(PersonName) ? null : PersonName.Trim(),
YearsExperience = YearsExperience, IsLicensed = IsLicensed,
AreaNote = string.IsNullOrWhiteSpace(AreaNote) ? null : AreaNote.Trim(),
Availability = EmploymentType, Gender = GenderRequirement,
PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
Phone = adminPhone, Description = Description,
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
Contacts = FreshContacts(),
Tags = string.Join(" ", new[] { tags, role.Name }.Where(x => !string.IsNullOrWhiteSpace(x))),
}; };
_db.Shifts.Add(shift); _db.TalentListings.Add(t);
firstTalent ??= t;
}
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
Raw.Status = RawListingStatus.Normalized; Raw.Status = RawListingStatus.Normalized;
Raw.LinkedShiftId = shift.Id; Raw.LinkedTalentId = firstTalent!.Id;
createdShift = shift; await _db.SaveChangesAsync();
return RedirectToPage("/Admin/Index");
}
// ---- Shift / Job: need a facility (falls back to «نامشخص / ثبت نشده») ----
var facilityId = await ResolveFacilityIdAsync();
if (facilityId is null)
{
Error = "شهری برای ساخت مرکز موجود نیست؛ ابتدا یک شهر اضافه کن.";
return RedirectToPage(new { id });
}
var many = validRoles.Count > 1;
if (Kind == ListingKind.Shift)
{
var created = new List<Shift>();
foreach (var role in validRoles)
{
var shift = new Shift
{
FacilityId = facilityId.Value, RoleId = role.Id,
Date = ShiftDate, StartTime = StartTime, EndTime = EndTime, ShiftType = ShiftType,
SpecialtyRequired = role.Name, Description = Description,
PayType = payType, PayAmount = payAmt, SharePercent = sharePct,
GenderRequirement = GenderRequirement, Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
};
_db.Shifts.Add(shift);
created.Add(shift);
}
await _db.SaveChangesAsync();
Raw.Status = RawListingStatus.Normalized;
Raw.LinkedShiftId = created[0].Id;
await _db.SaveChangesAsync();
foreach (var s in created) await _notify.NotifyNewShiftAsync(s.Id);
} }
else else
{
var created = new List<JobOpening>();
foreach (var role in validRoles)
{ {
var job = new JobOpening var job = new JobOpening
{ {
FacilityId = FacilityId, FacilityId = facilityId.Value, RoleId = role.Id,
RoleId = RoleId, // With several roles, give each a role-specific title; with one, honor the typed title.
Title = string.IsNullOrWhiteSpace(Title) ? "موقعیت استخدامی" : Title.Trim(), Title = many || string.IsNullOrWhiteSpace(Title) ? $"استخدام {role.Name}" : Title.Trim(),
EmploymentType = EmploymentType, EmploymentType = EmploymentType,
SalaryMin = Negotiable ? null : SalaryMin, SalaryMin = Negotiable ? null : SalaryMin, SalaryMax = Negotiable ? null : SalaryMax,
SalaryMax = Negotiable ? null : SalaryMax, GenderRequirement = GenderRequirement, Description = Description,
GenderRequirement = GenderRequirement, Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
Description = Description,
Status = ShiftStatus.Open,
Source = ShiftSource.Aggregated,
SourceUrl = Raw.SourceUrl,
}; };
_db.JobOpenings.Add(job); _db.JobOpenings.Add(job);
Raw.Status = RawListingStatus.Normalized; created.Add(job);
createdJob = job;
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
if (createdShift is not null) await _notify.NotifyNewShiftAsync(createdShift.Id); Raw.Status = RawListingStatus.Normalized;
if (createdJob is not null) await _notify.NotifyNewJobAsync(createdJob.Id); await _db.SaveChangesAsync();
foreach (var j in created) await _notify.NotifyNewJobAsync(j.Id);
}
return RedirectToPage("/Admin/Index"); return RedirectToPage("/Admin/Index");
} }
@@ -150,10 +263,66 @@ public class ReviewModel : PageModel
_ => (new TimeOnly(8, 0), new TimeOnly(8, 0)), _ => (new TimeOnly(8, 0), new TimeOnly(8, 0)),
}; };
/// <summary>Placeholder facility name used when an ad doesn't name a real one.</summary>
private const string UnknownFacilityName = "نامشخص / ثبت نشده";
/// <summary>
/// Returns a valid FacilityId. Prefers the picked facility, then the typed/parsed name
/// (reusing a fuzzy match before creating), and finally falls back to a single shared
/// «نامشخص / ثبت نشده» record so publishing never fails for a missing facility.
/// Returns null only when there are no cities at all.
/// </summary>
private async Task<int?> ResolveFacilityIdAsync()
{
if (FacilityId > 0 && await _db.Facilities.AnyAsync(f => f.Id == FacilityId))
return FacilityId;
var cityId = await _db.Cities.OrderByDescending(c => c.IsActive)
.Select(c => (int?)c.Id).FirstOrDefaultAsync();
if (cityId is null) return null; // no cities seeded — cannot create a facility
// No facility named in the ad → use/create the shared placeholder.
var isPlaceholder = string.IsNullOrWhiteSpace(NewFacilityName);
var name = isPlaceholder ? UnknownFacilityName : NewFacilityName.Trim();
// Approximate coords carried from the crawl (e.g. Divar). NEVER apply them to the shared
// «نامشخص» placeholder — it's reused across many ads, so a single ad's point would mislead.
bool HasGeo() => !isPlaceholder && Raw?.Lat is not null;
// Reuse an existing facility that's exactly or closely the same (Persian-aware fuzzy
// match), so we don't create duplicates like «بیمارستان میلاد» vs «میلاد».
var all = await _db.Facilities.ToListAsync();
var match = FacilityMatcher.FindBest(all, name, cityId);
if (match is not null)
{
if (HasGeo() && match.Lat is null && match.Lng is null) // backfill only, never overwrite
{
match.Lat = Raw!.Lat; match.Lng = Raw.Lng;
await _db.SaveChangesAsync();
}
return match.Id;
}
var facility = new Facility
{
Name = name,
CityId = cityId.Value,
Type = FacilityType.Hospital,
Verification = VerificationStatus.Unverified,
IsVerified = false,
Lat = HasGeo() ? Raw!.Lat : null,
Lng = HasGeo() ? Raw!.Lng : null,
};
_db.Facilities.Add(facility);
await _db.SaveChangesAsync();
return facility.Id;
}
private async Task LoadListsAsync() private async Task LoadListsAsync()
{ {
Facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync(); Facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync(); Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
} }
private Task<List<string>> CityNamesAsync() => _db.Cities.Select(c => c.Name).ToListAsync(); private Task<List<string>> CityNamesAsync() => _db.Cities.Select(c => c.Name).ToListAsync();
@@ -0,0 +1,40 @@
@page
@model JobsMedical.Web.Pages.Admin.ReviewsModel
@{
ViewData["Title"] = "مدیریت نظرات";
}
<div class="page-head">
<div class="container">
<h1>نظرات کاربران</h1>
<p class="muted"><a asp-page="/Admin/Overview">← پنل مدیریت</a></p>
</div>
</div>
<div class="container section" style="max-width:820px;">
@if (Model.Msg is not null) { <div class="alert alert-success">@Model.Msg</div> }
@if (Model.Items.Count == 0)
{
<div class="card empty-state">نظری ثبت نشده است.</div>
}
else
{
foreach (var r in Model.Items)
{
<div class="card card-pad" style="margin-bottom:8px; @(r.IsApproved ? "" : "opacity:.6;")">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:10px;">
<div>
<strong>@r.Facility.Name</strong>
<span style="color:#f59e0b;">@(new string('★', r.Stars))</span>
@if (!r.IsApproved) { <span class="badge badge-type">پنهان</span> }
<div class="muted" style="font-size:13px;">@(r.User.FullName ?? "کاربر") · <span dir="ltr">@JalaliDate.ToPersianDigits(r.User.Phone)</span></div>
@if (!string.IsNullOrWhiteSpace(r.Comment)) { <p style="margin:6px 0 0;">@r.Comment</p> }
</div>
<div style="display:flex; gap:6px; flex-wrap:wrap;">
<form method="post" asp-page-handler="Toggle" asp-route-id="@r.Id"><button class="btn btn-outline" style="padding:4px 12px;">@(r.IsApproved ? "پنهان‌کردن" : "نمایش")</button></form>
<form method="post" asp-page-handler="Delete" asp-route-id="@r.Id" onsubmit="return confirm('حذف شود؟');"><button class="btn btn-outline" style="padding:4px 12px; color:var(--danger); border-color:var(--danger);">حذف</button></form>
</div>
</div>
</div>
}
}
</div>
@@ -0,0 +1,38 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Admin;
[Authorize(Roles = "Admin")]
public class ReviewsModel : PageModel
{
private readonly AppDbContext _db;
public ReviewsModel(AppDbContext db) => _db = db;
public List<Review> Items { get; private set; } = new();
[TempData] public string? Msg { get; set; }
public async Task OnGetAsync()
{
Items = await _db.Reviews.Include(r => r.Facility).Include(r => r.User)
.OrderByDescending(r => r.CreatedAt).Take(200).ToListAsync();
}
public async Task<IActionResult> OnPostToggleAsync(int id)
{
var r = await _db.Reviews.FindAsync(id);
if (r is not null) { r.IsApproved = !r.IsApproved; await _db.SaveChangesAsync(); Msg = r.IsApproved ? "نمایش داده شد." : "پنهان شد."; }
return RedirectToPage();
}
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
var r = await _db.Reviews.FindAsync(id);
if (r is not null) { _db.Reviews.Remove(r); await _db.SaveChangesAsync(); Msg = "حذف شد."; }
return RedirectToPage();
}
}
@@ -0,0 +1,84 @@
@page
@model JobsMedical.Web.Pages.Admin.RolesModel
@{
ViewData["Title"] = "نقش‌ها و دسته‌بندی";
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
}
<div class="page-head">
<div class="container">
<h1>نقش‌ها و دسته‌بندی</h1>
<p class="muted"><a asp-page="/Admin/Index">← صف بررسی</a> — ادغام نقش‌های تکراری و مدیریت تاکسونومی.</p>
</div>
</div>
<div class="container section">
@if (Model.Message is not null)
{
<div class="alert alert-success">✓ @Model.Message</div>
}
<div class="card card-pad" style="margin-bottom:16px;">
<h3 style="margin-top:0;">ادغام نقش</h3>
<p class="muted" style="font-size:12.5px; margin-top:0;">همهٔ آگهی‌ها و علاقه‌مندی‌های «نقش مبدأ» به «نقش مقصد» منتقل و نقش مبدأ حذف می‌شود. این کار بازگشت‌ناپذیر است.</p>
<form method="post" asp-page-handler="Merge"
onsubmit="return confirm('ادغام انجام شود؟ این کار بازگشت‌ناپذیر است.');"
style="display:flex; gap:8px; flex-wrap:wrap; align-items:end;">
<div class="filter-group" style="margin:0; flex:1; min-width:200px;">
<label>نقش مبدأ (حذف می‌شود)</label>
<select name="sourceId" required>
<option value="">— انتخاب —</option>
@foreach (var x in Model.Stats)
{
<option value="@x.Role.Id">@x.Role.Name (@P(x.Total))</option>
}
</select>
</div>
<div class="filter-group" style="margin:0; flex:1; min-width:200px;">
<label>نقش مقصد (می‌ماند)</label>
<select name="targetId" required>
<option value="">— انتخاب —</option>
@foreach (var x in Model.Stats)
{
<option value="@x.Role.Id">@x.Role.Name (@P(x.Total))</option>
}
</select>
</div>
<button type="submit" class="btn btn-accent">ادغام</button>
</form>
</div>
<table style="width:100%; border-collapse:collapse; font-size:13.5px;">
<thead>
<tr style="color:var(--muted); text-align:start;">
<th style="text-align:start; padding:6px 0;">نقش</th>
<th style="text-align:start;">گروه</th>
<th style="text-align:start;">شیفت</th>
<th style="text-align:start;">استخدام</th>
<th style="text-align:start;">آماده‌به‌کار</th>
<th style="text-align:start;">جمع</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var x in Model.Stats)
{
<tr style="border-top:1px solid var(--line); @(x.Role.IsActive ? "" : "opacity:.5;")">
<td style="padding:8px 0;"><strong>@x.Role.Name</strong> @(x.Role.IsActive ? "" : "(غیرفعال)")</td>
<td class="muted">@x.Role.Category</td>
<td>@P(x.Shifts)</td>
<td>@P(x.Jobs)</td>
<td>@P(x.Talent)</td>
<td><strong>@P(x.Total)</strong></td>
<td style="text-align:end;">
<form method="post" asp-page-handler="Toggle" asp-route-id="@x.Role.Id" style="display:inline;">
<button type="submit" class="btn btn-outline" style="padding:4px 12px; font-size:12px;">
@(x.Role.IsActive ? "غیرفعال‌سازی" : "فعال‌سازی")
</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
@@ -0,0 +1,75 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Admin;
/// <summary>Role taxonomy hygiene — dynamic ingestion can mint near-duplicate roles over time
/// («کمک‌یار» vs «کمک بهیار»). This screen lists every role with its usage and lets an admin merge
/// one role into another (reassigning all its listings/preferences) or toggle a role's visibility.</summary>
[Authorize(Roles = "Admin")]
public class RolesModel : PageModel
{
private readonly AppDbContext _db;
public RolesModel(AppDbContext db) => _db = db;
public record RoleStat(Role Role, int Shifts, int Jobs, int Talent)
{
public int Total => Shifts + Jobs + Talent;
}
public List<RoleStat> Stats { get; private set; } = new();
[TempData] public string? Message { get; set; }
public async Task OnGetAsync() => await LoadAsync();
private async Task LoadAsync()
{
var roles = await _db.Roles.OrderBy(r => r.Category).ThenBy(r => r.Name).ToListAsync();
var sc = await _db.Shifts.GroupBy(s => s.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
var jc = await _db.JobOpenings.GroupBy(j => j.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
var tc = await _db.TalentListings.GroupBy(t => t.RoleId).Select(g => new { g.Key, C = g.Count() }).ToDictionaryAsync(x => x.Key, x => x.C);
Stats = roles.Select(r => new RoleStat(r, sc.GetValueOrDefault(r.Id), jc.GetValueOrDefault(r.Id), tc.GetValueOrDefault(r.Id)))
.OrderByDescending(x => x.Total).ToList();
}
/// <summary>Move every reference from <paramref name="sourceId"/> to <paramref name="targetId"/>
/// (listings — the Restrict FKs that would otherwise block — plus preferences/alerts/profiles),
/// then delete the now-empty source role.</summary>
public async Task<IActionResult> OnPostMergeAsync(int sourceId, int targetId)
{
if (sourceId == 0 || targetId == 0 || sourceId == targetId)
{ Message = "نقش مبدأ و مقصد را درست انتخاب کن."; return RedirectToPage(); }
var source = await _db.Roles.FindAsync(sourceId);
var target = await _db.Roles.FindAsync(targetId);
if (source is null || target is null) { Message = "نقش پیدا نشد."; return RedirectToPage(); }
var s = await _db.Shifts.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
var j = await _db.JobOpenings.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
var t = await _db.TalentListings.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, targetId));
// Nullable references too, so a saved preference/alert follows the merge instead of dangling.
await _db.UserPreferences.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
await _db.JobAlerts.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
await _db.DoctorProfiles.Where(x => x.RoleId == sourceId).ExecuteUpdateAsync(u => u.SetProperty(x => x.RoleId, (int?)targetId));
_db.Roles.Remove(source);
await _db.SaveChangesAsync();
string P(int n) => JalaliDate.ToPersianDigits(n.ToString());
Message = $"«{source.Name}» در «{target.Name}» ادغام شد — منتقل‌شده: {P(s)} شیفت، {P(j)} استخدام، {P(t)} آماده‌به‌کار.";
return RedirectToPage();
}
/// <summary>Hide a role from filters/forms without deleting it (keeps its listings intact).</summary>
public async Task<IActionResult> OnPostToggleAsync(int id)
{
var role = await _db.Roles.FindAsync(id);
if (role is not null) { role.IsActive = !role.IsActive; await _db.SaveChangesAsync(); }
return RedirectToPage();
}
}
+59 -10
View File
@@ -15,6 +15,12 @@
@if (Model.Saved is not null) { <div class="alert alert-success">✓ @Model.Saved</div> } @if (Model.Saved is not null) { <div class="alert alert-success">✓ @Model.Saved</div> }
@if (Model.DemoMsg is not null) { <div class="alert alert-success">@Model.DemoMsg</div> } @if (Model.DemoMsg is not null) { <div class="alert alert-success">@Model.DemoMsg</div> }
@if (Model.SmsTest is not null) { <div class="alert alert-success">@Model.SmsTest</div> } @if (Model.SmsTest is not null) { <div class="alert alert-success">@Model.SmsTest</div> }
@if (Model.ProxyTest is not null) { <div class="alert alert-success">@Model.ProxyTest</div> }
@if (Model.AiTest is not null)
{
<div class="alert @(Model.AiTest.StartsWith("✅") ? "alert-success" : "alert-error")"
style="white-space:pre-wrap; word-break:break-word;">@Model.AiTest</div>
}
<form method="post"> <form method="post">
<div class="settings-layout"> <div class="settings-layout">
@@ -65,14 +71,22 @@
<div style="flex:1;"><label>نام مدل</label><input type="text" name="AiModel" value="@Model.AiModel" dir="ltr" /></div> <div style="flex:1;"><label>نام مدل</label><input type="text" name="AiModel" value="@Model.AiModel" dir="ltr" /></div>
</div> </div>
<div class="filter-group"> <div class="filter-group">
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework)</label> <label>دستور و چارچوب هوش مصنوعی (Prompt / Framework) — ثابت 🔒</label>
<textarea name="AiSystemPrompt" rows="8" dir="rtl">@Model.AiSystemPrompt</textarea> <textarea rows="14" dir="rtl" readonly
<p class="muted" style="font-size:12px; margin:4px 0 0;">به مدل بگو چطور تأیید/رد کند و چه فیلدهایی را استخراج کند. خروجی باید JSON باشد.</p> style="background:var(--bg); color:var(--muted); cursor:not-allowed;">@JobsMedical.Web.Models.AppSetting.DefaultPrompt</textarea>
<p class="muted" style="font-size:12px; margin:4px 0 0;">این دستور در کد ثابت شده و قابل ویرایش نیست تا دسته‌بندی و استخراج همیشه درست بماند. یک «اسکیمای خروجی JSON» هم به‌صورت خودکار به انتهای آن افزوده می‌شود.</p>
</div> </div>
<label class="toggle-row"> <label class="toggle-row">
<input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" /> <input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" />
<span class="t-body"><span>در حالت خودکار، آگهی‌هایی که AI تأیید می‌کند مستقیم منتشر شوند</span></span> <span class="t-body"><span>در حالت خودکار، آگهی‌هایی که AI تأیید می‌کند مستقیم منتشر شوند</span></span>
</label> </label>
<label class="toggle-row">
<input type="checkbox" name="AiUseProxy" value="true" checked="@Model.AiUseProxy" />
<span class="t-body"><span>ارسال درخواست هوش مصنوعی از طریق پروکسی</span>
<span class="t-hint">برای دسترسی به سرویس‌هایی مثل OpenAI از داخل ایران؛ از همان آدرس پروکسی تب «منابع جمع‌آوری» استفاده می‌کند.</span></span>
</label>
<button type="submit" asp-page-handler="TestAi" class="btn btn-outline" style="margin-top:6px;">🤖 تست هوش مصنوعی (روی یک آگهی نمونه)</button>
<p class="muted" style="font-size:11px; margin:4px 0 0;">یک آگهی نمونه را به مدل می‌فرستد و تصمیم/استخراج آن را نشان می‌دهد. (ابتدا کلید و آدرس را ذخیره کن.)</p>
</section> </section>
<!-- SOURCES --> <!-- SOURCES -->
@@ -87,58 +101,93 @@
<input type="number" name="IngestIntervalMinutes" min="1" value="@Model.IngestIntervalMinutes" dir="ltr" /> <input type="number" name="IngestIntervalMinutes" min="1" value="@Model.IngestIntervalMinutes" dir="ltr" />
</div> </div>
<p class="muted" style="font-size:12px; margin:0 0 4px;">هر منبع را جداگانه روشن/خاموش و تنظیم کن.</p>
<div class="source-box">
<label class="toggle-row"> <label class="toggle-row">
<input type="checkbox" name="TelegramEnabled" value="true" checked="@Model.TelegramEnabled" /> <input type="checkbox" name="TelegramEnabled" value="true" checked="@Model.TelegramEnabled" />
<span class="t-body"><span>تلگرام (کانال‌های عمومی — بدون توکن)</span></span> <span class="t-body"><span>📨 تلگرام</span><span class="t-hint">کانال‌های عمومی — بدون توکن.</span></span>
</label> </label>
<div class="filter-group"> <div class="filter-group">
<label>یوزرنیم کانال‌ها (هر خط یک کانال)</label> <label>یوزرنیم کانال‌ها (هر خط یک کانال)</label>
<textarea name="TelegramChannels" rows="3" dir="ltr" placeholder="shift_channel&#10;another_channel">@Model.TelegramChannels</textarea> <textarea name="TelegramChannels" rows="3" dir="ltr" placeholder="shift_channel&#10;another_channel">@Model.TelegramChannels</textarea>
<label class="proxy-toggle"><input type="checkbox" name="TelegramUseProxy" value="true" checked="@Model.TelegramUseProxy" /> از پروکسی استفاده شود</label> <label class="proxy-toggle"><input type="checkbox" name="TelegramUseProxy" value="true" checked="@Model.TelegramUseProxy" /> از پروکسی استفاده شود</label>
</div> </div>
</div>
<div class="source-box">
<label class="toggle-row"> <label class="toggle-row">
<input type="checkbox" name="BaleEnabled" value="true" checked="@Model.BaleEnabled" /> <input type="checkbox" name="BaleEnabled" value="true" checked="@Model.BaleEnabled" />
<span class="t-body"><span>بله (بات باید عضو کانال باشد)</span></span> <span class="t-body"><span>💬 بله</span><span class="t-hint">بات باید عضو کانال باشد.</span></span>
</label> </label>
<div class="filter-group"><label>توکن بات بله</label><input type="password" name="BaleBotToken" value="@Model.BaleBotToken" dir="ltr" /> <div class="filter-group"><label>توکن بات بله</label><input type="password" name="BaleBotToken" value="@Model.BaleBotToken" dir="ltr" />
<label class="proxy-toggle"><input type="checkbox" name="BaleUseProxy" value="true" checked="@Model.BaleUseProxy" /> از پروکسی استفاده شود</label> <label class="proxy-toggle"><input type="checkbox" name="BaleUseProxy" value="true" checked="@Model.BaleUseProxy" /> از پروکسی استفاده شود</label>
</div> </div>
</div>
<div class="source-box">
<label class="toggle-row"> <label class="toggle-row">
<input type="checkbox" name="DivarEnabled" value="true" checked="@Model.DivarEnabled" /> <input type="checkbox" name="DivarEnabled" value="true" checked="@Model.DivarEnabled" />
<span class="t-body"><span>دیوار</span></span> <span class="t-body"><span>🟥 دیوار</span></span>
</label> </label>
<div class="filter-group" style="display:flex; gap:8px;"> <div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:0 0 120px;"><label>شهر (slug)</label><input type="text" name="DivarCity" value="@Model.DivarCity" dir="ltr" placeholder="tehran" /></div> <div style="flex:0 0 120px;"><label>شهر (slug)</label><input type="text" name="DivarCity" value="@Model.DivarCity" dir="ltr" placeholder="tehran" /></div>
<div style="flex:1;"><label>عبارت‌های جستجو (هر خط یکی)</label><textarea name="DivarQueries" rows="3">@Model.DivarQueries</textarea></div> <div style="flex:1;"><label>عبارت‌های جستجو (هر خط یکی)</label><textarea name="DivarQueries" rows="3">@Model.DivarQueries</textarea></div>
</div> </div>
<label class="proxy-toggle"><input type="checkbox" name="DivarUseProxy" value="true" checked="@Model.DivarUseProxy" /> از پروکسی استفاده شود</label> <label class="proxy-toggle"><input type="checkbox" name="DivarUseProxy" value="true" checked="@Model.DivarUseProxy" /> از پروکسی استفاده شود</label>
</div>
<div class="source-box">
<label class="toggle-row"> <label class="toggle-row">
<input type="checkbox" name="MedjobsEnabled" value="true" checked="@Model.MedjobsEnabled" /> <input type="checkbox" name="MedjobsEnabled" value="true" checked="@Model.MedjobsEnabled" />
<span class="t-body"><span>مدجابز (medjobs.ir)</span><span class="t-hint">خواندن کامل آگهی‌ها از سایت‌مپ.</span></span> <span class="t-body"><span>🩺 مدجابز (medjobs.ir)</span><span class="t-hint">خواندن کامل آگهی‌ها از سایت‌مپ + استخراج شماره.</span></span>
</label> </label>
<div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="MedjobsMaxAds" min="1" max="500" value="@Model.MedjobsMaxAds" dir="ltr" /> <div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="MedjobsMaxAds" min="1" max="500" value="@Model.MedjobsMaxAds" dir="ltr" />
<label class="proxy-toggle"><input type="checkbox" name="MedjobsUseProxy" value="true" checked="@Model.MedjobsUseProxy" /> از پروکسی استفاده شود</label> <label class="proxy-toggle"><input type="checkbox" name="MedjobsUseProxy" value="true" checked="@Model.MedjobsUseProxy" /> از پروکسی استفاده شود</label>
</div> </div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="IranEstekhdamEnabled" value="true" checked="@Model.IranEstekhdamEnabled" />
<span class="t-body"><span>🏥 ایران‌استخدام (iranestekhdam.ir)</span><span class="t-hint">آگهی‌های استخدامِ مراکز درمانیِ نام‌دار از سایت‌مپِ ماهانه؛ فقط نقش‌های بالینی.</span></span>
</label>
<div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="IranEstekhdamMaxAds" min="1" max="500" value="@Model.IranEstekhdamMaxAds" dir="ltr" />
<label class="proxy-toggle"><input type="checkbox" name="IranEstekhdamUseProxy" value="true" checked="@Model.IranEstekhdamUseProxy" /> از پروکسی استفاده شود</label>
</div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="MedboomEnabled" value="true" checked="@Model.MedboomEnabled" />
<span class="t-body"><span>🩺 مدبوم (medboom.ir)</span><span class="t-hint">آگهی‌های علوم پزشکی (بیشتر پزشک/دندانپزشک)، استخدام و آماده‌به‌کار؛ بدون نیاز به فیلترشکن.</span></span>
</label>
<div class="filter-group"><label>حداکثر آگهی در هر اجرا</label><input type="number" name="MedboomMaxAds" min="1" max="500" value="@Model.MedboomMaxAds" dir="ltr" />
<label class="proxy-toggle"><input type="checkbox" name="MedboomUseProxy" value="true" checked="@Model.MedboomUseProxy" /> از پروکسی استفاده شود</label>
</div>
</div>
<div class="source-box">
<label class="toggle-row"> <label class="toggle-row">
<input type="checkbox" name="WebsitesEnabled" value="true" checked="@Model.WebsitesEnabled" /> <input type="checkbox" name="WebsitesEnabled" value="true" checked="@Model.WebsitesEnabled" />
<span class="t-body"><span>وب‌سایت‌ها (آدرس‌های دلخواه)</span></span> <span class="t-body"><span>🌐 وب‌سایت‌ها</span><span class="t-hint">آدرس‌های دلخواه.</span></span>
</label> </label>
<div class="filter-group"> <div class="filter-group">
<label>آدرس صفحه‌ها (هر خط یک URL)</label> <label>آدرس صفحه‌ها (هر خط یک URL)</label>
<textarea name="WebsiteUrls" rows="3" dir="ltr" placeholder="https://example.ir/jobs/123">@Model.WebsiteUrls</textarea> <textarea name="WebsiteUrls" rows="3" dir="ltr" placeholder="https://example.ir/jobs/123">@Model.WebsiteUrls</textarea>
<label class="proxy-toggle"><input type="checkbox" name="WebsitesUseProxy" value="true" checked="@Model.WebsitesUseProxy" /> از پروکسی استفاده شود</label> <label class="proxy-toggle"><input type="checkbox" name="WebsitesUseProxy" value="true" checked="@Model.WebsitesUseProxy" /> از پروکسی استفاده شود</label>
</div> </div>
</div>
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" /> <div class="source-box">
<h3 style="margin-top:0;">پروکسی (Xray/V2Ray)</h3> <h4 style="margin:0 0 8px;">🛡️ پروکسی (Xray/V2Ray)</h4>
<div class="filter-group"> <div class="filter-group">
<label>آدرس پروکسی محلی</label> <label>آدرس پروکسی محلی</label>
<input type="text" name="IngestProxyUrl" value="@Model.IngestProxyUrl" dir="ltr" placeholder="socks5://xray:10808" /> <input type="text" name="IngestProxyUrl" value="@Model.IngestProxyUrl" dir="ltr" placeholder="socks5://xray:10808" />
<p class="muted" style="font-size:12px; margin:4px 0 0;">یک کلاینت Xray/V2Ray کانفیگ vmess/vless/trojan تو را به یک پروکسی محلی تبدیل می‌کند (socks5:// یا socks4:// یا http://). <strong>هر منبع جداگانه</strong> با تیکِ «از پروکسی استفاده شود» تعیین می‌کند که از این پروکسی عبور کند یا نه.</p> <p class="muted" style="font-size:12px; margin:4px 0 0;">یک کلاینت Xray/V2Ray کانفیگ vmess/vless/trojan تو را به یک پروکسی محلی تبدیل می‌کند (socks5:// یا socks4:// یا http://). <strong>هر منبع جداگانه</strong> با تیکِ «از پروکسی استفاده شود» تعیین می‌کند که از این پروکسی عبور کند یا نه.</p>
<button type="submit" asp-page-handler="TestProxy" class="btn btn-outline" style="margin-top:8px;">🔌 تست اتصال VPN/پروکسی</button>
<p class="muted" style="font-size:11px; margin:4px 0 0;">از طریق پروکسی به یک سایت فیلترشده وصل می‌شود؛ موفقیت یعنی تونل برقرار است. (ابتدا آدرس را ذخیره کن.)</p>
</div>
</div> </div>
</section> </section>
@@ -14,11 +14,16 @@ public class SettingsModel : PageModel
private readonly SettingsService _settings; private readonly SettingsService _settings;
private readonly ISmsSender _sms; private readonly ISmsSender _sms;
private readonly AppDbContext _db; private readonly AppDbContext _db;
public SettingsModel(SettingsService settings, ISmsSender sms, AppDbContext db) private readonly ScrapeHttpClients _clients;
private readonly IAiAuditor _ai;
public SettingsModel(SettingsService settings, ISmsSender sms, AppDbContext db,
ScrapeHttpClients clients, IAiAuditor ai)
{ {
_settings = settings; _settings = settings;
_sms = sms; _sms = sms;
_db = db; _db = db;
_clients = clients;
_ai = ai;
} }
[BindProperty] public IngestionMode Mode { get; set; } [BindProperty] public IngestionMode Mode { get; set; }
@@ -27,8 +32,9 @@ public class SettingsModel : PageModel
[BindProperty] public string? AiEndpoint { get; set; } [BindProperty] public string? AiEndpoint { get; set; }
[BindProperty] public string? AiApiKey { get; set; } [BindProperty] public string? AiApiKey { get; set; }
[BindProperty] public string? AiModel { get; set; } [BindProperty] public string? AiModel { get; set; }
[BindProperty] public string AiSystemPrompt { get; set; } = ""; // AiSystemPrompt is hardcoded (AppSetting.DefaultPrompt) and shown read-only — not bound/editable.
[BindProperty] public bool AiAutoApprove { get; set; } [BindProperty] public bool AiAutoApprove { get; set; }
[BindProperty] public bool AiUseProxy { get; set; }
// Channel scraping sources // Channel scraping sources
[BindProperty] public bool AutoIngestEnabled { get; set; } [BindProperty] public bool AutoIngestEnabled { get; set; }
[BindProperty] public int IngestIntervalMinutes { get; set; } = 30; [BindProperty] public int IngestIntervalMinutes { get; set; } = 30;
@@ -41,6 +47,12 @@ public class SettingsModel : PageModel
[BindProperty] public string? DivarQueries { get; set; } [BindProperty] public string? DivarQueries { get; set; }
[BindProperty] public bool MedjobsEnabled { get; set; } [BindProperty] public bool MedjobsEnabled { get; set; }
[BindProperty] public int MedjobsMaxAds { get; set; } = 40; [BindProperty] public int MedjobsMaxAds { get; set; } = 40;
[BindProperty] public bool IranEstekhdamEnabled { get; set; }
[BindProperty] public int IranEstekhdamMaxAds { get; set; } = 40;
[BindProperty] public bool IranEstekhdamUseProxy { get; set; }
[BindProperty] public bool MedboomEnabled { get; set; }
[BindProperty] public int MedboomMaxAds { get; set; } = 40;
[BindProperty] public bool MedboomUseProxy { get; set; }
[BindProperty] public bool SmsEnabled { get; set; } [BindProperty] public bool SmsEnabled { get; set; }
[BindProperty] public string? SmsApiKey { get; set; } [BindProperty] public string? SmsApiKey { get; set; }
[BindProperty] public string? SmsTemplate { get; set; } [BindProperty] public string? SmsTemplate { get; set; }
@@ -64,6 +76,8 @@ public class SettingsModel : PageModel
[TempData] public string? Saved { get; set; } [TempData] public string? Saved { get; set; }
[TempData] public string? SmsTest { get; set; } [TempData] public string? SmsTest { get; set; }
[TempData] public string? DemoMsg { get; set; } [TempData] public string? DemoMsg { get; set; }
[TempData] public string? ProxyTest { get; set; }
[TempData] public string? AiTest { get; set; }
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
@@ -74,8 +88,8 @@ public class SettingsModel : PageModel
AiEndpoint = s.AiEndpoint; AiEndpoint = s.AiEndpoint;
AiApiKey = s.AiApiKey; AiApiKey = s.AiApiKey;
AiModel = s.AiModel; AiModel = s.AiModel;
AiSystemPrompt = s.AiSystemPrompt;
AiAutoApprove = s.AiAutoApprove; AiAutoApprove = s.AiAutoApprove;
AiUseProxy = s.AiUseProxy;
AutoIngestEnabled = s.AutoIngestEnabled; AutoIngestEnabled = s.AutoIngestEnabled;
IngestIntervalMinutes = s.IngestIntervalMinutes; IngestIntervalMinutes = s.IngestIntervalMinutes;
TelegramEnabled = s.TelegramEnabled; TelegramEnabled = s.TelegramEnabled;
@@ -87,6 +101,12 @@ public class SettingsModel : PageModel
DivarQueries = s.DivarQueries; DivarQueries = s.DivarQueries;
MedjobsEnabled = s.MedjobsEnabled; MedjobsEnabled = s.MedjobsEnabled;
MedjobsMaxAds = s.MedjobsMaxAds; MedjobsMaxAds = s.MedjobsMaxAds;
IranEstekhdamEnabled = s.IranEstekhdamEnabled;
IranEstekhdamMaxAds = s.IranEstekhdamMaxAds;
IranEstekhdamUseProxy = s.IranEstekhdamUseProxy;
MedboomEnabled = s.MedboomEnabled;
MedboomMaxAds = s.MedboomMaxAds;
MedboomUseProxy = s.MedboomUseProxy;
SmsEnabled = s.SmsEnabled; SmsEnabled = s.SmsEnabled;
SmsApiKey = s.SmsApiKey; SmsApiKey = s.SmsApiKey;
SmsTemplate = s.SmsTemplate; SmsTemplate = s.SmsTemplate;
@@ -118,8 +138,9 @@ public class SettingsModel : PageModel
AiEndpoint = AiEndpoint, AiEndpoint = AiEndpoint,
AiApiKey = AiApiKey, AiApiKey = AiApiKey,
AiModel = AiModel, AiModel = AiModel,
AiSystemPrompt = AiSystemPrompt, // AiSystemPrompt intentionally omitted — AppSetting defaults it to DefaultPrompt (hardcoded).
AiAutoApprove = AiAutoApprove, AiAutoApprove = AiAutoApprove,
AiUseProxy = AiUseProxy,
AutoIngestEnabled = AutoIngestEnabled, AutoIngestEnabled = AutoIngestEnabled,
IngestIntervalMinutes = IngestIntervalMinutes, IngestIntervalMinutes = IngestIntervalMinutes,
TelegramEnabled = TelegramEnabled, TelegramEnabled = TelegramEnabled,
@@ -131,6 +152,12 @@ public class SettingsModel : PageModel
DivarQueries = DivarQueries, DivarQueries = DivarQueries,
MedjobsEnabled = MedjobsEnabled, MedjobsEnabled = MedjobsEnabled,
MedjobsMaxAds = MedjobsMaxAds, MedjobsMaxAds = MedjobsMaxAds,
IranEstekhdamEnabled = IranEstekhdamEnabled,
IranEstekhdamMaxAds = IranEstekhdamMaxAds,
IranEstekhdamUseProxy = IranEstekhdamUseProxy,
MedboomEnabled = MedboomEnabled,
MedboomMaxAds = MedboomMaxAds,
MedboomUseProxy = MedboomUseProxy,
SmsEnabled = SmsEnabled, SmsEnabled = SmsEnabled,
SmsApiKey = SmsApiKey, SmsApiKey = SmsApiKey,
SmsTemplate = SmsTemplate, SmsTemplate = SmsTemplate,
@@ -169,6 +196,45 @@ public class SettingsModel : PageModel
return RedirectToPage(); return RedirectToPage();
} }
/// <summary>Check the VPN/proxy is connected by reaching a normally-blocked site through it.</summary>
public async Task<IActionResult> OnPostTestProxyAsync()
{
var s = await _settings.GetAsync();
if (string.IsNullOrWhiteSpace(s.IngestProxyUrl))
{ ProxyTest = "ابتدا آدرس پروکسی را وارد و ذخیره کن."; return RedirectToPage(); }
var client = _clients.For(s, useProxy: true);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
// api.telegram.org is filtered in Iran — a reply means the tunnel reaches the open internet.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15));
using var resp = await client.GetAsync("https://api.telegram.org",
HttpCompletionOption.ResponseHeadersRead, cts.Token);
sw.Stop();
ProxyTest = $"✅ پروکسی وصل است — به اینترنت آزاد دسترسی دارد (HTTP {(int)resp.StatusCode}، {sw.ElapsedMilliseconds} میلی‌ثانیه).";
}
catch (Exception ex)
{
ProxyTest = "❌ اتصال از طریق پروکسی ناموفق بود. مطمئن شو سرویس Xray اجراست و کانفیگ معتبر است. خطا: " + ex.Message;
}
return RedirectToPage();
}
/// <summary>Send a sample post to the AI endpoint and show the verdict (validates key/endpoint/proxy).</summary>
public async Task<IActionResult> OnPostTestAiAsync()
{
var s = await _settings.GetAsync();
if (!s.AiEnabled || string.IsNullOrWhiteSpace(s.AiEndpoint))
{ AiTest = "ابتدا «فعال‌سازی هوش مصنوعی» را بزن و آدرس/کلید را ذخیره کن."; return RedirectToPage(); }
const string sample = "استخدام پرستار خانم برای بخش اورژانس بیمارستان میلاد تهران، شیفت شب، حقوق توافقی، تماس ۰۹۱۲۱۲۳۴۵۶۷";
// TestAsync runs the real call and returns the exact reason on failure (HTTP status,
// response body, network/proxy error) — unlike AuditAsync, which swallows errors to null.
AiTest = await _ai.TestAsync(sample, s);
return RedirectToPage();
}
public async Task<IActionResult> OnPostTestSmsAsync() public async Task<IActionResult> OnPostTestSmsAsync()
{ {
var s = await _settings.GetAsync(); var s = await _settings.GetAsync();
@@ -0,0 +1,104 @@
@page
@model JobsMedical.Web.Pages.Admin.SocialModel
@{
ViewData["Title"] = "شبکه‌های اجتماعی";
}
<div class="page-head">
<div class="container">
<h1>شبکه‌های اجتماعی</h1>
<p class="muted">انتشار خودکار «کادر آماده‌به‌کار امروز» در تلگرام و بله (متن) و بسته‌ی کپشن/هشتگ برای اینستاگرام.</p>
</div>
</div>
<div class="container section">
@if (Model.Message is not null) { <div class="alert alert-success">✓ @Model.Message</div> }
@if (Model.Error is not null) { <div class="alert alert-error">⚠ @Model.Error</div> }
<div class="layout-2">
<div>
<form method="post" class="card card-pad">
<label class="toggle-row">
<input type="checkbox" name="SocialEnabled" value="true" checked="@Model.SocialEnabled" />
<span class="t-body"><span>انتشار خودکار روشن باشد</span><span class="t-hint">روزانه چند بار، به‌صورت زمان‌بندی‌شده ارسال می‌شود.</span></span>
</label>
<div class="filter-group" style="display:flex; gap:8px;">
<div style="flex:0 0 160px;"><label>تعداد پست در روز</label><input type="number" name="SocialPostsPerDay" min="1" max="24" value="@Model.SocialPostsPerDay" dir="ltr" /></div>
<label class="proxy-toggle" style="align-self:end;"><input type="checkbox" name="SocialUseProxy" value="true" checked="@Model.SocialUseProxy" /> ارسال از طریق پروکسی (برای تلگرام)</label>
</div>
<div class="filter-group">
<label>سرتیتر پیام (Header)</label>
<textarea name="SocialHeader" rows="2" placeholder="مثلاً: 🩺 همکادر | مرجع شیفت و استخدام کادر درمان">@Model.SocialHeader</textarea>
</div>
<div class="filter-group">
<label>پاورقی پیام (Footer)</label>
<textarea name="SocialFooter" rows="2" placeholder="مثلاً: ثبت رایگان آگهی در hamkadr.ir | @@hamkadr">@Model.SocialFooter</textarea>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="SocialTelegramEnabled" value="true" checked="@Model.SocialTelegramEnabled" />
<span class="t-body"><span>📨 تلگرام (متن)</span><span class="t-hint">با بات تلگرام در کانال شما پست می‌شود.</span></span>
</label>
<div class="filter-group"><label>توکن بات تلگرام</label><input type="password" name="SocialTelegramBotToken" value="@Model.SocialTelegramBotToken" dir="ltr" placeholder="123456:ABC-..." /></div>
<div class="filter-group"><label>شناسه کانال/چت</label><input type="text" name="SocialTelegramChatId" value="@Model.SocialTelegramChatId" dir="ltr" placeholder="@@your_channel یا -100..." />
<p class="muted" style="font-size:11px; margin:4px 0 0;">بات باید ادمینِ کانال باشد.</p>
</div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="SocialBaleEnabled" value="true" checked="@Model.SocialBaleEnabled" />
<span class="t-body"><span>💬 بله (متن)</span></span>
</label>
<div class="filter-group"><label>توکن بات بله</label><input type="password" name="SocialBaleBotToken" value="@Model.SocialBaleBotToken" dir="ltr" /></div>
<div class="filter-group"><label>شناسه کانال/چت بله</label><input type="text" name="SocialBaleChatId" value="@Model.SocialBaleChatId" dir="ltr" placeholder="@@your_channel یا عدد" /></div>
</div>
<div class="source-box">
<label class="toggle-row">
<input type="checkbox" name="SocialInstagramEnabled" value="true" checked="@Model.SocialInstagramEnabled" />
<span class="t-body"><span>📷 اینستاگرام (نیمه‌خودکار)</span><span class="t-hint">کپشن و هشتگ آماده می‌شود؛ تصویر و انتشار را دستی انجام می‌دهی.</span></span>
</label>
<div class="filter-group"><label>هشتگ‌های اضافه (با فاصله یا خط جدید)</label>
<textarea name="InstagramHashtags" rows="2" dir="ltr" placeholder="#استخدام_پرستار #شیفت_تهران">@Model.InstagramHashtags</textarea>
</div>
</div>
<div class="settings-save">
<button type="submit" asp-page-handler="Save" class="btn btn-accent btn-block btn-lg">ذخیره تنظیمات</button>
</div>
</form>
<form method="post" style="margin-top:12px;">
<button type="submit" asp-page-handler="SendNow" class="btn btn-outline btn-block">📤 ارسال اکنون (تلگرام/بله)</button>
</form>
</div>
<aside>
<div class="card card-pad">
<h3 style="margin-top:0;">پیش‌نمایش پیام امروز</h3>
@if (Model.Preview is null || Model.Preview.Count == 0)
{
<p class="muted">امروز هنوز موردِ «آماده به کار» تازه‌ای ثبت نشده است.</p>
}
else
{
<p class="muted" style="font-size:12px;">@JalaliDate.ToPersianDigits(Model.Preview.Count.ToString()) مورد — همین متن به تلگرام/بله می‌رود.</p>
<pre style="white-space:pre-wrap; font-family:inherit; background:var(--bg); border:1px solid var(--line); border-radius:10px; padding:12px; font-size:13px; margin:0;">@Model.Preview.TelegramText</pre>
}
</div>
@if (Model.SocialInstagramEnabled && Model.Preview is not null && Model.Preview.Count > 0)
{
<div class="card card-pad" style="margin-top:12px;">
<h3 style="margin-top:0;">📷 بسته‌ی اینستاگرام</h3>
<label style="font-size:12px; font-weight:700;">کپشن (با هشتگ):</label>
<textarea id="igCaption" rows="8" style="width:100%; font-size:12.5px;">@Model.Preview.InstagramCaption</textarea>
<button type="button" class="btn btn-outline btn-block" style="margin-top:6px;" onclick="navigator.clipboard.writeText(document.getElementById('igCaption').value); this.textContent='کپی شد ✓';">کپی کپشن</button>
<p class="muted" style="font-size:11px; margin:8px 0 0;">تصویر کارت با فونت وزیر در نسخه‌ی بعدی اضافه می‌شود؛ فعلاً کپشن/هشتگ را کپی کن و در اینستاگرام پست کن.</p>
</div>
}
</aside>
</div>
</div>
@@ -0,0 +1,102 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using JobsMedical.Web.Services.Scraping;
using JobsMedical.Web.Services.Social;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Admin;
[Authorize(Roles = "Admin")]
public class SocialModel : PageModel
{
private readonly AppDbContext _db;
private readonly SettingsService _settings;
private readonly SocialPostService _social;
public SocialModel(AppDbContext db, SettingsService settings, SocialPostService social)
{
_db = db; _settings = settings; _social = social;
}
[TempData] public string? Message { get; set; }
[TempData] public string? Error { get; set; }
public SocialDigest? Preview { get; private set; }
[BindProperty] public bool SocialEnabled { get; set; }
[BindProperty] public int SocialPostsPerDay { get; set; }
[BindProperty] public string? SocialHeader { get; set; }
[BindProperty] public string? SocialFooter { get; set; }
[BindProperty] public bool SocialUseProxy { get; set; }
[BindProperty] public bool SocialTelegramEnabled { get; set; }
[BindProperty] public string? SocialTelegramBotToken { get; set; }
[BindProperty] public string? SocialTelegramChatId { get; set; }
[BindProperty] public bool SocialBaleEnabled { get; set; }
[BindProperty] public string? SocialBaleBotToken { get; set; }
[BindProperty] public string? SocialBaleChatId { get; set; }
[BindProperty] public bool SocialInstagramEnabled { get; set; }
[BindProperty] public string? InstagramHashtags { get; set; }
public async Task OnGetAsync()
{
var s = await _settings.GetAsync();
SocialEnabled = s.SocialEnabled;
SocialPostsPerDay = s.SocialPostsPerDay;
SocialHeader = s.SocialHeader;
SocialFooter = s.SocialFooter;
SocialUseProxy = s.SocialUseProxy;
SocialTelegramEnabled = s.SocialTelegramEnabled;
SocialTelegramBotToken = s.SocialTelegramBotToken;
SocialTelegramChatId = s.SocialTelegramChatId;
SocialBaleEnabled = s.SocialBaleEnabled;
SocialBaleBotToken = s.SocialBaleBotToken;
SocialBaleChatId = s.SocialBaleChatId;
SocialInstagramEnabled = s.SocialInstagramEnabled;
InstagramHashtags = s.InstagramHashtags;
Preview = await _social.BuildDigestAsync(s);
}
public async Task<IActionResult> OnPostSaveAsync()
{
var s = await _settings.GetAsync();
s.SocialEnabled = SocialEnabled;
s.SocialPostsPerDay = Math.Clamp(SocialPostsPerDay, 1, 24);
s.SocialHeader = SocialHeader?.Trim();
s.SocialFooter = SocialFooter?.Trim();
s.SocialUseProxy = SocialUseProxy;
s.SocialTelegramEnabled = SocialTelegramEnabled;
s.SocialTelegramBotToken = SocialTelegramBotToken?.Trim();
s.SocialTelegramChatId = SocialTelegramChatId?.Trim();
s.SocialBaleEnabled = SocialBaleEnabled;
s.SocialBaleBotToken = SocialBaleBotToken?.Trim();
s.SocialBaleChatId = SocialBaleChatId?.Trim();
s.SocialInstagramEnabled = SocialInstagramEnabled;
s.InstagramHashtags = InstagramHashtags?.Trim();
s.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
Message = "تنظیمات شبکه‌های اجتماعی ذخیره شد.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostSendNowAsync()
{
var r = await _social.PostAsync();
if (r.Count == 0) Error = r.Error ?? "موردی برای انتشار نبود.";
else
{
var parts = new List<string>();
if (r.TelegramOk) parts.Add("تلگرام ✓");
if (r.BaleOk) parts.Add("بله ✓");
Message = parts.Count > 0
? $"ارسال شد ({string.Join("، ", parts)}) — {JalaliDate.ToPersianDigits(r.Count.ToString())} مورد."
: "هیچ کانالی ارسال نشد؛ توکن/شناسه و فعال‌بودن را بررسی کن.";
if (r.Error is not null && parts.Count == 0) Error = r.Error;
}
return RedirectToPage();
}
}
@@ -88,8 +88,8 @@
@section Scripts { @section Scripts {
@if (!string.IsNullOrEmpty(Model.MapKey)) @if (!string.IsNullOrEmpty(Model.MapKey))
{ {
<link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.css" /> <link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.css" />
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.js"></script> <script src="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.js"></script>
} }
<script> <script>
(function () { (function () {
+9
View File
@@ -14,6 +14,15 @@
</p> </p>
} }
@if (Model.AdminDetail is not null)
{
<div style="margin:16px 0; padding:14px; border:1px solid var(--danger); border-radius:10px; background:#fff5f5; direction:ltr; text-align:left;">
<strong>🔧 جزئیات خطا (فقط برای ادمین)</strong>
@if (Model.AdminPath is not null) { <div style="margin:6px 0;"><code>@Model.AdminPath</code></div> }
<pre style="white-space:pre-wrap; word-break:break-word; font-size:12px; margin:8px 0 0; max-height:50vh; overflow:auto;">@Model.AdminDetail</pre>
</div>
}
<h3>Development Mode</h3> <h3>Development Mode</h3>
<p> <p>
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred. Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
+16
View File
@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
@@ -12,9 +13,24 @@ public class ErrorModel : PageModel
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
/// <summary>The real exception — shown ONLY to a logged-in Admin, so production 500s can be
/// diagnosed without server-log access. Hidden from everyone else.</summary>
public string? AdminDetail { get; private set; }
public string? AdminPath { get; private set; }
public void OnGet() public void OnGet()
{ {
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
if (User.IsInRole("Admin"))
{
var feat = HttpContext.Features.Get<IExceptionHandlerPathFeature>();
AdminPath = feat?.Path;
if (feat?.Error is { } ex)
AdminDetail = ex.GetType().FullName + ": " + ex.Message
+ (ex.InnerException is { } ie ? $"\n ↳ {ie.GetType().Name}: {ie.Message}" : "")
+ "\n\n" + ex.StackTrace;
}
} }
} }
@@ -0,0 +1,160 @@
@page "{id:int}"
@model JobsMedical.Web.Pages.Facilities.DetailsModel
@{
var f = Model.Facility!;
ViewData["Title"] = f.Name;
ViewData["Description"] = $"{f.Name} — {f.City?.Name}. شیفت‌ها و موقعیت‌های استخدامی کادر درمان در همکادر.";
string TypeLabel(FacilityType t) => t switch
{
FacilityType.Hospital => "بیمارستان",
FacilityType.Clinic => "کلینیک",
_ => "درمانگاه",
};
}
<div class="page-head">
<div class="container">
<h1 style="display:flex; align-items:center; gap:8px; flex-wrap:wrap;">
@f.Name
@if (f.IsVerified) { <span class="badge badge-verified">✓ تأیید شده</span> }
</h1>
<p class="muted">
@TypeLabel(f.Type) · 📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")
@if (Model.RatingCount > 0)
{
<text> · <span style="color:#f59e0b;">★</span> @JalaliDate.ToPersianDigits(Model.AvgRating.ToString("0.#")) (@JalaliDate.ToPersianDigits(Model.RatingCount.ToString()) نظر)</text>
}
</p>
</div>
</div>
<div class="container section">
@if (Model.Reported) { <div class="alert alert-success">✓ گزارش شما ثبت شد. متشکریم.</div> }
@* detail-grid = content(1fr) + sidebar(340px); the content div is first, so it gets the wide
column. (layout-2 is sidebar-first/270px and was squeezing the job cards into a narrow strip.) *@
<div class="detail-grid">
<div>
@if (Model.Shifts.Count == 0 && Model.Jobs.Count == 0)
{
<div class="card empty-state">در حال حاضر فرصت بازی در این مرکز ثبت نشده است.</div>
}
@if (Model.Shifts.Count > 0)
{
<div class="section-head"><h2>شیفت‌های باز (@JalaliDate.ToPersianDigits(Model.Shifts.Count.ToString()))</h2></div>
<div class="grid grid-3">
@foreach (var s in Model.Shifts) { <partial name="_ShiftCard" model="s" /> }
</div>
}
@if (Model.Jobs.Count > 0)
{
<div class="section-head" style="margin-top:18px;"><h2>موقعیت‌های استخدامی (@JalaliDate.ToPersianDigits(Model.Jobs.Count.ToString()))</h2></div>
<div class="grid grid-3">
@foreach (var j in Model.Jobs) { <partial name="_JobCard" model="j" /> }
</div>
}
<div class="section-head" style="margin-top:22px;"><h2>نظرات و امتیاز کاربران</h2></div>
@if (Model.ReviewMsg is not null) { <div class="alert alert-success">@Model.ReviewMsg</div> }
<div class="card card-pad" style="margin-bottom:14px;">
@if (Model.CanReview)
{
<form method="post" asp-page-handler="Review" asp-route-id="@f.Id">
<label style="font-weight:700;">@(Model.AlreadyReviewed ? "ویرایش نظر شما" : "ثبت نظر و امتیاز")</label>
<div class="star-input" style="margin:8px 0;">
@for (var i = 5; i >= 1; i--)
{
<input type="radio" name="stars" id="st@(i)" value="@i" @(i == 5 ? "checked" : "") />
<label for="st@(i)" title="@JalaliDate.ToPersianDigits(i.ToString())">★</label>
}
</div>
<textarea name="comment" rows="2" placeholder="تجربه‌ات از همکاری با این مرکز..."></textarea>
<button type="submit" class="btn btn-accent" style="margin-top:8px;">ثبت نظر</button>
</form>
}
else
{
<p class="muted" style="margin:0;">برای ثبت نظر <a asp-page="/Account/Login" asp-route-returnUrl="/Facilities/Details/@f.Id">وارد شو</a>.</p>
}
</div>
@if (Model.Reviews.Count == 0)
{
<p class="muted">هنوز نظری ثبت نشده است.</p>
}
else
{
foreach (var rv in Model.Reviews)
{
<div class="card card-pad" style="margin-bottom:8px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<strong>@rv.Who</strong>
<span style="color:#f59e0b; letter-spacing:2px;">@(new string('★', rv.Stars))<span style="color:var(--line);">@(new string('★', 5 - rv.Stars))</span></span>
</div>
@if (!string.IsNullOrWhiteSpace(rv.Comment)) { <p style="margin:6px 0 0;">@rv.Comment</p> }
<p class="muted" style="font-size:12px; margin:6px 0 0;">@JalaliDate.ToLongDate(DateOnly.FromDateTime(rv.When))</p>
</div>
}
}
</div>
<aside>
<div class="card card-pad">
<h3 style="margin-top:0;">اطلاعات مرکز</h3>
<div class="info-row"><span class="k">نوع</span><span class="v">@TypeLabel(f.Type)</span></div>
<div class="info-row"><span class="k">شهر</span><span class="v">@f.City?.Name</span></div>
@if (f.District is not null) { <div class="info-row"><span class="k">محله</span><span class="v">@f.District.Name</span></div> }
@if (!string.IsNullOrEmpty(f.Address)) { <div class="info-row"><span class="k">آدرس</span><span class="v">@f.Address</span></div> }
<div class="info-row"><span class="k">وضعیت</span><span class="v">@(f.IsVerified ? "✓ تأیید‌شده" : "تأیید نشده")</span></div>
</div>
<div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">موقعیت مکانی</h3>
@if (f.Lat is not null && f.Lng is not null)
{
var latS = f.Lat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var lngS = f.Lng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
@if (!string.IsNullOrEmpty(Model.MapKey))
{
<div id="facmap" data-lat="@latS" data-lng="@lngS" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
}
<a class="btn btn-outline btn-block" style="margin-top:8px;" target="_blank" rel="noopener"
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
}
else
{
<p class="muted" style="margin:0;">مختصات این مرکز ثبت نشده است.</p>
}
</div>
<div class="card card-pad" style="margin-top:16px;">
<details>
<summary class="muted" style="font-size:13px; cursor:pointer;">شکایت از این مرکز</summary>
<form method="post" action="/report" style="margin-top:8px;">
<input type="hidden" name="targetType" value="Facility" />
<input type="hidden" name="targetId" value="@f.Id" />
<input type="hidden" name="label" value="@f.Name" />
<input type="hidden" name="returnUrl" value="/Facilities/Details/@f.Id" />
<textarea name="reason" rows="2" placeholder="شکایت یا گزارش درباره این مرکز..." required></textarea>
<button type="submit" class="btn btn-outline btn-block" style="margin-top:6px;">ثبت شکایت</button>
</form>
</details>
</div>
</aside>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.MapKey) && Model.Facility?.Lat is not null)
{
<partial name="_NeshanMap" model="Model.MapKey" />
}
@* Place/clinic structured data — only for a real named facility (not the «نامشخص» placeholder). *@
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
{
var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}";
@Html.Raw("<script type=\"application/ld+json\">"
+ JobsMedical.Web.Services.SeoJsonLd.MedicalOrganization(f, bu, Model.AvgRating, Model.RatingCount)
+ "</script>")
}
@@ -0,0 +1,92 @@
using System.Security.Claims;
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Facilities;
public class DetailsModel : PageModel
{
private readonly AppDbContext _db;
private readonly JobsMedical.Web.Services.Scraping.SettingsService _settings;
public DetailsModel(AppDbContext db, JobsMedical.Web.Services.Scraping.SettingsService settings)
{
_db = db;
_settings = settings;
}
public Facility? Facility { get; private set; }
public List<Shift> Shifts { get; private set; } = new();
public List<JobOpening> Jobs { get; private set; } = new();
public string? MapKey { get; private set; }
public bool Reported { get; private set; }
public record ReviewRow(string Who, int Stars, string? Comment, DateTime When);
public List<ReviewRow> Reviews { get; private set; } = new();
public double AvgRating { get; private set; }
public int RatingCount { get; private set; }
public bool CanReview { get; private set; } // logged in & not yet reviewed
public bool AlreadyReviewed { get; private set; }
[TempData] public string? ReviewMsg { get; set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Facility = await _db.Facilities.Include(f => f.City).Include(f => f.District)
.FirstOrDefaultAsync(f => f.Id == id);
if (Facility is null) return NotFound();
var today = DateOnly.FromDateTime(DateTime.UtcNow);
Shifts = await _db.Shifts.Include(s => s.Role)
.Where(s => s.FacilityId == id && s.Status == ShiftStatus.Open && s.Date >= today)
.OrderBy(s => s.Date).Take(12).ToListAsync();
Jobs = await _db.JobOpenings.Include(j => j.Role)
.Where(j => j.FacilityId == id && j.Status == ShiftStatus.Open)
.OrderByDescending(j => j.CreatedAt).Take(12).ToListAsync();
MapKey = (await _settings.GetAsync()).NeshanMapKey;
Reported = Request.Query["reported"] == "1";
await LoadReviewsAsync(id);
return Page();
}
public async Task<IActionResult> OnPostReviewAsync(int id, int stars, string? comment)
{
if (User.Identity?.IsAuthenticated != true)
return RedirectToPage("/Account/Login", new { returnUrl = $"/Facilities/Details/{id}" });
var uid = int.Parse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier)!);
if (!await _db.Facilities.AnyAsync(f => f.Id == id)) return NotFound();
stars = Math.Clamp(stars, 1, 5);
var existing = await _db.Reviews.FirstOrDefaultAsync(r => r.FacilityId == id && r.UserId == uid);
if (existing is null)
_db.Reviews.Add(new Review { FacilityId = id, UserId = uid, Stars = stars, Comment = comment?.Trim() });
else { existing.Stars = stars; existing.Comment = comment?.Trim(); existing.CreatedAt = DateTime.UtcNow; }
await _db.SaveChangesAsync();
ReviewMsg = "نظر شما ثبت شد. متشکریم.";
return RedirectToPage(new { id });
}
private async Task LoadReviewsAsync(int id)
{
var rows = await _db.Reviews.Include(r => r.User)
.Where(r => r.FacilityId == id && r.IsApproved)
.OrderByDescending(r => r.CreatedAt).ToListAsync();
RatingCount = rows.Count;
AvgRating = rows.Count > 0 ? Math.Round(rows.Average(r => r.Stars), 1) : 0;
Reviews = rows.Take(20).Select(r => new ReviewRow(
r.User.FullName ?? "کاربر", r.Stars, r.Comment, r.CreatedAt)).ToList();
if (User.Identity?.IsAuthenticated == true &&
int.TryParse(User.FindFirstValue(System.Security.Claims.ClaimTypes.NameIdentifier), out var uid))
{
AlreadyReviewed = rows.Any(r => r.UserId == uid)
|| await _db.Reviews.AnyAsync(r => r.FacilityId == id && r.UserId == uid);
CanReview = true;
}
}
}
@@ -18,7 +18,7 @@
<div class="grid grid-3"> <div class="grid grid-3">
@foreach (var row in Model.Rows) @foreach (var row in Model.Rows)
{ {
<div class="card card-pad"> <a class="card card-pad" asp-page="/Facilities/Details" asp-route-id="@row.Facility.Id" style="display:block;">
<div class="row" style="display:flex; justify-content:space-between; align-items:center;"> <div class="row" style="display:flex; justify-content:space-between; align-items:center;">
<span class="facility" style="font-weight:800; font-size:16px;">@row.Facility.Name</span> <span class="facility" style="font-weight:800; font-size:16px;">@row.Facility.Name</span>
@if (row.Facility.IsVerified) @if (row.Facility.IsVerified)
@@ -32,12 +32,11 @@
</p> </p>
<div class="foot" style="display:flex; justify-content:space-between; align-items:center; border-top:1px solid var(--line); padding-top:12px;"> <div class="foot" style="display:flex; justify-content:space-between; align-items:center; border-top:1px solid var(--line); padding-top:12px;">
<span class="pay" style="color:var(--primary-dark); font-weight:800;"> <span class="pay" style="color:var(--primary-dark); font-weight:800;">
@JalaliDate.ToPersianDigits(row.OpenShifts.ToString()) شیفت باز @JalaliDate.ToPersianDigits(row.OpenListings.ToString()) آگهی فعال
</span> </span>
<a class="btn btn-outline" style="padding:6px 14px;" <span class="btn btn-outline" style="padding:6px 14px;">مشاهده مرکز</span>
asp-page="/Calendar/Index" asp-route-FacilityId="@row.Facility.Id">تقویم</a>
</div>
</div> </div>
</a>
} }
</div> </div>
</div> </div>
@@ -10,21 +10,36 @@ public class IndexModel : PageModel
private readonly AppDbContext _db; private readonly AppDbContext _db;
public IndexModel(AppDbContext db) => _db = db; public IndexModel(AppDbContext db) => _db = db;
public record FacilityRow(Facility Facility, int OpenShifts); public record FacilityRow(Facility Facility, int OpenListings);
public List<FacilityRow> Rows { get; private set; } = new(); public List<FacilityRow> Rows { get; private set; } = new();
// The shared placeholder for unnamed aggregated ads is not a real, browseable facility.
private const string PlaceholderName = "نامشخص / ثبت نشده";
public async Task OnGetAsync() public async Task OnGetAsync()
{ {
var today = DateOnly.FromDateTime(DateTime.UtcNow); var today = DateOnly.FromDateTime(DateTime.UtcNow);
var facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync(); var jobCutoff = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
var counts = await _db.Shifts
var facilities = await _db.Facilities.Include(f => f.City)
.Where(f => f.Name != PlaceholderName).ToListAsync();
// "Active listings" = open shifts + open (fresh) job openings — a facility that is hiring
// shouldn't read «۰ شیفت باز» just because it posted a job rather than a dated shift.
var shiftCounts = await _db.Shifts
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today) .Where(s => s.Status == ShiftStatus.Open && s.Date >= today)
.GroupBy(s => s.FacilityId) .GroupBy(s => s.FacilityId).Select(g => new { g.Key, C = g.Count() })
.Select(g => new { g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Key, x => x.C);
.ToDictionaryAsync(x => x.Key, x => x.Count); var jobCounts = await _db.JobOpenings
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCutoff)
.GroupBy(j => j.FacilityId).Select(g => new { g.Key, C = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.C);
Rows = facilities Rows = facilities
.Select(f => new FacilityRow(f, counts.GetValueOrDefault(f.Id))) .Select(f => new FacilityRow(f, shiftCounts.GetValueOrDefault(f.Id) + jobCounts.GetValueOrDefault(f.Id)))
.OrderByDescending(r => r.OpenListings) // active facilities first
.ThenByDescending(r => r.Facility.IsVerified)
.ThenBy(r => r.Facility.Name)
.ToList(); .ToList();
} }
} }
+52 -70
View File
@@ -2,7 +2,7 @@
@model IndexModel @model IndexModel
@{ @{
ViewData["Title"] = null; // use default site title for the home page (best for SEO) ViewData["Title"] = null; // use default site title for the home page (best for SEO)
ViewData["Description"] = "همکادر؛ سریع‌ترین راه برای کادر درمان (پزشک، پرستار، ماما، تکنسین) جهت یافتن شیفت و موقعیت استخدامی در بیمارستان‌ها و کلینیک‌های تهران. به‌جای گشتن در کانال‌های تلگرام و بله، همه فرصت‌ها یک‌جا."; ViewData["Description"] = "یافتن شیفت و موقعیت استخدامی کادر درمان (پزشک، پرستار، ماما، تکنسین) در بیمارستان‌ها و کلینیک‌های تهران همهٔ فرصت‌ها یک‌جا در همکادر.";
} }
<section class="hero"> <section class="hero">
@@ -14,106 +14,64 @@
مرکز درمانی، محل و تقویم هفتگی — یک‌جا. مرکز درمانی، محل و تقویم هفتگی — یک‌جا.
</p> </p>
<form class="search-card" method="get" asp-page="/Shifts/Index"> <form class="hero-search" method="get" action="/Search" role="search" data-suggest>
<div class="field"> <div class="hero-search-pill">
<label>شهر</label> <span class="hs-ico">🔎</span>
<select name="cityId"> <input type="search" name="Q" autocomplete="off"
<option value="">همه شهرها</option> placeholder="جستجو: پرستار، mmt، دندان‌پزشک…" />
@foreach (var c in Model.Cities) <button type="submit" class="btn btn-accent btn-lg hs-submit" aria-label="جستجو">
{ <span class="hs-submit-txt">جستجو</span>
<option value="@c.Id">@c.Name</option> <span class="hs-submit-ico" aria-hidden="true">🔎</span>
} </button>
</select>
</div> </div>
<div class="field"> <div class="hero-chips">
<label>نقش</label> <span class="hc-label">جستجوهای پرطرفدار:</span>
<select name="roleId"> <a href="/Search?Q=%D9%BE%D8%B1%D8%B3%D8%AA%D8%A7%D8%B1">پرستار</a>
<option value="">همه نقش‌ها</option> <a href="/Search?Q=%D9%BE%D8%B2%D8%B4%DA%A9">پزشک</a>
@foreach (var r in Model.Roles) <a href="/Search?Q=%D8%B4%DB%8C%D9%81%D8%AA%20%D8%B4%D8%A8">شیفت شب</a>
{ <a href="/Search?Q=%D8%A2%D9%85%D8%A7%D8%AF%D9%87%20%D8%A8%D9%87%20%DA%A9%D8%A7%D8%B1">آماده به کار</a>
<option value="@r.Id">@r.Name</option>
}
</select>
</div>
<div class="field">
<label>نوع شیفت</label>
<select name="shiftType">
<option value="">همه</option>
<option value="0">صبح</option>
<option value="1">عصر</option>
<option value="2">شب</option>
<option value="3">آنکال</option>
</select>
</div>
<div class="field">
<label>&nbsp;</label>
<button type="submit" class="btn btn-accent btn-block btn-lg">جستجوی فرصت‌ها</button>
</div> </div>
</form> </form>
<div class="stat-pills"> <div class="stat-pills">
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.OpenShiftCount.ToString())</span><span class="l">شیفت باز</span></div> <div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.OpenJobCount.ToString())</span><span class="l">موقعیت استخدام</span></div>
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.FacilityCount.ToString())</span><span class="l">مرکز درمانی</span></div> <div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.FacilityCount.ToString())</span><span class="l">مرکز درمانی</span></div>
<div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.CityCount.ToString())</span><span class="l">شهر فعال</span></div> <div class="stat-pill"><span class="n">@JalaliDate.ToPersianDigits(Model.CityCount.ToString())</span><span class="l">شهر فعال</span></div>
</div> </div>
</div> </div>
</section> </section>
@if (Model.Recommendations.Count > 0)
{
<section class="section" style="padding-bottom:0;"> <section class="section" style="padding-bottom:0;">
<div class="container"> <div class="container">
@if (Model.HasPersonalization) <a asp-page="/Recommendations/Index" class="rec-banner" style="text-decoration:none; color:#fff;">
{
<div class="rec-banner">
<div> <div>
<h2 style="margin:0 0 4px;">✨ پیشنهادهای ویژه شما</h2> <h2 style="margin:0 0 4px;">✨ پیشنهادهای ویژه شما</h2>
<span style="opacity:.9; font-size:14px;">بر اساس علاقه‌مندی‌ها و فعالیت شما انتخاب شده‌اند</span> <span style="opacity:.9; font-size:14px;">فرصت‌های متناسب با نقش، شهر و فعالیت شما — همه یک‌جا</span>
</div>
<a class="btn btn-outline" asp-page="/Preferences/Index">ویرایش علاقه‌مندی‌ها</a>
</div>
}
else
{
<div class="rec-banner">
<div>
<h2 style="margin:0 0 4px;">پیشنهادها را شخصی‌سازی کن</h2>
<span style="opacity:.9; font-size:14px;">نقش، شهر و نوع شیفت دلخواهت را بگو تا بهترین فرصت‌ها را برایت پیدا کنیم</span>
</div>
<a class="btn btn-outline" asp-page="/Preferences/Index">تنظیم علاقه‌مندی‌ها</a>
</div>
}
<div class="grid grid-3">
@foreach (var rec in Model.Recommendations)
{
<partial name="_RecommendationCard" model="rec" />
}
</div> </div>
<span class="btn btn-outline">مشاهده پیشنهادها ←</span>
</a>
</div> </div>
</section> </section>
}
@* Shifts are rare for aggregated content (most ads are ongoing hiring, not dated shifts) — only
show the section when there are real open shifts, so we never display a fabricated/empty date. *@
@if (Model.LatestShifts.Count > 0)
{
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2>جدیدترین شیفت‌ها</h2> <h2>جدیدترین شیفت‌ها</h2>
<a asp-page="/Shifts/Index">مشاهده همه ←</a> <a href="/Shifts">مشاهده همه ←</a>
</div> </div>
@if (Model.LatestShifts.Count == 0)
{
<div class="empty-state">فعلاً شیفت بازی ثبت نشده است.</div>
}
else
{
<div class="grid grid-3"> <div class="grid grid-3">
@foreach (var s in Model.LatestShifts) @foreach (var s in Model.LatestShifts)
{ {
<partial name="_ShiftCard" model="s" /> <partial name="_ShiftCard" model="s" />
} }
</div> </div>
}
</div> </div>
</section> </section>
}
@if (Model.LatestJobs.Count > 0) @if (Model.LatestJobs.Count > 0)
{ {
@@ -121,7 +79,7 @@
<div class="container"> <div class="container">
<div class="section-head"> <div class="section-head">
<h2>فرصت‌های استخدامی</h2> <h2>فرصت‌های استخدامی</h2>
<a asp-page="/Jobs/Index">مشاهده همه ←</a> <a href="/Jobs">مشاهده همه ←</a>
</div> </div>
<div class="grid grid-3"> <div class="grid grid-3">
@foreach (var j in Model.LatestJobs) @foreach (var j in Model.LatestJobs)
@@ -133,6 +91,24 @@
</section> </section>
} }
@if (Model.LatestTalent.Count > 0)
{
<section class="section" style="padding-top:0;">
<div class="container">
<div class="section-head">
<h2>کادر درمان آماده به کار</h2>
<a asp-page="/Talent/Index">مشاهده همه ←</a>
</div>
<div class="grid grid-3">
@foreach (var t in Model.LatestTalent)
{
<partial name="_TalentCard" model="t" />
}
</div>
</div>
</section>
}
<section class="section" style="background: var(--surface); border-top: 1px solid var(--line);"> <section class="section" style="background: var(--surface); border-top: 1px solid var(--line);">
<div class="container"> <div class="container">
<div class="section-head"><h2>چطور کار می‌کند؟</h2></div> <div class="section-head"><h2>چطور کار می‌کند؟</h2></div>
@@ -152,3 +128,9 @@
</div> </div>
</div> </div>
</section> </section>
@section Head {
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Organization(bu) + "</script>")
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.WebSite(bu) + "</script>")
}
+13 -12
View File
@@ -9,23 +9,19 @@ namespace JobsMedical.Web.Pages;
public class IndexModel : PageModel public class IndexModel : PageModel
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly RecommendationService _recs;
private readonly InterestService _interest;
public IndexModel(AppDbContext db, RecommendationService recs, InterestService interest) public IndexModel(AppDbContext db)
{ {
_db = db; _db = db;
_recs = recs;
_interest = interest;
} }
public List<Recommendation> Recommendations { get; private set; } = new();
public bool HasPersonalization { get; private set; }
public List<Shift> LatestShifts { get; private set; } = new(); public List<Shift> LatestShifts { get; private set; } = new();
public List<JobOpening> LatestJobs { get; private set; } = new(); public List<JobOpening> LatestJobs { get; private set; } = new();
public List<TalentListing> LatestTalent { get; private set; } = new();
public List<City> Cities { get; private set; } = new(); public List<City> Cities { get; private set; } = new();
public List<Role> Roles { get; private set; } = new(); public List<Role> Roles { get; private set; } = new();
public int OpenShiftCount { get; private set; } public int OpenShiftCount { get; private set; }
public int OpenJobCount { get; private set; }
public int FacilityCount { get; private set; } public int FacilityCount { get; private set; }
public int CityCount { get; private set; } public int CityCount { get; private set; }
@@ -33,11 +29,6 @@ public class IndexModel : PageModel
{ {
var today = DateOnly.FromDateTime(DateTime.UtcNow); var today = DateOnly.FromDateTime(DateTime.UtcNow);
Recommendations = await _recs.GetForVisitorAsync(6);
// "Personalized" = we actually used a signal (prefs or behavior), not just cold-start freshness.
HasPersonalization = (await _interest.GetPreferencesAsync())?.HasAny == true
|| (await _interest.RecentEventsAsync(1)).Count > 0;
LatestShifts = await _db.Shifts LatestShifts = await _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City) .Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Role) .Include(s => s.Role)
@@ -56,9 +47,19 @@ public class IndexModel : PageModel
.Take(3) .Take(3)
.ToListAsync(); .ToListAsync();
LatestTalent = await _db.TalentListings
.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
.Where(t => t.Status == ShiftStatus.Open
&& t.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc)
.OrderByDescending(t => t.CreatedAt)
.Take(6) // two rows of the grid-3 «آماده به کار» section
.ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync(); Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync(); Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
OpenShiftCount = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today); OpenShiftCount = await _db.Shifts.CountAsync(s => s.Status == ShiftStatus.Open && s.Date >= today);
OpenJobCount = await _db.JobOpenings.CountAsync(j => j.Status == ShiftStatus.Open
&& j.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc);
FacilityCount = await _db.Facilities.CountAsync(); FacilityCount = await _db.Facilities.CountAsync();
CityCount = await _db.Cities.CountAsync(c => c.IsActive); CityCount = await _db.Cities.CountAsync(c => c.IsActive);
} }
+85 -46
View File
@@ -3,8 +3,18 @@
@{ @{
var j = Model.Job!; var j = Model.Job!;
var f = j.Facility!; var f = j.Facility!;
var hasFac = JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f); // false for the «نامشخص» placeholder
var jobContacts = (j.Contacts ?? new List<JobsMedical.Web.Models.ContactMethod>()).ToList();
// Map: listing's own approx coords (aggregated) then facility's; aggregated = approximate area.
var mapLat = j.Lat ?? f.Lat;
var mapLng = j.Lng ?? f.Lng;
var mapApprox = j.Source == JobsMedical.Web.Models.ShiftSource.Aggregated;
ViewData["Title"] = j.Title; ViewData["Title"] = j.Title;
ViewData["Description"] = $"{j.Title} در {f.Name}، {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}."; ViewData["Description"] = hasFac
? $"{j.Title} در {f.Name}، {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}."
: $"{j.Title} در {f.City?.Name}. موقعیت استخدامی برای {j.Role?.Name}.";
// Don't let Google index filled/expired openings (avoids dead "Job for jobs" results).
if (j.Status != JobsMedical.Web.Models.ShiftStatus.Open) ViewData["NoIndex"] = true;
string empLabel = j.EmploymentType switch string empLabel = j.EmploymentType switch
{ {
EmploymentType.FullTime => "تمام‌وقت", EmploymentType.FullTime => "تمام‌وقت",
@@ -15,18 +25,23 @@
string salary; string salary;
if (j.SalaryMin is null && j.SalaryMax is null) salary = "توافقی"; if (j.SalaryMin is null && j.SalaryMax is null) salary = "توافقی";
else if (j.SalaryMin == j.SalaryMax) salary = JalaliDate.Toman(j.SalaryMin) + " ماهانه"; else if (j.SalaryMin == j.SalaryMax) salary = JalaliDate.Toman(j.SalaryMin) + " ماهانه";
else if (j.SalaryMax is null) salary = "از " + JalaliDate.Toman(j.SalaryMin) + " ماهانه"; // min only — avoid «تا توافقی»
else salary = $"از {JalaliDate.ToPersianDigits((j.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(j.SalaryMax)} ماهانه"; else salary = $"از {JalaliDate.ToPersianDigits((j.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(j.SalaryMax)} ماهانه";
var crumbs = new List<JobsMedical.Web.Services.Crumb> { new("خانه", "/"), new("استخدام", "/Jobs") };
if (j.Role is not null) crumbs.Add(new(j.Role.Name, "/استخدام/" + JobsMedical.Web.Services.SeoSlug.Of(j.Role.Name)));
crumbs.Add(new(j.Title, null));
} }
<div class="page-head"> <div class="page-head">
<div class="container"> <div class="container">
<partial name="_Breadcrumbs" model="crumbs" />
<div class="row" style="display:flex; gap:10px; align-items:center;"> <div class="row" style="display:flex; gap:10px; align-items:center;">
<span class="badge badge-job">@empLabel</span> <span class="badge badge-job">@empLabel</span>
@if (j.Role is not null) { <span class="badge badge-type">@j.Role.Name</span> } @if (j.Role is not null) { <span class="badge badge-type">@j.Role.Name</span> }
@if (f.IsVerified) { <span class="badge badge-verified">✓ مرکز تأیید شده</span> } @if (f.IsVerified) { <span class="badge badge-verified">✓ مرکز تأیید شده</span> }
</div> </div>
<h1 style="margin-top:8px;">@j.Title</h1> <h1 style="margin-top:8px;">@j.Title</h1>
<p class="muted">🏥 @f.Name — 📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")</p> <p class="muted">@(hasFac ? "🏥 " + f.Name + " — " : "")📍 @f.City?.Name@(f.District is not null ? "، " + f.District.Name : "")</p>
</div> </div>
</div> </div>
@@ -35,10 +50,34 @@
<div> <div>
@if (Model.ShowContact) @if (Model.ShowContact)
{ {
<div class="alert alert-success"> <div class="contact-reveal" style="margin-bottom:16px;">
✓ تمایل شما ثبت شد. برای پیگیری استخدام با مرکز تماس بگیرید: <h4>✓ راه‌های ارتباطی</h4>
<strong>@(f.Phone ?? "شماره ثبت نشده")</strong> @if (jobContacts.Count > 0)
@if (!string.IsNullOrEmpty(f.BaleId)) { <text> — بله: @f.BaleId</text> } {
@* Numbers from THIS ad (aggregated) — the correct, per-listing contacts. *@
<partial name="_ContactList" model="jobContacts" />
}
else if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f) && (!string.IsNullOrEmpty(f.Phone) || !string.IsNullOrEmpty(f.BaleId)))
{
@if (!string.IsNullOrEmpty(f.Phone))
{
<div class="contact-row">
<span class="c-meta"><span class="c-type">📞 تلفن مرکز</span><span class="c-val" dir="ltr">@f.Phone</span></span>
<a class="btn btn-accent" href="tel:@f.Phone">تماس</a>
</div>
}
@if (!string.IsNullOrEmpty(f.BaleId))
{
<div class="contact-row">
<span class="c-meta"><span class="c-type">💬 بله</span><span class="c-val" dir="ltr">@f.BaleId</span></span>
<a class="btn btn-outline" href="https://ble.ir/@f.BaleId" target="_blank" rel="noopener">باز کردن</a>
</div>
}
}
else
{
<p class="muted" style="margin:0;">شماره‌ای ثبت نشده است.</p>
}
</div> </div>
} }
@if (Model.Saved) @if (Model.Saved)
@@ -78,19 +117,14 @@
<div class="pay" style="font-size:19px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">@salary</div> <div class="pay" style="font-size:19px; margin-bottom:6px; color:var(--primary-dark); font-weight:800;">@salary</div>
<p class="muted" style="font-size:13px; margin-top:0;">@empLabel</p> <p class="muted" style="font-size:13px; margin-top:0;">@empLabel</p>
<div class="aside-apply"> <div class="aside-apply">
<form method="post"> <button type="button" class="btn btn-accent btn-block btn-lg contact-trigger"
<button type="submit" asp-page-handler="Interest" asp-route-id="@j.Id" data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده راه ارتباطی</button>
class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده راه ارتباطی</button>
</form>
</div>
<div style="display:flex; gap:8px; margin-top:8px;">
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-block">♡ ذخیره</button>
</form>
<form method="post" style="flex:1;">
<button type="submit" asp-page-handler="Dismiss" asp-route-id="@j.Id" class="btn btn-outline btn-block">✕ علاقه‌مند نیستم</button>
</form>
</div> </div>
<button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-block like-trigger" style="margin-top:8px;"
data-like-type="job" data-like-id="@j.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> پسندیدم
<span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
</button>
@if (Model.Reported) @if (Model.Reported)
{ {
<p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p> <p class="muted" style="font-size:12px; margin:8px 0 0;">✓ گزارش شما ثبت شد. متشکریم.</p>
@@ -111,7 +145,7 @@
@if (j.Facility is not null) @if (j.Facility is not null)
{ {
<details style="margin-top:6px;"> <details style="margin-top:6px;">
<summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این مرکز (@j.Facility.Name)</summary> <summary class="muted" style="font-size:12px; cursor:pointer;">شکایت از این @(hasFac ? "مرکز (" + j.Facility.Name + ")" : "آگهی")</summary>
<form method="post" action="/report" style="margin-top:8px;"> <form method="post" action="/report" style="margin-top:8px;">
<input type="hidden" name="targetType" value="Facility" /> <input type="hidden" name="targetType" value="Facility" />
<input type="hidden" name="targetId" value="@j.Facility.Id" /> <input type="hidden" name="targetId" value="@j.Facility.Id" />
@@ -125,15 +159,15 @@
} }
</div> </div>
@if (j.Facility?.Lat is not null && j.Facility?.Lng is not null)
{
var latS = j.Facility.Lat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var lngS = j.Facility.Lng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
<div class="card card-pad" style="margin-top:16px;"> <div class="card card-pad" style="margin-top:16px;">
<h3 style="margin-top:0;">موقعیت مکانی</h3> <h3 style="margin-top:0;">موقعیت مکانی</h3>
@if (mapLat is not null && mapLng is not null)
{
var latS = mapLat.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var lngS = mapLng.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
@if (!string.IsNullOrEmpty(Model.MapKey)) @if (!string.IsNullOrEmpty(Model.MapKey))
{ {
<div id="facmap" data-lat="@latS" data-lng="@lngS" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div> <div id="facmap" data-lat="@latS" data-lng="@lngS" data-approx="@(mapApprox ? "true" : "false")" style="height:200px; border-radius:10px; overflow:hidden; border:1px solid var(--line);"></div>
} }
else else
{ {
@@ -141,39 +175,44 @@
🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small> 🗺️<br /><small class="muted" dir="ltr">@latS، @lngS</small>
</div> </div>
} }
@if (mapApprox)
{
<p class="muted" style="font-size:12px; margin:8px 0 0;">📍 محدودهٔ تقریبی (برگرفته از آگهی منبع)؛ موقعیت دقیق نیست.</p>
}
<a class="btn btn-outline btn-block" style="margin-top:8px;" target="_blank" rel="noopener" <a class="btn btn-outline btn-block" style="margin-top:8px;" target="_blank" rel="noopener"
href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a> href="https://neshan.org/maps/@(latS),@(lngS),16z">مسیریابی در نشان</a>
</div>
} }
else
{
<p class="muted" style="margin:0;">مختصات این آگهی ثبت نشده است.</p>
}
</div>
</aside> </aside>
</div> </div>
</div> </div>
@* Sticky bottom action bar — mobile only. *@ @* Sticky bottom action bar — mobile only. *@
<div class="mobile-action-bar"> <div class="mobile-action-bar">
@if (Model.ShowContact) <button type="button" class="btn btn-accent btn-lg cta-main contact-trigger"
{ data-contact-type="job" data-contact-id="@j.Id">📞 اعلام تمایل و مشاهده تماس</button>
@if (!string.IsNullOrEmpty(f.Phone)) <button type="button" class="btn @(Model.IsLiked ? "btn-accent" : "btn-outline") btn-lg like-trigger" aria-label="پسندیدن"
{ data-like-type="job" data-like-id="@j.Id" data-liked="@(Model.IsLiked ? "true" : "false")">
<a class="btn btn-accent btn-lg cta-main" href="tel:@f.Phone">📞 تماس با مرکز</a> <span class="like-ico">@(Model.IsLiked ? "♥" : "♡")</span> <span class="like-count">@JalaliDate.ToPersianDigits(Model.LikeCount.ToString())</span>
} </button>
else
{
<span class="cta-main center muted" style="align-self:center;">اطلاعات تماس در بالای صفحه</span>
}
}
else
{
<form method="post" class="cta-main">
<button type="submit" asp-page-handler="Interest" asp-route-id="@j.Id" class="btn btn-accent btn-block btn-lg">اعلام تمایل و مشاهده تماس</button>
</form>
<form method="post">
<button type="submit" asp-page-handler="Save" asp-route-id="@j.Id" class="btn btn-outline btn-lg" aria-label="ذخیره" title="ذخیره">♡</button>
</form>
}
</div> </div>
@if (!string.IsNullOrEmpty(Model.MapKey) && Model.Job?.Facility?.Lat is not null) @if (!string.IsNullOrEmpty(Model.MapKey) && mapLat is not null)
{ {
<partial name="_NeshanMap" model="Model.MapKey" /> <partial name="_NeshanMap" model="Model.MapKey" />
} }
@section Head {
@* Only emit JobPosting structured data for a real named employer — Google for Jobs rejects a
placeholder/empty hiringOrganization (most aggregated ads have no named center). *@
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(crumbs, bu) + "</script>")
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
{
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.JobPosting(j, bu) + "</script>")
}
}
@@ -23,6 +23,8 @@ public class DetailsModel : PageModel
public JobOpening? Job { get; private set; } public JobOpening? Job { get; private set; }
public string? MapKey { get; private set; } public string? MapKey { get; private set; }
public int LikeCount { get; private set; }
public bool IsLiked { get; private set; }
public bool ShowContact { get; private set; } public bool ShowContact { get; private set; }
public bool Saved { get; private set; } public bool Saved { get; private set; }
public bool Reported { get; private set; } public bool Reported { get; private set; }
@@ -31,7 +33,13 @@ public class DetailsModel : PageModel
{ {
await LoadAsync(id); await LoadAsync(id);
if (Job is null) return NotFound(); if (Job is null) return NotFound();
// Intentionally removed (admin-archived out-of-scope/duplicate ad): 410 Gone is the standard
// signal for permanent removal, so search engines deindex it cleanly (we keep the row for audit).
if (Job.Status == ShiftStatus.Archived) return StatusCode(StatusCodes.Status410Gone);
MapKey = (await _settings.GetAsync()).NeshanMapKey; MapKey = (await _settings.GetAsync()).NeshanMapKey;
LikeCount = await _db.Likes.CountAsync(l => l.TargetType == LikeTargetType.Job && l.TargetId == id);
var meId = int.TryParse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var n) ? n : (int?)null;
IsLiked = meId is int u && await _db.Likes.AnyAsync(l => l.UserId == u && l.TargetType == LikeTargetType.Job && l.TargetId == id);
Reported = Request.Query["reported"] == "1"; Reported = Request.Query["reported"] == "1";
await _interest.LogJobAsync(InterestEventType.View, id); await _interest.LogJobAsync(InterestEventType.View, id);
return Page(); return Page();
@@ -67,6 +75,7 @@ public class DetailsModel : PageModel
.Include(j => j.Facility).ThenInclude(f => f.City) .Include(j => j.Facility).ThenInclude(f => f.City)
.Include(j => j.Facility).ThenInclude(f => f.District) .Include(j => j.Facility).ThenInclude(f => f.District)
.Include(j => j.Role) .Include(j => j.Role)
.Include(j => j.Contacts)
.FirstOrDefaultAsync(j => j.Id == id); .FirstOrDefaultAsync(j => j.Id == id);
} }
} }
+30 -3
View File
@@ -1,23 +1,40 @@
@page @page
@model JobsMedical.Web.Pages.Jobs.IndexModel @model JobsMedical.Web.Pages.Jobs.IndexModel
@{ @{
ViewData["Title"] = "موقعیت‌های استخدامی"; // Title/description are set in the page model (SetSeo) from the active role/city.
} }
<div class="page-head"> <div class="page-head">
<div class="container"> <div class="container">
<h1>موقعیت‌های استخدامی</h1> <partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
<h1>@Model.PageHeading</h1>
<p class="muted"> <p class="muted">
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) موقعیت شغلی پیدا شد @JalaliDate.ToPersianDigits(Model.TotalCount.ToString()) موقعیت شغلی پیدا شد
@if (Model.NearMeActive) @if (Model.NearMeActive)
{ {
<span> — مرتب‌شده بر اساس نزدیک‌ترین به شما 📍</span> <span> — مرتب‌شده بر اساس نزدیک‌ترین به شما 📍</span>
} }
</p> </p>
@if (!string.IsNullOrEmpty(Model.PageIntro))
{
<p class="muted" style="max-width:720px; font-size:13.5px; line-height:1.9;">@Model.PageIntro</p>
}
</div> </div>
</div> </div>
<div class="container section"> <div class="container section">
@if (Model.Roles.Count > 0)
{
@* Internal links to the SEO landing pages (/استخدام/{نقش}) — and since this page IS the
landing page, every landing page cross-links to all the others. *@
<div class="role-links">
<span class="rl-label">استخدام بر اساس نقش:</span>
@foreach (var r in Model.Roles.Take(14))
{
<a class="rl-chip" href="/استخدام/@JobsMedical.Web.Services.SeoSlug.Of(r.Name)">@r.Name</a>
}
</div>
}
<div class="layout-2"> <div class="layout-2">
<aside class="card card-pad filter-card"> <aside class="card card-pad filter-card">
<h3>فیلترها</h3> <h3>فیلترها</h3>
@@ -100,6 +117,7 @@
<partial name="_JobCard" model="j" /> <partial name="_JobCard" model="j" />
} }
</div> </div>
<partial name="_Pager" model="(Model.CurrentPage, Model.TotalPages)" />
} }
</div> </div>
</div> </div>
@@ -124,3 +142,12 @@
} }
</script> </script>
} }
@section Head {
@{ var bcUrl = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(Model.Breadcrumbs, bcUrl) + "</script>")
@if (Model.Results.Count > 0)
{
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.ItemList(Model.Results.Select(j => "/Jobs/Details/" + j.Id), bcUrl) + "</script>")
}
}
+73 -7
View File
@@ -20,6 +20,16 @@ public class IndexModel : PageModel
[BindProperty(SupportsGet = true)] public double? Lat { get; set; } [BindProperty(SupportsGet = true)] public double? Lat { get; set; }
[BindProperty(SupportsGet = true)] public double? Lng { get; set; } [BindProperty(SupportsGet = true)] public double? Lng { get; set; }
// Pretty-URL segments (/استخدام/{roleSlug}/{citySlug?}); resolved to RoleId/CityId below.
[BindProperty(SupportsGet = true)] public string? RoleSlug { get; set; }
[BindProperty(SupportsGet = true)] public string? CitySlug { get; set; }
[BindProperty(SupportsGet = true)] public int Page { get; set; } = 1;
private const int PageSize = 24;
public int TotalCount { get; private set; }
public int TotalPages { get; private set; }
public int CurrentPage { get; private set; }
public bool NearMeActive => Lat is not null && Lng is not null; public bool NearMeActive => Lat is not null && Lng is not null;
public List<JobOpening> Results { get; private set; } = new(); public List<JobOpening> Results { get; private set; } = new();
@@ -27,10 +37,35 @@ public class IndexModel : PageModel
public List<District> Districts { get; private set; } = new(); public List<District> Districts { get; private set; } = new();
public List<Role> Roles { get; private set; } = new(); public List<Role> Roles { get; private set; } = new();
public async Task OnGetAsync() /// <summary>Dynamic page heading/H1 + title, set from the active role/city for SEO.</summary>
public string PageHeading { get; private set; } = "موقعیت‌های استخدامی";
/// <summary>A short unique intro shown on role/city landing pages (avoids thin-content).</summary>
public string? PageIntro { get; private set; }
/// <summary>Breadcrumb trail (also emitted as BreadcrumbList JSON-LD).</summary>
public IReadOnlyList<Crumb> Breadcrumbs { get; private set; } = Array.Empty<Crumb>();
public async Task<IActionResult> OnGetAsync()
{ {
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync(); Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync(); Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
// Pretty-URL landing: resolve slugs → filters. A slug matching nothing is a 404 (don't
// render a thin page under a junk URL).
if (!string.IsNullOrWhiteSpace(RoleSlug))
{
var role = Roles.FirstOrDefault(r => SeoSlug.Matches(r.Name, RoleSlug));
if (role is null) return NotFound();
RoleId = role.Id;
}
if (!string.IsNullOrWhiteSpace(CitySlug))
{
var city = Cities.FirstOrDefault(c => SeoSlug.Matches(c.Name, CitySlug));
if (city is null) return NotFound();
CityId = city.Id;
}
Districts = await _db.Districts Districts = await _db.Districts
.Where(d => d.IsActive && (CityId == null || d.CityId == CityId)) .Where(d => d.IsActive && (CityId == null || d.CityId == CityId))
.OrderBy(d => d.Name).ToListAsync(); .OrderBy(d => d.Name).ToListAsync();
@@ -49,19 +84,50 @@ public class IndexModel : PageModel
if (GenderFilter is Gender g && g != Gender.Any) if (GenderFilter is Gender g && g != Gender.Any)
q = q.Where(j => j.GenderRequirement == Gender.Any || j.GenderRequirement == g); q = q.Where(j => j.GenderRequirement == Gender.Any || j.GenderRequirement == g);
var results = await q.ToListAsync(); TotalCount = await q.CountAsync();
TotalPages = Math.Max(1, (int)Math.Ceiling(TotalCount / (double)PageSize));
CurrentPage = Math.Clamp(Page, 1, TotalPages);
var skip = (CurrentPage - 1) * PageSize;
if (NearMeActive) if (NearMeActive)
{ {
foreach (var j in results) // Distance sort needs all rows in memory; paginate after sorting.
var all = await q.ToListAsync();
foreach (var j in all)
if (j.Facility.Lat is double flat && j.Facility.Lng is double flng) if (j.Facility.Lat is double flat && j.Facility.Lng is double flng)
j.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng); j.DistanceKm = Geo.DistanceKm(Lat!.Value, Lng!.Value, flat, flng);
Results = results.OrderBy(j => j.DistanceKm ?? double.MaxValue) Results = all.OrderBy(j => j.DistanceKm ?? double.MaxValue)
.ThenByDescending(j => j.CreatedAt).ToList(); .ThenByDescending(j => j.CreatedAt).Skip(skip).Take(PageSize).ToList();
} }
else else
{ {
Results = results.OrderByDescending(j => j.CreatedAt).ToList(); Results = await q.OrderByDescending(j => j.CreatedAt).Skip(skip).Take(PageSize).ToListAsync();
} }
SetSeo(Roles.FirstOrDefault(r => r.Id == RoleId)?.Name, Cities.FirstOrDefault(c => c.Id == CityId)?.Name);
return Page();
}
/// <summary>Title/H1/meta from the active role+city so the page targets «استخدام [نقش] [شهر]».</summary>
private void SetSeo(string? role, string? city)
{
PageHeading =
role is not null && city is not null ? $"استخدام {role} در {city}"
: role is not null ? $"استخدام {role}"
: city is not null ? $"استخدام کادر درمان در {city}"
: "موقعیت‌های استخدامی";
ViewData["Title"] = PageHeading;
ViewData["Description"] = role is not null || city is not null
? $"جدیدترین آگهی‌های {PageHeading} در همکادر؛ مشاهده فرصت‌ها و تماس مستقیم با مراکز درمانی."
: "موقعیت‌های استخدامی کادر درمان (پزشک، پرستار، ماما، تکنسین) در بیمارستان‌ها و کلینیک‌های تهران — همکادر.";
if (role is not null || city is not null)
PageIntro = $"در این صفحه جدیدترین فرصت‌های {PageHeading}، گردآوری‌شده از منابع معتبر، را می‌بینید. "
+ "روی هر آگهی بزنید تا جزئیات، شرایط و راهِ تماسِ مستقیم با مرکز درمانی نمایش داده شود. "
+ "برای فرصت‌های مرتبط، نقش یا شهر دیگری را از لینک‌های بالا انتخاب کنید.";
var crumbs = new List<Crumb> { new("خانه", "/"), new("استخدام", "/Jobs") };
if (role is not null) crumbs.Add(new(role, "/استخدام/" + SeoSlug.Of(role)));
if (city is not null) crumbs.Add(new(city, null));
Breadcrumbs = crumbs;
} }
} }
@@ -92,6 +92,9 @@
<div> <div>
<span class="badge @b.cls" style="margin-bottom:6px; display:inline-block;">@b.txt</span> <span class="badge @b.cls" style="margin-bottom:6px; display:inline-block;">@b.txt</span>
<partial name="_ShiftCard" model="s" /> <partial name="_ShiftCard" model="s" />
<form method="post" asp-page-handler="WithdrawShift" asp-route-id="@s.Id" onsubmit="return confirm('از این فرصت انصراف می‌دهی؟');">
<button class="btn btn-outline" style="width:100%; padding:5px; font-size:12px; margin-top:6px; color:var(--danger); border-color:var(--danger);">انصراف از درخواست</button>
</form>
</div> </div>
} }
@foreach (var j in Model.AppliedJobs) @foreach (var j in Model.AppliedJobs)
@@ -3,6 +3,7 @@ using JobsMedical.Web.Data;
using JobsMedical.Web.Models; using JobsMedical.Web.Models;
using JobsMedical.Web.Services; using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -67,6 +68,21 @@ public class IndexModel : PageModel
.ToDictionary(g => g.Key, g => g.OrderByDescending(e => e.CreatedAt).First().Status); .ToDictionary(g => g.Key, g => g.OrderByDescending(e => e.CreatedAt).First().Status);
} }
public Task<IActionResult> OnPostWithdrawShiftAsync(int id) => WithdrawAsync(id, isJob: false);
public Task<IActionResult> OnPostWithdrawJobAsync(int id) => WithdrawAsync(id, isJob: true);
private async Task<IActionResult> WithdrawAsync(int id, bool isJob)
{
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
var visitorIds = await _db.Visitors.Where(v => v.UserId == userId).Select(v => v.Id).ToListAsync();
var evs = _db.InterestEvents.Where(e => visitorIds.Contains(e.VisitorId)
&& e.EventType == InterestEventType.Apply
&& (isJob ? e.JobOpeningId == id : e.ShiftId == id));
_db.InterestEvents.RemoveRange(evs);
await _db.SaveChangesAsync();
return RedirectToPage();
}
private Task<List<Shift>> ShiftsByIds(List<int> ids) => _db.Shifts private Task<List<Shift>> ShiftsByIds(List<int> ids) => _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City) .Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Facility).ThenInclude(f => f.District) .Include(s => s.Facility).ThenInclude(f => f.District)
+47
View File
@@ -0,0 +1,47 @@
@page
@model JobsMedical.Web.Pages.Me.LikedModel
@{
ViewData["Title"] = "پسندیده‌ها";
}
<div class="page-head">
<div class="container">
<h1>❤️ پسندیده‌ها</h1>
<p class="muted">فرصت‌هایی که پسندیده‌ای — برای حذف، دوباره روی دکمهٔ ♥ بزن.</p>
</div>
</div>
<div class="container section">
@if (Model.Total == 0)
{
<div class="card empty-state">
هنوز چیزی نپسندیده‌ای. در <a asp-page="/Jobs/Index">استخدام</a>،
<a asp-page="/Shifts/Index">شیفت‌ها</a> و <a asp-page="/Talent/Index">آماده‌به‌کار</a>
فرصت‌ها را ببین و آن‌هایی که دوست داری را با دکمهٔ ♥ پسند کن.
</div>
}
@if (Model.Jobs.Count > 0)
{
<div class="section-head"><h2>موقعیت‌های استخدامی (@JalaliDate.ToPersianDigits(Model.Jobs.Count.ToString()))</h2></div>
<div class="grid grid-3">
@foreach (var j in Model.Jobs) { <partial name="_JobCard" model="j" /> }
</div>
}
@if (Model.Shifts.Count > 0)
{
<div class="section-head" style="margin-top:18px;"><h2>شیفت‌ها (@JalaliDate.ToPersianDigits(Model.Shifts.Count.ToString()))</h2></div>
<div class="grid grid-3">
@foreach (var s in Model.Shifts) { <partial name="_ShiftCard" model="s" /> }
</div>
}
@if (Model.Talent.Count > 0)
{
<div class="section-head" style="margin-top:18px;"><h2>آماده به کار (@JalaliDate.ToPersianDigits(Model.Talent.Count.ToString()))</h2></div>
<div class="grid grid-3">
@foreach (var t in Model.Talent) { <partial name="_TalentCard" model="t" /> }
</div>
}
</div>
@@ -0,0 +1,45 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Me;
/// <summary>«پسندیده‌ها» — the listings the logged-in user has liked (jobs, shifts, talent), newest
/// first. Only still-open listings are shown; un-liking is done with the same heart button.</summary>
[Authorize]
public class LikedModel : PageModel
{
private readonly AppDbContext _db;
public LikedModel(AppDbContext db) => _db = db;
public List<JobOpening> Jobs { get; private set; } = new();
public List<Shift> Shifts { get; private set; } = new();
public List<TalentListing> Talent { get; private set; } = new();
public int Total => Jobs.Count + Shifts.Count + Talent.Count;
public async Task OnGetAsync()
{
var uid = int.Parse(User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value);
var likes = await _db.Likes.Where(l => l.UserId == uid)
.OrderByDescending(l => l.CreatedAt).ToListAsync();
var jobIds = likes.Where(l => l.TargetType == LikeTargetType.Job).Select(l => l.TargetId).ToList();
var shiftIds = likes.Where(l => l.TargetType == LikeTargetType.Shift).Select(l => l.TargetId).ToList();
var talentIds = likes.Where(l => l.TargetType == LikeTargetType.Talent).Select(l => l.TargetId).ToList();
Jobs = await _db.JobOpenings
.Include(j => j.Facility).ThenInclude(f => f.City)
.Include(j => j.Facility).ThenInclude(f => f.District)
.Include(j => j.Role)
.Where(j => jobIds.Contains(j.Id) && j.Status == ShiftStatus.Open).ToListAsync();
Shifts = await _db.Shifts
.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Role)
.Where(s => shiftIds.Contains(s.Id) && s.Status == ShiftStatus.Open).ToListAsync();
Talent = await _db.TalentListings
.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
.Where(t => talentIds.Contains(t.Id) && t.Status == ShiftStatus.Open).ToListAsync();
}
}
@@ -95,4 +95,12 @@
<button type="submit" class="btn btn-accent btn-block btn-lg">ذخیره پروفایل</button> <button type="submit" class="btn btn-accent btn-block btn-lg">ذخیره پروفایل</button>
</form> </form>
<div class="card card-pad" style="margin-top:14px; border-color:var(--danger);">
<h3 style="margin-top:0; color:var(--danger);">حذف حساب کاربری</h3>
<p class="muted" style="font-size:13px;">با حذف حساب، اطلاعات پروفایل، رزومه، هشدارها و درخواست‌های شما حذف می‌شود. این کار بازگشت‌ناپذیر است.</p>
<form method="post" asp-page-handler="DeleteAccount" onsubmit="return confirm('آیا از حذف کامل حساب خود مطمئنی؟ این کار بازگشت‌ناپذیر است.');">
<button type="submit" class="btn btn-outline" style="color:var(--danger); border-color:var(--danger);">حذف حساب من</button>
</form>
</div>
</div> </div>
@@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using JobsMedical.Web.Data; using JobsMedical.Web.Data;
using JobsMedical.Web.Models; using JobsMedical.Web.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
@@ -115,6 +116,18 @@ public class ProfileModel : PageModel
return RedirectToPage(); return RedirectToPage();
} }
/// <summary>Permanently delete the account + its data (per the privacy policy).</summary>
public async Task<IActionResult> OnPostDeleteAccountAsync()
{
var uid = Uid;
// Detach anonymous browsing history (keep events, drop the user link), then remove the user.
await _db.Visitors.Where(v => v.UserId == uid)
.ExecuteUpdateAsync(s => s.SetProperty(v => v.UserId, (int?)null));
await _db.Users.Where(u => u.Id == uid).ExecuteDeleteAsync(); // cascades profile/alerts/reviews/applications
await HttpContext.SignOutAsync(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
return RedirectToPage("/Index");
}
private async Task LoadListsAsync() private async Task LoadListsAsync()
{ {
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync(); Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
@@ -29,31 +29,13 @@ public class IndexModel : PageModel
public bool Saved { get; private set; } public bool Saved { get; private set; }
public async Task OnGetAsync() // Preferences have moved onto the «پیشنهادهای ویژه شما» page (settings next to their result).
{ // Keep this route working by redirecting any old link/bookmark there.
await LoadListsAsync(); public IActionResult OnGet() => RedirectToPage("/Recommendations/Index");
var prefs = await _interest.GetPreferencesAsync();
if (prefs is not null)
{
RoleId = prefs.RoleId;
CityId = prefs.CityId;
PreferredShiftType = prefs.PreferredShiftType;
MinPay = prefs.MinPay;
Gender = prefs.Gender;
}
}
public async Task<IActionResult> OnPostAsync() public async Task<IActionResult> OnPostAsync()
{ {
await _interest.SavePreferencesAsync(RoleId, CityId, PreferredShiftType, MinPay, Gender); await _interest.SavePreferencesAsync(RoleId, CityId, PreferredShiftType, MinPay, Gender);
// Back to home so the personalized feed is the immediate payoff. return RedirectToPage("/Recommendations/Index");
TempData["prefsSaved"] = true;
return RedirectToPage("/Index");
}
private async Task LoadListsAsync()
{
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
} }
} }
@@ -0,0 +1,94 @@
@page "/Recommendations"
@model JobsMedical.Web.Pages.Recommendations.IndexModel
@{
ViewData["Title"] = "پیشنهادهای ویژه شما";
ViewData["Description"] = "پیشنهادهای شخصی‌سازی‌شدهٔ شیفت و استخدام برای شما در همکادر — بر اساس نقش، شهر و فعالیت شما.";
ViewData["NoIndex"] = true; // personalized to the visitor — not an indexable page
}
<div class="page-head">
<div class="container">
<h1>✨ پیشنهادهای ویژه شما</h1>
<p class="muted">
@(Model.HasPersonalization
? "بر اساس علاقه‌مندی‌ها و فعالیت شما انتخاب شده‌اند. علاقه‌مندی‌ها را پایین‌تر تنظیم کن."
: "نقش، شهر و نوع شیفت دلخواهت را تنظیم کن تا بهترین فرصت‌ها را برایت پیدا کنیم.")
</p>
</div>
</div>
<div class="container section">
@if (Model.Saved)
{
<div class="alert alert-success">✓ علاقه‌مندی‌ها ذخیره شد — پیشنهادها به‌روزرسانی شدند.</div>
}
@* Preferences — the settings that drive the feed, collapsed by default once personalized. *@
<details class="card card-pad" style="margin-bottom:18px;" @(Model.HasPersonalization ? "" : "open")>
<summary style="font-weight:800; cursor:pointer; font-size:16px;">⚙️ تنظیم علاقه‌مندی‌ها</summary>
<form method="post" style="margin-top:14px;">
<div class="grid grid-3">
<div class="filter-group">
<label>نقش / رشته</label>
<select name="RoleId">
<option value="">مهم نیست</option>
@foreach (var r in Model.Roles)
{
<option value="@r.Id" selected="@(Model.RoleId == r.Id)">@r.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>شهر</label>
<select name="CityId">
<option value="">مهم نیست</option>
@foreach (var c in Model.Cities)
{
<option value="@c.Id" selected="@(Model.CityId == c.Id)">@c.Name</option>
}
</select>
</div>
<div class="filter-group">
<label>نوع شیفت ترجیحی</label>
<select name="PreferredShiftType">
<option value="">مهم نیست</option>
<option value="0" selected="@(Model.PreferredShiftType == ShiftType.Day)">صبح</option>
<option value="1" selected="@(Model.PreferredShiftType == ShiftType.Evening)">عصر</option>
<option value="2" selected="@(Model.PreferredShiftType == ShiftType.Night)">شب</option>
<option value="3" selected="@(Model.PreferredShiftType == ShiftType.OnCall)">آنکال</option>
</select>
</div>
<div class="filter-group">
<label>جنسیت شما</label>
<select name="Gender">
<option value="0" selected="@(Model.Gender == JobsMedical.Web.Models.Gender.Any)">نمی‌خواهم بگویم</option>
<option value="1" selected="@(Model.Gender == JobsMedical.Web.Models.Gender.Male)">آقا</option>
<option value="2" selected="@(Model.Gender == JobsMedical.Web.Models.Gender.Female)">خانم</option>
</select>
</div>
<div class="filter-group">
<label>حداقل حقوق مورد انتظار (تومان)</label>
<input type="number" name="MinPay" value="@Model.MinPay" placeholder="مثلاً ۲۰۰۰۰۰۰۰" dir="ltr" />
</div>
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg" style="margin-top:6px;">ذخیره و دیدن پیشنهادها</button>
</form>
</details>
@if (Model.Recommendations.Count > 0)
{
<div class="grid grid-3">
@foreach (var rec in Model.Recommendations)
{
<partial name="_RecommendationCard" model="rec" />
}
</div>
}
else
{
<div class="card empty-state">
هنوز پیشنهادی برای شما نیست. علاقه‌مندی‌هایت را تنظیم کن یا چند فرصت را در
<a asp-page="/Jobs/Index">استخدام</a> و <a asp-page="/Shifts/Index">شیفت‌ها</a> ببین تا پیشنهادها شخصی شوند.
</div>
}
</div>
@@ -0,0 +1,66 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages.Recommendations;
/// <summary>
/// Dedicated «پیشنهادهای ویژه شما» page: the personalized recommendation feed plus the preference
/// controls that drive it (role/city/shift-type/pay/gender), in one place — moved off the homepage
/// and consolidating the old /Preferences screen so the settings live next to their result.
/// </summary>
public class IndexModel : PageModel
{
private readonly AppDbContext _db;
private readonly RecommendationService _recs;
private readonly InterestService _interest;
public IndexModel(AppDbContext db, RecommendationService recs, InterestService interest)
{
_db = db;
_recs = recs;
_interest = interest;
}
public List<Recommendation> Recommendations { get; private set; } = new();
public bool HasPersonalization { get; private set; }
public List<Role> Roles { get; private set; } = new();
public List<City> Cities { get; private set; } = new();
[BindProperty] public int? RoleId { get; set; }
[BindProperty] public int? CityId { get; set; }
[BindProperty] public ShiftType? PreferredShiftType { get; set; }
[BindProperty] public long? MinPay { get; set; }
[BindProperty] public Gender Gender { get; set; }
[TempData] public bool Saved { get; set; }
public async Task OnGetAsync() => await LoadAsync();
public async Task<IActionResult> OnPostAsync()
{
await _interest.SavePreferencesAsync(RoleId, CityId, PreferredShiftType, MinPay, Gender);
Saved = true;
return RedirectToPage(); // reload so the feed reflects the new preferences immediately
}
private async Task LoadAsync()
{
Roles = await _db.Roles.Where(r => r.IsActive).OrderBy(r => r.SortOrder).ToListAsync();
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
Recommendations = await _recs.GetForVisitorAsync(12);
var prefs = await _interest.GetPreferencesAsync();
HasPersonalization = prefs?.HasAny == true || (await _interest.RecentEventsAsync(1)).Count > 0;
if (prefs is not null)
{
RoleId = prefs.RoleId;
CityId = prefs.CityId;
PreferredShiftType = prefs.PreferredShiftType;
MinPay = prefs.MinPay;
Gender = prefs.Gender;
}
}
}
+56
View File
@@ -0,0 +1,56 @@
@page
@model JobsMedical.Web.Pages.SearchModel
@{
ViewData["Title"] = Model.HasQuery ? $"جستجو: {Model.Q}" : "جستجو";
ViewData["q"] = Model.Q; // drives highlighting in the cards
ViewData["NoIndex"] = true;
}
<div class="page-head">
<div class="container">
<h1>جستجو</h1>
<form method="get" class="search-hero">
<input type="search" name="Q" value="@Model.Q" placeholder="مثلاً: پرستار شب تهران، mmt، دندانپزشک پروانه‌دار…" autofocus />
<button type="submit" class="btn btn-accent">🔎 جستجو</button>
</form>
@if (Model.HasQuery)
{
<p class="muted">@JalaliDate.ToPersianDigits(Model.Total.ToString()) نتیجه برای «@Model.Q»</p>
}
</div>
</div>
<div class="container section">
@if (!Model.HasQuery)
{
<div class="card empty-state">یک عبارت بنویس تا در شیفت‌ها، استخدام‌ها و آماده‌به‌کارها جستجو شود. هر کلمه باید جایی پیدا شود.</div>
}
else if (Model.Total == 0)
{
<div class="card empty-state">نتیجه‌ای پیدا نشد. عبارت دیگری امتحان کن.</div>
}
else
{
@if (Model.Shifts.Count > 0)
{
<div class="section-head"><h2>شیفت‌ها (@JalaliDate.ToPersianDigits(Model.Shifts.Count.ToString()))</h2><a asp-page="/Shifts/Index">همه شیفت‌ها ←</a></div>
<div class="grid grid-3">
@foreach (var s in Model.Shifts) { <partial name="_ShiftCard" model="s" /> }
</div>
}
@if (Model.Jobs.Count > 0)
{
<div class="section-head" style="margin-top:24px;"><h2>استخدام‌ها (@JalaliDate.ToPersianDigits(Model.Jobs.Count.ToString()))</h2><a asp-page="/Jobs/Index">همه استخدام‌ها ←</a></div>
<div class="grid grid-3">
@foreach (var j in Model.Jobs) { <partial name="_JobCard" model="j" /> }
</div>
}
@if (Model.Talent.Count > 0)
{
<div class="section-head" style="margin-top:24px;"><h2>آماده به کار (@JalaliDate.ToPersianDigits(Model.Talent.Count.ToString()))</h2><a asp-page="/Talent/Index">همه ←</a></div>
<div class="grid grid-3">
@foreach (var t in Model.Talent) { <partial name="_TalentCard" model="t" /> }
</div>
}
}
</div>
@@ -0,0 +1,91 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services.Scraping;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages;
/// <summary>Site-wide rich search across shifts, hiring openings, and applicants with keyword
/// highlighting. Every query term must match somewhere (Postgres ILIKE over the relevant fields).</summary>
public class SearchModel : PageModel
{
private readonly AppDbContext _db;
public SearchModel(AppDbContext db) => _db = db;
[BindProperty(SupportsGet = true)] public string? Q { get; set; }
public List<Shift> Shifts { get; private set; } = new();
public List<JobOpening> Jobs { get; private set; } = new();
public List<TalentListing> Talent { get; private set; } = new();
public int Total => Shifts.Count + Jobs.Count + Talent.Count;
public bool HasQuery => !string.IsNullOrWhiteSpace(Q);
public async Task OnGetAsync()
{
if (!HasQuery) return;
var terms = Q!.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var jobCut = ListingPolicy.JobCutoffUtc;
var talentCut = ListingPolicy.TalentCutoffUtc;
var sq = _db.Shifts.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Facility).ThenInclude(f => f.District).Include(s => s.Role)
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today);
foreach (var t in terms)
{
var like = $"%{t}%";
sq = sq.Where(s => EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Facility.City.Name, like)
|| EF.Functions.ILike(s.Role.Name, like) || EF.Functions.ILike(s.SpecialtyRequired, like)
|| EF.Functions.ILike(s.Description ?? "", like));
}
var shiftPool = await sq.OrderByDescending(s => s.CreatedAt).Take(60).ToListAsync();
Shifts = shiftPool
.OrderByDescending(s => Rank(terms, 3, s.Role?.Name, s.Facility?.Name, s.Facility?.City?.Name, s.SpecialtyRequired)
+ Rank(terms, 1, s.Description))
.ThenByDescending(s => s.CreatedAt).Take(30).ToList();
var jq = _db.JobOpenings.Include(j => j.Facility).ThenInclude(f => f.City)
.Include(j => j.Facility).ThenInclude(f => f.District).Include(j => j.Role)
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut);
foreach (var t in terms)
{
var like = $"%{t}%";
jq = jq.Where(j => EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like)
|| EF.Functions.ILike(j.Facility.City.Name, like) || EF.Functions.ILike(j.Role.Name, like)
|| EF.Functions.ILike(j.Description ?? "", like));
}
var jobPool = await jq.OrderByDescending(j => j.CreatedAt).Take(60).ToListAsync();
Jobs = jobPool
.OrderByDescending(j => Rank(terms, 3, j.Title, j.Role?.Name, j.Facility?.Name, j.Facility?.City?.Name)
+ Rank(terms, 1, j.Description))
.ThenByDescending(j => j.CreatedAt).Take(30).ToList();
var tq = _db.TalentListings.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut);
foreach (var t in terms)
{
var like = $"%{t}%";
tq = tq.Where(x => EF.Functions.ILike(x.Tags ?? "", like) || EF.Functions.ILike(x.Description ?? "", like)
|| EF.Functions.ILike(x.PersonName ?? "", like) || EF.Functions.ILike(x.AreaNote ?? "", like)
|| EF.Functions.ILike(x.Role.Name, like) || EF.Functions.ILike(x.City.Name, like));
}
var talentPool = await tq.OrderByDescending(x => x.CreatedAt).Take(60).ToListAsync();
Talent = talentPool
.OrderByDescending(x => Rank(terms, 3, x.Role?.Name, x.City?.Name, x.PersonName, x.Tags)
+ Rank(terms, 1, x.Description, x.AreaNote))
.ThenByDescending(x => x.CreatedAt).Take(30).ToList();
}
/// <summary>Relevance score: +weight per term found in any of the given fields.</summary>
private static int Rank(string[] terms, int weight, params string?[] fields)
{
var score = 0;
foreach (var term in terms)
foreach (var f in fields)
if (!string.IsNullOrEmpty(f) && f.Contains(term, StringComparison.OrdinalIgnoreCase))
{ score += weight; break; }
return score;
}
}
@@ -0,0 +1,20 @@
@model IReadOnlyList<JobsMedical.Web.Services.Crumb>
@* Visible breadcrumb trail. The last crumb is the current page (no link). Pair with the
BreadcrumbList JSON-LD (SeoJsonLd.Breadcrumb) emitted in @@section Head. *@
@if (Model is { Count: > 1 })
{
<nav class="breadcrumbs" aria-label="مسیر">
@for (var i = 0; i < Model.Count; i++)
{
if (i > 0) { <span class="bc-sep" aria-hidden="true"></span> }
@if (!string.IsNullOrEmpty(Model[i].Url) && i < Model.Count - 1)
{
<a href="@Model[i].Url">@Model[i].Name</a>
}
else
{
<span class="bc-current">@Model[i].Name</span>
}
}
</nav>
}
@@ -0,0 +1,17 @@
@model IReadOnlyList<JobsMedical.Web.Models.ContactMethod>
@* Renders one row per contact channel (phone/Bale/Telegram/email/…) with a clickable action.
Shared by the shift, job, and applicant detail pages. *@
@foreach (var c in Model.OrderBy(c => c.SortOrder))
{
var href = JobsMedical.Web.Services.ContactInfo.Href(c.Type, c.Value);
var label = JobsMedical.Web.Services.ContactInfo.Label(c.Type);
var icon = JobsMedical.Web.Services.ContactInfo.Icon(c.Type);
var cls = c.Type is JobsMedical.Web.Models.ContactType.Mobile or JobsMedical.Web.Models.ContactType.Phone ? "btn-accent" : "btn-outline";
<div class="contact-row">
<span class="c-meta"><span class="c-type">@icon @label</span><span class="c-val" dir="ltr">@c.Value</span></span>
@if (href is not null)
{
<a class="btn @cls" href="@href" target="_blank" rel="noopener">@(c.Type is JobsMedical.Web.Models.ContactType.Mobile or JobsMedical.Web.Models.ContactType.Phone ? "تماس" : "باز کردن")</a>
}
</div>
}
@@ -10,29 +10,40 @@
string salary; string salary;
if (Model.SalaryMin is null && Model.SalaryMax is null) salary = "توافقی"; if (Model.SalaryMin is null && Model.SalaryMax is null) salary = "توافقی";
else if (Model.SalaryMin == Model.SalaryMax) salary = JalaliDate.Toman(Model.SalaryMin) + " ماهانه"; else if (Model.SalaryMin == Model.SalaryMax) salary = JalaliDate.Toman(Model.SalaryMin) + " ماهانه";
else if (Model.SalaryMax is null) salary = "از " + JalaliDate.Toman(Model.SalaryMin) + " ماهانه"; // min only — avoid «تا توافقی»
else salary = $"از {JalaliDate.ToPersianDigits((Model.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(Model.SalaryMax)} ماهانه"; else salary = $"از {JalaliDate.ToPersianDigits((Model.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(Model.SalaryMax)} ماهانه";
var q = ViewData["q"] as string;
} }
<a class="card card-pad shift-card" asp-page="/Jobs/Details" asp-route-id="@Model.Id"> <a class="card card-pad shift-card" asp-page="/Jobs/Details" asp-route-id="@Model.Id">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<span class="facility">@Model.Title</span> <span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Title, q)</span>
<span class="badge badge-job">@empLabel</span> <span class="badge badge-job">@empLabel</span>
</div> </div>
<div class="row"> <div class="row">
@if (Model.Role is not null) @if (Model.Role is not null)
{ {
<span class="badge badge-type">@Model.Role.Name</span> <span class="badge badge-type">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Role.Name, q)</span>
} }
@if (Model.GenderRequirement != Gender.Any) @if (Model.GenderRequirement != Gender.Any)
{ {
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span> <span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
} }
<span>🏥 @Model.Facility?.Name</span> @if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(Model.Facility))
{
<span>🏥 @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)</span>
}
</div> </div>
<div class="row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div> <div class="row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div>
@if (Model.DistanceKm is double km) @if (Model.DistanceKm is double km)
{ {
<div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div> <div class="row"><span class="badge badge-distance">📍 @JalaliDate.ToPersianDigits(km.ToString("0.#")) کیلومتر از شما</span></div>
} }
@{ var snip = JobsMedical.Web.Services.SearchHighlight.Snippet(Model.Description, q); }
@if (snip.Value.Length > 0)
{
<div class="search-snippet">@snip</div>
}
<div class="row muted" style="font-size:12px; margin-top:6px;">🕒 @JalaliDate.TimeAgo(Model.CreatedAt)</div>
<div class="foot"> <div class="foot">
<span class="pay">@salary</span> <span class="pay">@salary</span>
<span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span> <span class="btn btn-outline" style="padding: 6px 14px;">جزئیات</span>
+237 -25
View File
@@ -6,25 +6,71 @@
var title = ViewData["Title"] as string; var title = ViewData["Title"] as string;
int unreadCount = 0; int unreadCount = 0;
int meId = 0; int meId = 0;
string? meName = null; string? meFullName = null;
string? mePhone = null;
bool meHasAvatar = false; bool meHasAvatar = false;
if (User.Identity?.IsAuthenticated == true && int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out meId)) if (User.Identity?.IsAuthenticated == true && int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out meId))
{ {
unreadCount = await Notifications.UnreadCountAsync(meId); unreadCount = await Notifications.UnreadCountAsync(meId);
var info = await Db.Users.Where(u => u.Id == meId) var info = await Db.Users.Where(u => u.Id == meId)
.Select(u => new { u.FullName, u.Phone, HasAvatar = u.Avatar != null }).FirstOrDefaultAsync(); .Select(u => new { u.FullName, u.Phone, HasAvatar = u.Avatar != null }).FirstOrDefaultAsync();
meName = string.IsNullOrWhiteSpace(info?.FullName) ? info?.Phone : info!.FullName; meFullName = string.IsNullOrWhiteSpace(info?.FullName) ? null : info!.FullName!.Trim();
mePhone = info?.Phone;
meHasAvatar = info?.HasAvatar ?? false; meHasAvatar = info?.HasAvatar ?? false;
} }
var meInitial = string.IsNullOrWhiteSpace(meName) ? "؟" : meName!.Trim().Substring(0, 1); // Avatar glyph/label: prefer a real name; never show a bare phone digit like "0".
var meInitial = meFullName is not null ? meFullName.Substring(0, 1) : "👤";
var meLabel = meFullName ?? "حساب من";
// Single, role-aware dashboard entry — the full menu lives in the panel sub-nav (_PanelNav).
var dashUrl = "/Me/Index"; var dashLabel = "داشبورد من"; var dashIcon = "🗂️";
if (User.IsInRole("Admin")) { dashUrl = "/Admin/Overview"; dashLabel = "پنل مدیریت"; dashIcon = "🛠️"; }
else if (User.IsInRole("FacilityAdmin")) { dashUrl = "/Employer/Index"; dashLabel = "پنل کارفرما"; dashIcon = "🏥"; }
// --- SEO context ---
var baseUrl = $"{Context.Request.Scheme}://{Context.Request.Host}";
var path = Context.Request.Path.Value ?? "/";
var canonical = baseUrl + (path == "/" ? "" : path); // canonical ignores query string
var pageDesc = ViewData["Description"] as string
?? "همکادر؛ سامانه یافتن شیفت و موقعیت استخدامی برای کادر درمان (پزشک، پرستار، ماما و تکنسین) در بیمارستان‌ها و کلینیک‌های تهران.";
var pageTitle = title is null ? "همکادر | شیفت و استخدام کادر درمان" : title + " | همکادر";
var ogImage = ViewData["OgImage"] as string ?? baseUrl + "/icons/icon-512.png";
// Private/applicant areas must never be indexed.
string[] noindexPrefixes = { "/Admin", "/Me", "/Employer", "/Account", "/Preferences" };
var noIndex = (ViewData["NoIndex"] as bool? ?? false)
|| noindexPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
// Show the centralized dashboard sub-nav on any logged-in panel page.
string[] panelPrefixes = { "/Admin", "/Me", "/Employer", "/Preferences" };
var showPanelNav = User.Identity?.IsAuthenticated == true
&& panelPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
} }
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fa" dir="rtl"> <html lang="fa" dir="rtl">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@(title is null ? "همکادر | شیفت و استخدام کادر درمان" : title + " | همکادر")</title> <title>@pageTitle</title>
<meta name="description" content="@(ViewData["Description"] as string ?? "همکادر؛ سامانه یافتن شیفت و موقعیت استخدامی برای کادر درمان (پزشک، پرستار، ماما و تکنسین) در بیمارستان‌ها و کلینیک‌های تهران.")" /> <meta name="description" content="@pageDesc" />
@if (noIndex)
{
<meta name="robots" content="noindex, nofollow" />
}
else
{
<link rel="canonical" href="@canonical" />
}
@* Open Graph / Twitter — rich previews when shared in Telegram/Bale/etc. *@
<meta property="og:type" content="website" />
<meta property="og:site_name" content="همکادر" />
<meta property="og:title" content="@pageTitle" />
<meta property="og:description" content="@pageDesc" />
<meta property="og:url" content="@canonical" />
<meta property="og:image" content="@ogImage" />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@pageTitle" />
<meta name="twitter:description" content="@pageDesc" />
@* Preload the body-weight font so the swap from Tahoma happens fast. Vazirmatn is @* Preload the body-weight font so the swap from Tahoma happens fast. Vazirmatn is
self-hosted under wwwroot/fonts (@@font-face in site.css) — no external CDN. *@ self-hosted under wwwroot/fonts (@@font-face in site.css) — no external CDN. *@
<link rel="preload" href="~/fonts/Vazirmatn-Regular.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="~/fonts/Vazirmatn-Regular.woff2" as="font" type="font/woff2" crossorigin />
@@ -33,11 +79,15 @@
@* PWA: installable app (Web/Windows/Android via this manifest; iOS via apple-* tags) *@ @* PWA: installable app (Web/Windows/Android via this manifest; iOS via apple-* tags) *@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0e8f8a" /> <meta name="theme-color" content="#0e8f8a" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" /> <meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="همکادر" /> <meta name="apple-mobile-web-app-title" content="همکادر" />
@await RenderSectionAsync("Head", required: false)
</head> </head>
<body data-unread="@unreadCount" data-authed="@(User.Identity?.IsAuthenticated == true ? "1" : "0")"> <body data-unread="@unreadCount" data-authed="@(User.Identity?.IsAuthenticated == true ? "1" : "0")">
<header class="site-header"> <header class="site-header">
@@ -59,17 +109,27 @@
</label> </label>
<div class="nav-collapse"> <div class="nav-collapse">
@* Browse items only — personal ones (پیشنهادها/پسندیده‌ها) live in the profile menu. *@
<nav class="main-nav"> <nav class="main-nav">
<a asp-page="/Index">خانه</a> <a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a>
<a asp-page="/Shifts/Index" data-tour="shifts">شیفت‌ها</a> <a href="/Jobs" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a>
<a asp-page="/Jobs/Index" data-tour="jobs">استخدام</a> <a href="/Shifts" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفت‌ها</a>
<a asp-page="/Calendar/Index">تقویم هفتگی</a> <a asp-page="/Talent/Index" class="@(path.StartsWith("/Talent") ? "active" : null)">آماده به کار</a>
<a asp-page="/Download">دریافت اپ</a> @if (User.Identity?.IsAuthenticated != true)
<a asp-page="/Facilities/Index">مراکز درمانی</a> {
<a asp-page="/Preferences/Index" data-tour="prefs">علاقه‌مندی‌ها</a> <a asp-page="/Recommendations/Index" class="@(path.StartsWith("/Recommendations") ? "active" : null)">✨ پیشنهادها</a>
<a asp-page="/Help" data-tour="help">راهنما</a> }
<details class="nav-more">
<summary class="@(path.StartsWith("/Facilities") || path.StartsWith("/Calendar") ? "active" : null)">بیشتر ▾</summary>
<div class="nav-more-menu">
<a asp-page="/Facilities/Index" class="@(path.StartsWith("/Facilities") ? "active" : null)">🏥 مراکز درمانی</a>
<a asp-page="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">🗓️ تقویم هفتگی</a>
</div>
</details>
<a asp-page="/Search" class="nav-search-link @(path.StartsWith("/Search") ? "active" : null)">🔎 جستجو</a>
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<a class="btn btn-accent btn-sm cta-post" asp-page="/Employer/Index" data-tour="post"> ثبت آگهی</a>
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
{ {
<a class="nav-action bell-inline js-bell" asp-page="/Me/Notifications" title="اعلان‌ها" data-tour="bell"><span class="bell-ico">🔔</span><span class="bell-label">اعلان‌ها</span>@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a> <a class="nav-action bell-inline js-bell" asp-page="/Me/Notifications" title="اعلان‌ها" data-tour="bell"><span class="bell-ico">🔔</span><span class="bell-label">اعلان‌ها</span>@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
@@ -85,25 +145,33 @@
{ {
<span class="avatar-fallback">@meInitial</span> <span class="avatar-fallback">@meInitial</span>
} }
<span class="avatar-name">@meLabel</span>
<span class="avatar-caret">▾</span> <span class="avatar-caret">▾</span>
</label> </label>
<nav class="profile-dropdown"> <nav class="profile-dropdown">
<div class="pd-head">@meName</div> <div class="pd-id">
<a asp-page="/Me/Profile">👤 ویرایش پروفایل</a> @if (meHasAvatar)
<a asp-page="/Me/Index" data-tour="panel">🗂️ پنل کارجو</a>
<a asp-page="/Me/Alerts">🔎 هشدارهای شغلی</a>
<a asp-page="/Preferences/Index">⭐ علاقه‌مندی‌ها</a>
<a asp-page="/Me/Notifications">🔔 اعلان‌ها@if (unreadCount > 0) {<span class="bell-badge" style="position:static; margin-inline-start:6px;">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
@if (User.IsInRole("FacilityAdmin"))
{ {
<a asp-page="/Employer/Index">🏥 پنل کارفرما</a> <img class="avatar-img" src="/avatar/@meId" alt="" />
} }
@if (User.IsInRole("Admin")) else
{ {
<span class="avatar-fallback">@meInitial</span>
}
<div class="pd-id-text">
<strong>@(meFullName ?? "کاربر همکادر")</strong>
@if (mePhone is not null)
{
<span class="muted" dir="ltr">@mePhone</span>
}
</div>
</div>
<div class="pd-sep"></div> <div class="pd-sep"></div>
<a asp-page="/Admin/Overview">🛠️ پنل مدیریت</a> <a asp-page="/Recommendations/Index">✨ پیشنهادهای ویژه شما</a>
<a asp-page="/Admin/Settings"> تنظیمات</a> <a asp-page="/Me/Liked"> پسندیده‌ها</a>
} <div class="pd-sep"></div>
<a href="@dashUrl" data-tour="panel">@dashIcon @dashLabel</a>
<a asp-page="/Me/Profile">👤 ویرایش پروفایل</a>
<div class="pd-sep"></div> <div class="pd-sep"></div>
<form method="post" asp-page="/Account/Logout"> <form method="post" asp-page="/Account/Logout">
<button type="submit" class="pd-logout">🚪 خروج</button> <button type="submit" class="pd-logout">🚪 خروج</button>
@@ -121,6 +189,10 @@
</header> </header>
<main role="main"> <main role="main">
@if (showPanelNav)
{
<partial name="_PanelNav" />
}
@RenderBody() @RenderBody()
</main> </main>
@@ -161,7 +233,147 @@
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
var t = document.getElementById('profile-toggle'); var t = document.getElementById('profile-toggle');
if (t && t.checked && !e.target.closest('.profile-menu')) t.checked = false; if (t && t.checked && !e.target.closest('.profile-menu')) t.checked = false;
// Close the «بیشتر» nav dropdown when clicking outside it.
document.querySelectorAll('details.nav-more[open]').forEach(function (d) {
if (!d.contains(e.target)) d.removeAttribute('open');
}); });
});
</script>
@* Instant search suggestions (typeahead) — attaches to every form[data-suggest]
(header pill + homepage hero). *@
<script>
(function () {
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function hi(text, q) {
var safe = esc(text);
var terms = q.split(/\s+/).filter(function (t) { return t.length >= 2; })
.map(function (t) { return t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); });
if (!terms.length) return safe;
try { return safe.replace(new RegExp('(' + terms.join('|') + ')', 'gi'), '<mark>$1</mark>'); }
catch (e) { return safe; }
}
function attach(form) {
var input = form.querySelector('input[type=search], input[name=Q]');
if (!input) return;
var box = document.createElement('div');
box.className = 'nav-search-results';
box.style.display = 'none';
// Anchor the dropdown to the input's box (the hero pill) so it sits
// directly under the input rather than below the popular-search chips.
(input.closest('.hero-search-pill') || form).appendChild(box);
var timer;
function hide() { box.style.display = 'none'; box.innerHTML = ''; }
input.addEventListener('input', function () {
var q = input.value.trim();
clearTimeout(timer);
if (q.length < 2) { hide(); return; }
timer = setTimeout(function () {
fetch('/search/suggest?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (data) {
var items = (data && data.items) || [];
var total = (data && data.total) || items.length;
if (!items.length) { hide(); return; }
function fa(n) { return String(n).replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'[+d]; }); }
var html = items.map(function (it) {
var sub = it.sub ? '<span class="ns-sub">' + hi(it.sub, q) + '</span>' : '';
return '<a href="' + it.url + '"><span class="ns-type">' + esc(it.type) +
'</span><span class="ns-text"><span class="ns-label">' + hi(it.label, q) +
'</span>' + sub + '</span></a>';
}).join('');
html += '<a class="ns-all" href="/Search?Q=' + encodeURIComponent(q) + '">مشاهده همه ' + fa(total) + ' نتیجه برای «' + esc(q) + '» ←</a>';
box.innerHTML = html;
box.style.display = 'block';
}).catch(function () { hide(); });
}, 200);
});
document.addEventListener('click', function (e) { if (!form.contains(e.target)) hide(); });
input.addEventListener('keydown', function (e) { if (e.key === 'Escape') hide(); });
}
document.querySelectorAll('[data-suggest]').forEach(attach);
})();
</script>
@* Contact modal — any element with data-contact-type + data-contact-id opens it; numbers are
fetched from /contact on click (so they never sit in list HTML and bots can't scrape them). *@
<div id="contactModal" class="contact-modal" aria-hidden="true">
<div class="contact-modal-box" role="dialog" aria-modal="true" aria-labelledby="contactModalTitle">
<div class="contact-modal-head">
<h3 id="contactModalTitle">راه‌های ارتباطی</h3>
<button type="button" class="contact-modal-x" data-contact-close aria-label="بستن">✕</button>
</div>
<div id="contactModalBody" class="contact-modal-body"></div>
</div>
</div>
@* Like («پسندیدن») toggle — login-gated, updates the button state + count in place. *@
<script>
(function () {
function fa(n) { return String(n).replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'[+d]; }); }
document.addEventListener('click', function (e) {
var b = e.target.closest('.like-trigger');
if (!b) return;
e.preventDefault();
if (document.body.dataset.authed !== '1') {
location.href = '/Account/Login?returnUrl=' + encodeURIComponent(location.pathname);
return;
}
var fd = new FormData();
fd.append('type', b.dataset.likeType);
fd.append('id', b.dataset.likeId);
b.disabled = true;
fetch('/like', { method: 'POST', body: fd })
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(function (d) {
b.dataset.liked = d.liked ? 'true' : 'false';
b.classList.toggle('btn-accent', d.liked);
b.classList.toggle('btn-outline', !d.liked);
var ico = b.querySelector('.like-ico'); if (ico) ico.textContent = d.liked ? '♥' : '♡';
var c = b.querySelector('.like-count'); if (c) c.textContent = fa(d.count);
})
.catch(function () {})
.finally(function () { b.disabled = false; });
});
})();
</script>
<script>
(function () {
var modal = document.getElementById('contactModal');
var box = document.getElementById('contactModalBody');
var titleEl = document.getElementById('contactModalTitle');
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function open() { modal.classList.add('show'); modal.setAttribute('aria-hidden', 'false'); }
function close() { modal.classList.remove('show'); modal.setAttribute('aria-hidden', 'true'); box.innerHTML = ''; }
function render(d) {
titleEl.textContent = d.title || 'راه‌های ارتباطی';
var html = '';
(d.contacts || []).forEach(function (c) {
html += '<div class="contact-row"><span class="c-meta"><span class="c-type">' + esc(c.icon + ' ' + c.label) +
'</span><span class="c-val" dir="ltr">' + esc(c.value) + '</span></span>' +
(c.href ? '<a class="btn btn-accent" href="' + esc(c.href) + '" target="_blank" rel="nofollow noopener">تماس</a>' : '') + '</div>';
});
if (d.fallbackUrl) html += '<a class="btn btn-accent btn-block" href="' + esc(d.fallbackUrl) +
'" target="_blank" rel="nofollow noopener">' + esc(d.fallbackLabel || 'مشاهده') + '</a>';
box.innerHTML = html || '<p class="muted" style="margin:0;">شماره‌ای ثبت نشده است.</p>';
}
document.addEventListener('click', function (e) {
var trigger = e.target.closest('[data-contact-type]');
if (trigger) {
e.preventDefault(); e.stopPropagation(); // don't follow the card link
titleEl.textContent = 'راه‌های ارتباطی';
box.innerHTML = '<p class="muted" style="margin:0;">در حال دریافت…</p>';
open();
fetch('/contact?type=' + encodeURIComponent(trigger.getAttribute('data-contact-type')) +
'&id=' + encodeURIComponent(trigger.getAttribute('data-contact-id')))
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(render)
.catch(function () { box.innerHTML = '<p class="muted" style="margin:0;">خطا در دریافت اطلاعات تماس.</p>'; });
return;
}
if (e.target.closest('[data-contact-close]') || e.target === modal) close();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
})();
</script> </script>
@* Live in-app notifications over SSE (our own origin — works in Iran, no Google push). @* Live in-app notifications over SSE (our own origin — works in Iran, no Google push).
@@ -4,18 +4,25 @@
data-lat="…" data-lng="…"> exists. Pass the Neshan web key as the model. data-lat="…" data-lng="…"> exists. Pass the Neshan web key as the model.
The SDK is a synchronous script, so the init below runs once L is defined. The SDK is a synchronous script, so the init below runs once L is defined.
*@ *@
<link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.css" /> <link rel="stylesheet" href="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.css" />
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.js"></script> <script src="https://static.neshan.org/sdk/leaflet/1.4.0/leaflet.js"></script>
<script> <script>
(function () { (function () {
var el = document.getElementById('facmap'); var el = document.getElementById('facmap');
if (!el || !window.L) return; if (!el || !window.L) return;
var lat = parseFloat(el.dataset.lat), lng = parseFloat(el.dataset.lng); var lat = parseFloat(el.dataset.lat), lng = parseFloat(el.dataset.lng);
if (isNaN(lat) || isNaN(lng)) return; if (isNaN(lat) || isNaN(lng)) return;
// Approximate (aggregated) listings show a shaded AREA circle, not a precise pin.
var approx = el.dataset.approx === 'true';
var map = new L.Map('facmap', { var map = new L.Map('facmap', {
key: '@Model', maptype: 'neshan', poi: true, traffic: false, key: '@Model', maptype: 'neshan', poi: !approx, traffic: false,
center: [lat, lng], zoom: 15 center: [lat, lng], zoom: approx ? 14 : 15
}); });
if (approx) {
var radius = parseInt(el.dataset.radius || '700', 10);
L.circle([lat, lng], { radius: radius, color: '#e07b39', weight: 1, fillColor: '#e07b39', fillOpacity: 0.18 }).addTo(map);
} else {
L.marker([lat, lng]).addTo(map); L.marker([lat, lng]).addTo(map);
}
})(); })();
</script> </script>
@@ -0,0 +1,51 @@
@model (int Current, int Total)
@{
var (cur, total) = Model;
}
@if (total > 1)
{
@* Build a page URL that preserves every current filter in the query string. *@
Func<int, string> pageUrl = p =>
{
var parts = Context.Request.Query
.Where(kv => !string.Equals(kv.Key, "Page", StringComparison.OrdinalIgnoreCase))
.Select(kv => Uri.EscapeDataString(kv.Key) + "=" + Uri.EscapeDataString(kv.Value.ToString()))
.ToList();
parts.Add("Page=" + p);
return Context.Request.Path + "?" + string.Join("&", parts);
};
var from = Math.Max(1, cur - 2);
var to = Math.Min(total, cur + 2);
<nav class="pager" aria-label="صفحه‌بندی">
@if (cur > 1)
{
<a class="pager-btn" href="@pageUrl(cur - 1)" rel="prev">→ قبلی</a>
}
@if (from > 1)
{
<a class="pager-num" href="@pageUrl(1)">@JalaliDate.ToPersianDigits("1")</a>
@if (from > 2) { <span class="pager-gap">…</span> }
}
@for (var p = from; p <= to; p++)
{
if (p == cur)
{
<span class="pager-num active" aria-current="page">@JalaliDate.ToPersianDigits(p.ToString())</span>
}
else
{
<a class="pager-num" href="@pageUrl(p)">@JalaliDate.ToPersianDigits(p.ToString())</a>
}
}
@if (to < total)
{
@if (to < total - 1) { <span class="pager-gap">…</span> }
<a class="pager-num" href="@pageUrl(total)">@JalaliDate.ToPersianDigits(total.ToString())</a>
}
@if (cur < total)
{
<a class="pager-btn" href="@pageUrl(cur + 1)" rel="next">بعدی ←</a>
}
</nav>
}

Some files were not shown because too many files have changed in this diff Show More