Compare commits
136 Commits
02eb761488
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f0e0b82375 | |||
| 923a3fb90e | |||
| aaeb37e1af | |||
| a97c556770 | |||
| 5fcdb8599f | |||
| ccc5a954dd | |||
| e3750b7d43 | |||
| fce13aaeb0 | |||
| 9fc83b231b | |||
| 2d4ea3a762 | |||
| c1c914df9f | |||
| 39c866f4c7 | |||
| fdeefb7625 | |||
| 1f628d971e | |||
| b3e7123d74 | |||
| 219207ad68 | |||
| 410fc86c60 | |||
| b223d3af2d | |||
| 2b7ac96472 | |||
| 0334cac3dc | |||
| 98fc01be8e | |||
| 33450a37ea | |||
| 17da713a35 | |||
| 92802d0da0 | |||
| c778b87e79 | |||
| b1d0d0d4fd | |||
| cdb58eeb86 | |||
| 7bbb4e385e | |||
| fbf8deaa8c | |||
| d39546389e | |||
| 5c04658faf | |||
| 845d0c9013 | |||
| 3e65c88765 | |||
| 1c580e0f7a | |||
| b48e7dbc65 | |||
| bb8c6c3be5 | |||
| 7740d9f8d7 | |||
| f118db55ef | |||
| da55f82c6c | |||
| 88eca92333 | |||
| 8be275596b | |||
| e2011d335e | |||
| a16a805869 | |||
| baa617daa9 | |||
| 7e17e7ccb3 | |||
| f1a00cb955 | |||
| cdca4ad264 | |||
| 5e1b2ee979 | |||
| 3edd21d2b6 | |||
| 142136ebc9 | |||
| 9bc3fdec79 | |||
| a432fce858 | |||
| 8d0a403b36 | |||
| 21befd5b1e | |||
| fb7bfad9ce | |||
| e582597b20 | |||
| 85a5191c45 | |||
| 993c34758f | |||
| 4ab6ce29c9 | |||
| 704b68be16 | |||
| d62929ca0d | |||
| 4c0b29addf | |||
| 0cf5b30dd8 | |||
| 38031cb189 | |||
| b71d8b362b | |||
| 337b510540 | |||
| efbf998caf | |||
| a03dcb1157 | |||
| 380243b669 | |||
| cf5e0011c4 | |||
| 59fb30ac77 | |||
| 753a14286f | |||
| 62e9bf1353 | |||
| c92744fb50 | |||
| 69e2a12a3a | |||
| bcf90f2437 | |||
| 6cf7c6b573 | |||
| 1e96526bd9 | |||
| 5e5d7f80ef | |||
| 8b0b21f24d | |||
| bd8d754ee8 | |||
| 69a630d185 | |||
| 3d1d72ed9b | |||
| 36612b6bf0 | |||
| eb7d0f6559 | |||
| 61afc957aa | |||
| 9db4deafbc | |||
| 234bcd1f88 | |||
| 6b657c7795 | |||
| e4dc5180ad | |||
| 48760c4e83 | |||
| 13e00ec011 | |||
| 386e25c8fd | |||
| 70c048a37b | |||
| fb02c81830 | |||
| 2bb8771ade | |||
| 490821a637 | |||
| f9d7c48d88 | |||
| 0622270cd2 | |||
| b092a5cfe5 | |||
| a5d6e212e2 | |||
| d238888710 | |||
| 213af9db48 | |||
| 4e5df73cf7 | |||
| bdcca5e548 | |||
| e6a796ab27 | |||
| a2fc70ae57 | |||
| 5f769b0293 | |||
| da6e86fa7f | |||
| 3d128ea051 | |||
| 487c7ca82f | |||
| 524c66e25e | |||
| 0c49b89891 | |||
| 018c0f0286 | |||
| 33c13ec524 | |||
| 69e4f305e9 | |||
| 2485173aad | |||
| 6af6a026a1 | |||
| aa61efd46f | |||
| d87afb577c | |||
| 437258294b | |||
| c46e628f6a | |||
| e633463906 | |||
| 167d263560 | |||
| 60c1997642 | |||
| 02d635415b | |||
| 6f02b1a0e9 | |||
| 2170ba250c | |||
| 86809190e7 | |||
| b1e474ba33 | |||
| cde6b68a39 | |||
| 213faadf55 | |||
| 42deac1261 | |||
| cea27c8684 | |||
| 698565c460 | |||
| 70bab6b916 |
@@ -39,20 +39,18 @@ jobs:
|
||||
git fetch --depth=1 origin "${REF}"
|
||||
git checkout FETCH_HEAD
|
||||
|
||||
- name: Write NuGet config (Liara primary; Nexus optional)
|
||||
# NOTE: mirror.soroushasadi.com currently serves an incomplete TLS chain
|
||||
# (leaf only, no intermediate). .NET on Linux does NOT auto-fetch the
|
||||
# intermediate via AIA the way Windows does, so it fails with PartialChain.
|
||||
# Liara serves a complete chain, so it is the deterministic source here.
|
||||
# Re-add Nexus once nginx points ssl_certificate at fullchain.pem.
|
||||
- name: Write NuGet config (Nexus only)
|
||||
# Single source = our Nexus mirror. We do NOT list Liara as a fallback: NuGet loads
|
||||
# the service index of EVERY configured source, so a 500 from a fallback aborts the
|
||||
# whole restore (NU1301). Nexus is the source of truth.
|
||||
run: |
|
||||
cat > /tmp/nuget.ci.config << 'EOF'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="liara"
|
||||
value="https://package-mirror.liara.ir/repository/nuget/index.json"
|
||||
<add key="nexus"
|
||||
value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
|
||||
protocolVersion="3" />
|
||||
</packageSources>
|
||||
<config>
|
||||
|
||||
@@ -21,3 +21,6 @@ Thumbs.db
|
||||
# local dev run logs
|
||||
/run.log
|
||||
/run.err
|
||||
|
||||
# Xray VPN config holds real credentials — keep it server-only.
|
||||
deploy/xray/config.json
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Local-test image — uses public Microsoft base images + the Liara NuGet mirror.
|
||||
# (The production Dockerfile pulls everything through the Nexus mirror instead.)
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY nuget.docker.config /tmp/nuget.config
|
||||
COPY src/ ./src/
|
||||
RUN dotnet restore src/JobsMedical.Web/JobsMedical.Web.csproj --configfile /tmp/nuget.config -p:NuGetAudit=false
|
||||
RUN dotnet publish src/JobsMedical.Web/JobsMedical.Web.csproj -c Release -o /out --no-restore \
|
||||
/p:UseAppHost=false /p:NuGetAudit=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
WORKDIR /app
|
||||
COPY --from=build /out ./
|
||||
EXPOSE 8080
|
||||
ENV ASPNETCORE_URLS=http://+:8080 \
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
ENTRYPOINT ["dotnet", "JobsMedical.Web.dll"]
|
||||
@@ -0,0 +1,60 @@
|
||||
# Run همکادر locally with Docker (for testing)
|
||||
|
||||
A self-contained stack — app built from source + its own Postgres. It does **not** touch
|
||||
production and does **not** send SMS.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml up --build
|
||||
```
|
||||
|
||||
Then open **http://localhost:18080**.
|
||||
|
||||
First run takes a few minutes (pulls the .NET images, restores NuGet from the Liara mirror,
|
||||
builds, then applies EF migrations + seeds demo data on startup).
|
||||
|
||||
## Log in (OTP shown on screen — no SMS)
|
||||
|
||||
Because it runs in the **Development** environment, the login code is printed on the page
|
||||
instead of being texted:
|
||||
|
||||
1. Go to **ورود / ثبتنام**, choose **کادر درمان**, enter **`09120000000`** (the admin phone).
|
||||
2. Press **دریافت کد تأیید** → the 5-digit code appears in a green box on the page.
|
||||
3. Enter it → you're in as **admin** (you'll see پنل مدیریت / تنظیمات in the nav).
|
||||
|
||||
> Kavenegar is never called in Development, even if SMS is toggled on.
|
||||
|
||||
## Test ingestion
|
||||
|
||||
1. Go to **پنل مدیریت → تنظیمات → منابع جمعآوری**.
|
||||
2. Enable a source and fill its config, e.g. **مدجابز (medjobs.ir)** or **تلگرام** (channel
|
||||
usernames). For Telegram from inside Iran you'll need the proxy — tick **«از پروکسی استفاده شود»**
|
||||
under that source and set the proxy address (see `deploy/xray/README.md`); locally you can run
|
||||
your own Xray and point it at `socks5://host.docker.internal:PORT`.
|
||||
3. Save, then trigger a run from **پنل مدیریت → صف آگهیها** (Run-now), or set
|
||||
**«اجرای خودکار»** with a short interval.
|
||||
4. Watch logs: `docker compose -f docker-compose.local.yml logs -f app`
|
||||
New items land in the review queue (Manual mode) or publish (Automatic mode).
|
||||
|
||||
## Inspect the database
|
||||
|
||||
Exposed on host port **5544** (5432/5433/5434 are used by other local containers):
|
||||
|
||||
```bash
|
||||
docker exec -it hamkadr_local_db psql -U hamkadr -d hamkadr
|
||||
```
|
||||
|
||||
## Stop / reset
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml down # stop (keeps data)
|
||||
docker compose -f docker-compose.local.yml down -v # stop + wipe the DB volume
|
||||
```
|
||||
|
||||
## Notes
|
||||
- Host ports: app **18080**, Postgres **5544** (chosen to avoid clashing with your other
|
||||
local containers). Change them in `docker-compose.local.yml` if needed.
|
||||
- If `mcr.microsoft.com` isn't reachable on your machine, edit `Dockerfile.local` and swap the
|
||||
two `FROM mcr.microsoft.com/dotnet/...` lines for `mirror.soroushasadi.com/dotnet/...`.
|
||||
- Same for the Postgres image (`postgres:16-alpine` → `mirror.soroushasadi.com/postgres:16-alpine`).
|
||||
@@ -0,0 +1,66 @@
|
||||
# Ingestion proxy (Xray / V2Ray) — for scanning Telegram etc. from Iran
|
||||
|
||||
The app's HttpClient can't speak `vmess` / `vless` / `trojan` directly. Instead, the **Xray
|
||||
sidecar** (compose service `xray`) reads your config and exposes a plain **SOCKS5 proxy at
|
||||
`xray:10808`** (and HTTP at `xray:10809`) on the internal compose network. The app is then
|
||||
pointed at that proxy from the admin panel, and only ingestion traffic goes through it.
|
||||
|
||||
```
|
||||
[app ingestion] → socks5://xray:10808 → [Xray client] → vmess/vless/trojan → server → Telegram
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Create your config** from the example (it is git-ignored, so deploys never overwrite it):
|
||||
```bash
|
||||
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):
|
||||
```bash
|
||||
docker compose --profile proxy up -d xray
|
||||
docker logs hamkadr_xray --tail 30 # should show it listening, no errors
|
||||
```
|
||||
|
||||
3. **Point the app at it**: open `/Admin/Settings` → «کانالها/منابع» →
|
||||
- tick **«ارسال جمعآوری از طریق پروکسی»**
|
||||
- set the proxy URL to **`socks5://xray:10808`**
|
||||
- Save, then run ingestion (Telegram source enabled).
|
||||
|
||||
4. **Quick test** the proxy reaches Telegram:
|
||||
```bash
|
||||
docker exec hamkadr_api sh -c "wget -q -O- --timeout=15 -e use_proxy=yes -e http_proxy=http://xray:10809 https://t.me/s/telegram | head -c 200" || true
|
||||
```
|
||||
|
||||
## Where to get the config values
|
||||
If you have a share link (`vmess://…`, `vless://…`, `trojan://…`), import it into the Xray/v2rayN
|
||||
client and **export the JSON config**, or decode it and fill the templates below.
|
||||
|
||||
### vless + ws + tls (matches the default template in config.json)
|
||||
```json
|
||||
{ "tag":"proxy","protocol":"vless","settings":{"vnext":[{"address":"HOST","port":443,
|
||||
"users":[{"id":"UUID","encryption":"none"}]}]},
|
||||
"streamSettings":{"network":"ws","security":"tls","tlsSettings":{"serverName":"SNI"},
|
||||
"wsSettings":{"path":"/PATH","headers":{"Host":"SNI"}}} }
|
||||
```
|
||||
|
||||
### vmess + ws + tls
|
||||
```json
|
||||
{ "tag":"proxy","protocol":"vmess","settings":{"vnext":[{"address":"HOST","port":443,
|
||||
"users":[{"id":"UUID","alterId":0,"security":"auto"}]}]},
|
||||
"streamSettings":{"network":"ws","security":"tls","tlsSettings":{"serverName":"SNI"},
|
||||
"wsSettings":{"path":"/PATH","headers":{"Host":"SNI"}}} }
|
||||
```
|
||||
|
||||
### trojan + tls
|
||||
```json
|
||||
{ "tag":"proxy","protocol":"trojan","settings":{"servers":[{"address":"HOST","port":443,
|
||||
"password":"PASSWORD"}]},
|
||||
"streamSettings":{"network":"tcp","security":"tls","tlsSettings":{"serverName":"SNI"}} }
|
||||
```
|
||||
|
||||
> Security note: `config.json` contains your VPN credentials. It's mounted read-only into the
|
||||
> container. Do **not** commit a real config — keep the committed file as a placeholder and
|
||||
> drop the real one on the server only (or add it to `.gitignore` if you keep it locally).
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"log": { "loglevel": "warning" },
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "socks-in",
|
||||
"listen": "0.0.0.0",
|
||||
"port": 10808,
|
||||
"protocol": "socks",
|
||||
"settings": { "udp": true, "auth": "noauth" }
|
||||
},
|
||||
{
|
||||
"tag": "http-in",
|
||||
"listen": "0.0.0.0",
|
||||
"port": 10809,
|
||||
"protocol": "http"
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"//": "REPLACE this whole outbound with YOUR vmess / vless / trojan config.",
|
||||
"//vmess-example": "see deploy/xray/README.md for vmess & trojan templates",
|
||||
"tag": "proxy",
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"vnext": [
|
||||
{
|
||||
"address": "YOUR_SERVER_ADDRESS",
|
||||
"port": 443,
|
||||
"users": [
|
||||
{
|
||||
"id": "YOUR_UUID",
|
||||
"encryption": "none",
|
||||
"flow": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"streamSettings": {
|
||||
"network": "ws",
|
||||
"security": "tls",
|
||||
"tlsSettings": { "serverName": "YOUR_SNI" },
|
||||
"wsSettings": { "path": "/", "headers": { "Host": "YOUR_SNI" } }
|
||||
}
|
||||
},
|
||||
{ "tag": "direct", "protocol": "freedom" }
|
||||
],
|
||||
"routing": {
|
||||
"rules": [
|
||||
{ "type": "field", "inboundTag": ["socks-in", "http-in"], "outboundTag": "proxy" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Local test stack — build from source + a throwaway Postgres. Nothing here touches production.
|
||||
#
|
||||
# docker compose -f docker-compose.local.yml up --build
|
||||
# → open http://localhost:8080
|
||||
#
|
||||
# Runs in the Development environment, so:
|
||||
# • the login OTP code is shown ON SCREEN (no SMS sent via Kavenegar)
|
||||
# • sample Tehran demo data (facilities/shifts/jobs) is seeded automatically
|
||||
# Admin login: phone 09120000000 → request code → the code appears on the page.
|
||||
name: hamkadr-local
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.local
|
||||
container_name: hamkadr_local_app
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "18080:8080"
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: "Development" # ← OTP on screen, demo data, no Kavenegar
|
||||
ASPNETCORE_URLS: "http://+:8080"
|
||||
ConnectionStrings__Default: "Host=db;Port=5432;Database=hamkadr;Username=hamkadr;Password=hamkadr_local"
|
||||
Auth__AdminPhone: "09120000000"
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: hamkadr_local_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: hamkadr
|
||||
POSTGRES_USER: hamkadr
|
||||
POSTGRES_PASSWORD: hamkadr_local
|
||||
ports:
|
||||
- "5544:5432" # exposed for inspection on host port 5544 (5432/5433/5434 already in use)
|
||||
volumes:
|
||||
- hamkadr_local_db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U hamkadr -d hamkadr"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
volumes:
|
||||
hamkadr_local_db:
|
||||
@@ -29,6 +29,19 @@ services:
|
||||
# healthcheck is defined in the Dockerfile (bash /dev/tcp probe) so the deploy
|
||||
# job's `docker inspect Health.Status` wait works.
|
||||
|
||||
# ── Xray/V2Ray client — converts a vmess/vless/trojan config into a local SOCKS proxy ──
|
||||
# so ingestion can reach Telegram etc. from inside Iran. Behind the "proxy" profile, so it
|
||||
# only runs when you opt in: `docker compose --profile proxy up -d xray`. Put YOUR config at
|
||||
# deploy/xray/config.json (see deploy/xray/README.md), then in /Admin/Settings set the
|
||||
# ingestion proxy to socks5://xray:10808. Not published — only the app reaches it.
|
||||
xray:
|
||||
image: mirror.soroushasadi.com/teddysun/xray:latest
|
||||
container_name: hamkadr_xray
|
||||
restart: unless-stopped
|
||||
profiles: ["proxy"]
|
||||
volumes:
|
||||
- ./deploy/xray/config.json:/etc/xray/config.json:ro
|
||||
|
||||
# ── PostgreSQL (internal only — never published) ─────────────────────────────
|
||||
db:
|
||||
image: mirror.soroushasadi.com/postgres:16-alpine
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.0",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<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="liara" value="https://package-mirror.liara.ir/repository/nuget/index.json" protocolVersion="3" />
|
||||
</packageSources>
|
||||
<config>
|
||||
<add key="http_retry_count" value="6" />
|
||||
|
||||
+6
-7
@@ -1,17 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
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
|
||||
TLS chain. mirror.soroushasadi.com currently serves a leaf-only chain, which
|
||||
.NET on Linux rejects with PartialChain (Windows auto-fetches the missing
|
||||
intermediate via AIA; Linux does not). Re-add Nexus once nginx serves
|
||||
fullchain.pem for mirror.soroushasadi.com.
|
||||
inside the SDK container). Single source = our Nexus mirror. We deliberately
|
||||
do NOT list a fallback: NuGet loads the service index of every configured
|
||||
source, so a 500 from a fallback would abort the whole restore. nuget.org is
|
||||
filtered in Iran and is intentionally absent — Nexus is the source of truth.
|
||||
-->
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="liara"
|
||||
value="https://package-mirror.liara.ir/repository/nuget/index.json"
|
||||
<add key="nexus"
|
||||
value="https://mirror.soroushasadi.com/repository/nuget-group/index.json"
|
||||
protocolVersion="3" />
|
||||
</packageSources>
|
||||
<config>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
using JobsMedical.Web.Models;
|
||||
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Data;
|
||||
|
||||
public class AppDbContext : DbContext
|
||||
public class AppDbContext : DbContext, IDataProtectionKeyContext
|
||||
{
|
||||
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<District> Districts => Set<District>();
|
||||
public DbSet<Role> Roles => Set<Role>();
|
||||
@@ -15,6 +19,8 @@ public class AppDbContext : DbContext
|
||||
public DbSet<Facility> Facilities => Set<Facility>();
|
||||
public DbSet<Shift> Shifts => Set<Shift>();
|
||||
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<RawListing> RawListings => Set<RawListing>();
|
||||
public DbSet<Visitor> Visitors => Set<Visitor>();
|
||||
@@ -25,6 +31,10 @@ public class AppDbContext : DbContext
|
||||
public DbSet<Notification> Notifications => Set<Notification>();
|
||||
public DbSet<Report> Reports => Set<Report>();
|
||||
public DbSet<FacilityDocument> FacilityDocuments => Set<FacilityDocument>();
|
||||
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)
|
||||
{
|
||||
@@ -83,6 +93,22 @@ public class AppDbContext : DbContext
|
||||
.HasOne(d => d.Facility).WithMany(f => f.Documents)
|
||||
.HasForeignKey(d => d.FacilityId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Job alerts belong to a user; remove with the user. Don't cascade from Role.
|
||||
b.Entity<JobAlert>()
|
||||
.HasOne(a => a.User).WithMany()
|
||||
.HasForeignKey(a => a.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
b.Entity<JobAlert>()
|
||||
.HasOne(a => a.Role).WithMany()
|
||||
.HasForeignKey(a => a.RoleId).OnDelete(DeleteBehavior.SetNull);
|
||||
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.
|
||||
b.Entity<Shift>()
|
||||
.HasOne(s => s.Role).WithMany(r => r.Shifts)
|
||||
@@ -119,6 +145,35 @@ public class AppDbContext : DbContext
|
||||
b.Entity<JobOpening>().HasIndex(j => j.Status);
|
||||
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<Notification>()
|
||||
@@ -130,5 +185,14 @@ public class AppDbContext : DbContext
|
||||
// Dedupe ingested listings by content hash.
|
||||
b.Entity<RawListing>().HasIndex(r => r.ContentHash);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,42 @@ namespace JobsMedical.Web.Data;
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
await SeedReferenceAsync(db);
|
||||
await EnsureRolesAsync(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) ----------
|
||||
public static async Task SeedReferenceAsync(AppDbContext db)
|
||||
{
|
||||
@@ -29,14 +59,7 @@ public static class SeedData
|
||||
new City { Name = "شیراز", Province = "فارس", IsActive = false });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Roles.AddRange(
|
||||
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 });
|
||||
// Roles are seeded by EnsureRolesAsync (idempotent, runs every startup).
|
||||
|
||||
foreach (var n in new[] { "سعادتآباد", "شهرک غرب", "ولیعصر / پارکوی", "نارمک",
|
||||
"تهرانپارس", "گیشا / برج میلاد", "ونک", "تجریش" })
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="WebPush" Version="1.0.12" />
|
||||
</ItemGroup>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class IngestProxy : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IngestProxyEnabled",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "IngestProxyUrl",
|
||||
table: "AppSettings",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IngestProxyEnabled",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IngestProxyUrl",
|
||||
table: "AppSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class JobAlerts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobAlerts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
UserId = table.Column<int>(type: "integer", nullable: false),
|
||||
Label = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: true),
|
||||
Scope = table.Column<int>(type: "integer", nullable: false),
|
||||
RoleId = table.Column<int>(type: "integer", nullable: true),
|
||||
CityId = table.Column<int>(type: "integer", nullable: true),
|
||||
DistrictId = table.Column<int>(type: "integer", nullable: true),
|
||||
ShiftType = table.Column<int>(type: "integer", nullable: true),
|
||||
EmploymentType = table.Column<int>(type: "integer", nullable: true),
|
||||
MinPay = table.Column<long>(type: "bigint", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JobAlerts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_JobAlerts_Cities_CityId",
|
||||
column: x => x.CityId,
|
||||
principalTable: "Cities",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_JobAlerts_Roles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "Roles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_JobAlerts_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobAlerts_CityId",
|
||||
table: "JobAlerts",
|
||||
column: "CityId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobAlerts_IsActive",
|
||||
table: "JobAlerts",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobAlerts_RoleId",
|
||||
table: "JobAlerts",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobAlerts_UserId",
|
||||
table: "JobAlerts",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobAlerts");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PerSourceProxy : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "BaleUseProxy",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "DivarUseProxy",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "MedjobsUseProxy",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TelegramUseProxy",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "WebsitesUseProxy",
|
||||
table: "AppSettings",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BaleUseProxy",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DivarUseProxy",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MedjobsUseProxy",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TelegramUseProxy",
|
||||
table: "AppSettings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WebsitesUseProxy",
|
||||
table: "AppSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1230
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 ApplicationStatus : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Status",
|
||||
table: "InterestEvents",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Status",
|
||||
table: "InterestEvents");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1248
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UserProfileMedia : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "Avatar",
|
||||
table: "Users",
|
||||
type: "bytea",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "AvatarContentType",
|
||||
table: "Users",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<byte[]>(
|
||||
name: "Resume",
|
||||
table: "Users",
|
||||
type: "bytea",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ResumeContentType",
|
||||
table: "Users",
|
||||
type: "character varying(120)",
|
||||
maxLength: 120,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ResumeFileName",
|
||||
table: "Users",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Avatar",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AvatarContentType",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Resume",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ResumeContentType",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ResumeFileName",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1267
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1473
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1581
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1617
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1635
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1644
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)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<bool>("AiUseProxy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("AutoIngestEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -66,6 +69,9 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<bool>("BaleEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("BaleUseProxy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("DemoMode")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -80,15 +86,50 @@ namespace JobsMedical.Web.Migrations
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<bool>("DivarUseProxy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("IngestIntervalMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IngestProxyEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("IngestProxyUrl")
|
||||
.HasMaxLength(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")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("MedjobsMaxAds")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("MedjobsUseProxy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("Mode")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -114,6 +155,51 @@ namespace JobsMedical.Web.Migrations
|
||||
.HasMaxLength(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")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
@@ -121,6 +207,9 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<bool>("TelegramEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("TelegramUseProxy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -146,6 +235,9 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<bool>("WebsitesEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("WebsitesUseProxy")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppSettings");
|
||||
@@ -211,6 +303,45 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -396,6 +527,44 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -416,6 +585,9 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<int?>("ShiftId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("VisitorId")
|
||||
.IsRequired()
|
||||
.HasColumnType("character varying(36)");
|
||||
@@ -431,6 +603,61 @@ namespace JobsMedical.Web.Migrations
|
||||
b.ToTable("InterestEvents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.JobAlert", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("CityId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int?>("DistrictId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("EmploymentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<long?>("MinPay")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int?>("RoleId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Scope")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("ShiftType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CityId");
|
||||
|
||||
b.HasIndex("IsActive");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("JobAlerts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -455,6 +682,12 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<int>("GenderRequirement")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double?>("Lat")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("Lng")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("Requirements")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
@@ -494,6 +727,36 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -549,9 +812,18 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<DateTime>("FetchedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("Lat")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<int?>("LinkedShiftId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("LinkedTalentId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double?>("Lng")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<string>("ParsedJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
@@ -581,6 +853,8 @@ namespace JobsMedical.Web.Migrations
|
||||
|
||||
b.HasIndex("LinkedShiftId");
|
||||
|
||||
b.HasIndex("LinkedTalentId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.ToTable("RawListings");
|
||||
@@ -629,6 +903,43 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -685,6 +996,12 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Property<int>("GenderRequirement")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<double?>("Lat")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<double?>("Lng")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<long?>("PayAmount")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -729,6 +1046,96 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -737,6 +1144,13 @@ namespace JobsMedical.Web.Migrations
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<byte[]>("Avatar")
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<string>("AvatarContentType")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("BanReason")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
@@ -759,6 +1173,17 @@ namespace JobsMedical.Web.Migrations
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<byte[]>("Resume")
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<string>("ResumeContentType")
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("ResumeFileName")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -872,6 +1297,25 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.User", "Doctor")
|
||||
@@ -891,6 +1335,30 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.City", "City")
|
||||
@@ -986,6 +1454,30 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Navigation("Visitor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.JobAlert", b =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.City", "City")
|
||||
.WithMany()
|
||||
.HasForeignKey("CityId");
|
||||
|
||||
b.HasOne("JobsMedical.Web.Models.Role", "Role")
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("JobsMedical.Web.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("City");
|
||||
|
||||
b.Navigation("Role");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.Facility", "Facility")
|
||||
@@ -1005,6 +1497,17 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.User", "User")
|
||||
@@ -1020,9 +1523,36 @@ namespace JobsMedical.Web.Migrations
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.Shift", "LinkedShift")
|
||||
.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("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 =>
|
||||
@@ -1044,6 +1574,32 @@ namespace JobsMedical.Web.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("JobsMedical.Web.Models.City", "City")
|
||||
@@ -1094,6 +1650,11 @@ namespace JobsMedical.Web.Migrations
|
||||
b.Navigation("Shifts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.JobOpening", b =>
|
||||
{
|
||||
b.Navigation("Contacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Role", b =>
|
||||
{
|
||||
b.Navigation("Shifts");
|
||||
@@ -1102,6 +1663,13 @@ namespace JobsMedical.Web.Migrations
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.Shift", b =>
|
||||
{
|
||||
b.Navigation("Applications");
|
||||
|
||||
b.Navigation("Contacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.TalentListing", b =>
|
||||
{
|
||||
b.Navigation("Contacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("JobsMedical.Web.Models.User", b =>
|
||||
|
||||
@@ -32,6 +32,10 @@ public class AppSetting
|
||||
/// <summary>If AI approves AND Mode is Automatic, publish without human review.</summary>
|
||||
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) ---
|
||||
/// <summary>Run the ingestion worker on a timer.</summary>
|
||||
public bool AutoIngestEnabled { get; set; } = false;
|
||||
@@ -51,6 +55,22 @@ public class AppSetting
|
||||
/// <summary>Generic web pages to scrape, one URL per line.</summary>
|
||||
[MaxLength(4000)] public string? WebsiteUrls { get; set; }
|
||||
|
||||
/// <summary>Local proxy an Xray/V2Ray client sidecar exposes, e.g. socks5://xray:10808
|
||||
/// (also accepts socks4:// or http://). The app cannot read vmess/vless/trojan directly;
|
||||
/// the sidecar converts that config into this local proxy. Per-source toggles below decide
|
||||
/// which channels actually route through it.</summary>
|
||||
[MaxLength(200)] public string? IngestProxyUrl { get; set; }
|
||||
|
||||
/// <summary>Legacy global flag — kept for compatibility; per-source flags below now control routing.</summary>
|
||||
public bool IngestProxyEnabled { get; set; } = false;
|
||||
|
||||
// Per-source: route this source's fetches through IngestProxyUrl (only when a URL is set).
|
||||
public bool TelegramUseProxy { get; set; } = false;
|
||||
public bool BaleUseProxy { get; set; } = false;
|
||||
public bool DivarUseProxy { get; set; } = false;
|
||||
public bool MedjobsUseProxy { get; set; } = false;
|
||||
public bool WebsitesUseProxy { get; set; } = false;
|
||||
|
||||
public bool DivarEnabled { get; set; } = false;
|
||||
[MaxLength(60)] public string? DivarCity { get; set; } = "tehran";
|
||||
/// <summary>Divar search terms, one per line or comma-separated.</summary>
|
||||
@@ -61,6 +81,18 @@ public class AppSetting
|
||||
/// <summary>Max ads to fetch per ingestion run (be polite; dedupe skips already-seen).</summary>
|
||||
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). ---
|
||||
public bool SmsEnabled { get; set; } = false;
|
||||
[MaxLength(200)] public string? SmsApiKey { get; set; }
|
||||
@@ -84,6 +116,32 @@ public class AppSetting
|
||||
[MaxLength(200)] public string? VapidPrivateKey { get; set; }
|
||||
[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;
|
||||
|
||||
/// <summary>Split a textarea (newline/comma separated) into trimmed non-empty items.</summary>
|
||||
@@ -92,17 +150,48 @@ public class AppSetting
|
||||
: s.Split(new[] { '\n', '\r', ',', '،' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.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 = """
|
||||
تو دستیار بررسی آگهیهای کاری حوزه درمان برای پلتفرم «همکادر» هستی.
|
||||
هر آگهی خام را بخوان و تصمیم بگیر:
|
||||
- approve: آگهی واقعی و مرتبط با شیفت/استخدام کادر درمان است و اطلاعات کافی دارد.
|
||||
- reject: تبلیغ، اسپم، نامرتبط، یا فاقد اطلاعات حداقلی است.
|
||||
- review: مرتبط است اما ناقص/مبهم و نیاز به بررسی انسانی دارد.
|
||||
نقش، شهر/محله، نوع شیفت، نوع همکاری، مبلغ یا درصد سهم، و عنوان را در صورت وجود استخراج کن.
|
||||
فقط با یک شیء JSON پاسخ بده با کلیدهای:
|
||||
decision (approve|reject|review)، confidence (0-100)، reason (فارسی کوتاه)،
|
||||
kind (shift|job)، role، city، district، shiftType (day|evening|night|oncall)،
|
||||
employmentType (fulltime|parttime|contract|plan)، payAmount (عدد تومان یا null)،
|
||||
sharePercent (0-100 یا null)، title، facilityName.
|
||||
تو دستیار دستهبندی آگهیهای کادر درمان تهران برای پلتفرم «همکادر» هستی. هر ورودی یک متن خام از
|
||||
کانالهای تلگرام/بله/دیوار است. وظیفه: (۱) تشخیص نوع، (۲) استخراج دقیق فیلدها و دستهبندی فرد،
|
||||
(۳) تصمیم تأیید/رد/بررسی. فقط یک شیء JSON معتبر برگردان؛ هیچ متن اضافهای ننویس.
|
||||
|
||||
نوع (kind):
|
||||
• shift = مرکز درمانی برای بازهٔ زمانی مشخص نیرو میخواهد.
|
||||
• job = مرکز درمانی استخدام دائم/قراردادی دارد.
|
||||
• talent= فردی از کادر درمان خودش را «آماده به کار / آماده همکاری» معرفی میکند
|
||||
(سمت عرضه؛ مرکز ندارد و شماره تماسِ خودِ فرد مهمترین فیلد است).
|
||||
|
||||
نقش (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; }
|
||||
}
|
||||
@@ -27,7 +27,8 @@ public enum ShiftStatus
|
||||
Open = 0, // باز
|
||||
Filled = 1, // پر شده
|
||||
Expired = 2, // منقضی
|
||||
Cancelled = 3 // لغو شده
|
||||
Cancelled = 3, // لغو شده
|
||||
Archived = 4 // بایگانیشده (پنهان از سایت، نگهداری برای تحلیل)
|
||||
}
|
||||
|
||||
public enum ShiftSource
|
||||
@@ -69,11 +70,21 @@ public enum EmploymentType
|
||||
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
|
||||
{
|
||||
Shift = 0,
|
||||
Job = 1
|
||||
Job = 1,
|
||||
Talent = 2
|
||||
}
|
||||
|
||||
/// <summary>Which listing types a job alert watches.</summary>
|
||||
public enum AlertScope
|
||||
{
|
||||
Any = 0, // هر دو (شیفت و استخدام)
|
||||
Shifts = 1, // فقط شیفت
|
||||
Jobs = 2 // فقط استخدام
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -94,6 +105,23 @@ public enum IngestionMode
|
||||
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 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; }
|
||||
}
|
||||
@@ -31,5 +31,9 @@ public class InterestEvent
|
||||
|
||||
public InterestEventType EventType { get; set; }
|
||||
|
||||
/// <summary>For Apply events: the application status the employer/applicant sees
|
||||
/// (Interested = new/pending → Accepted/Rejected by the employer, Withdrawn by the applicant).</summary>
|
||||
public ApplicationStatus Status { get; set; } = ApplicationStatus.Interested;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace JobsMedical.Web.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A saved job alert ("هشدار شغلی") — the user describes what they're after (kind, role, city,
|
||||
/// shift type, employment type, minimum pay). When an employer publishes a matching shift/job,
|
||||
/// the matching engine notifies the owner. A user can keep several alerts; each can be paused.
|
||||
/// </summary>
|
||||
public class JobAlert
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int UserId { get; set; }
|
||||
public User User { get; set; } = null!;
|
||||
|
||||
[MaxLength(120)] public string? Label { get; set; } // optional friendly name
|
||||
|
||||
public AlertScope Scope { get; set; } = AlertScope.Any;
|
||||
|
||||
public int? RoleId { get; set; } // null = any role
|
||||
public Role? Role { get; set; }
|
||||
|
||||
public int? CityId { get; set; } // null = any city
|
||||
public City? City { get; set; }
|
||||
|
||||
public int? DistrictId { get; set; } // null = any district
|
||||
|
||||
public ShiftType? ShiftType { get; set; } // for shifts; null = any
|
||||
public EmploymentType? EmploymentType { get; set; } // for jobs; null = any
|
||||
|
||||
/// <summary>Minimum acceptable pay (تومان): per-shift amount for shifts, monthly salary for jobs.</summary>
|
||||
public long? MinPay { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -40,8 +40,16 @@ public class JobOpening
|
||||
[MaxLength(500)]
|
||||
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;
|
||||
|
||||
/// <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.
|
||||
[NotMapped] public double? DistanceKm { get; set; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,9 +24,17 @@ public class RawListing
|
||||
public int? LinkedShiftId { get; set; } // شیفت ساختهشده از این آگهی
|
||||
public Shift? LinkedShift { get; set; }
|
||||
|
||||
public int? LinkedTalentId { get; set; } // آگهی «آماده به کار» ساختهشده از این متن
|
||||
public TalentListing? LinkedTalent { get; set; }
|
||||
|
||||
[MaxLength(500)]
|
||||
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>
|
||||
[MaxLength(64)]
|
||||
public string? ContentHash { get; set; }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace JobsMedical.Web.Models;
|
||||
|
||||
/// <summary>A کادر درمان's rating + review of a facility they worked with (1–5 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;
|
||||
}
|
||||
@@ -40,10 +40,19 @@ public class Shift
|
||||
[MaxLength(500)]
|
||||
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 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.
|
||||
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||
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; }
|
||||
}
|
||||
@@ -26,6 +26,13 @@ public class User
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// --- Profile media (editable at /Me/Profile; stored in-DB so it survives deploys) ---
|
||||
public byte[]? Avatar { get; set; }
|
||||
[MaxLength(100)] public string? AvatarContentType { get; set; }
|
||||
public byte[]? Resume { get; set; }
|
||||
[MaxLength(200)] public string? ResumeFileName { get; set; }
|
||||
[MaxLength(120)] public string? ResumeContentType { get; set; }
|
||||
|
||||
// Navigation
|
||||
public DoctorProfile? DoctorProfile { get; set; }
|
||||
public ICollection<Application> Applications { get; set; } = new List<Application>();
|
||||
|
||||
@@ -106,7 +106,7 @@ public class LoginModel : PageModel
|
||||
// Route to the right panel for the account type.
|
||||
return user.Role switch
|
||||
{
|
||||
UserRole.Admin => RedirectToPage("/Admin/Index"),
|
||||
UserRole.Admin => RedirectToPage("/Admin/Overview"),
|
||||
UserRole.FacilityAdmin => RedirectToPage("/Employer/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);
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@
|
||||
<h1>پنل مدیریت — جمعآوری و صف آگهیها</h1>
|
||||
<p class="muted">
|
||||
آگهیهای جمعآوریشده از منابع را بررسی، ساختارمند و منتشر کن.
|
||||
(@JalaliDate.ToPersianDigits(Model.Queue.Count.ToString()) در صف،
|
||||
@JalaliDate.ToPersianDigits(Model.Flagged.Count.ToString()) پرچمخورده)
|
||||
(@JalaliDate.ToPersianDigits(Model.QueueTotal.ToString()) در صف،
|
||||
@JalaliDate.ToPersianDigits(Model.FlaggedTotal.ToString()) پرچمخورده)
|
||||
· <a asp-page="/Admin/Overview">داشبورد</a>
|
||||
· <a asp-page="/Admin/Users">کاربران</a>
|
||||
· <a asp-page="/Admin/Facilities">مراکز</a>
|
||||
@@ -40,6 +40,77 @@
|
||||
<p class="muted" style="font-size:11px; margin:8px 0 0;">
|
||||
موتور: واکشی ← حذف تکراری ← تجزیه ← اعتبارسنجی ← صف بررسی.
|
||||
</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;" />
|
||||
|
||||
@@ -62,6 +133,43 @@
|
||||
</aside>
|
||||
|
||||
<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>
|
||||
@if (Model.Queue.Count == 0)
|
||||
{
|
||||
@@ -73,9 +181,19 @@
|
||||
{
|
||||
<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>
|
||||
<p class="muted" style="font-size:13px;">اعتبارسنجی اینها را کامل ندانست؛ در صورت صحت میتوانی منتشرشان کنی.</p>
|
||||
@@ -83,6 +201,16 @@
|
||||
{
|
||||
<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>
|
||||
|
||||
@@ -13,25 +13,37 @@ public class IndexModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
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;
|
||||
_ingest = ingest;
|
||||
_scopes = scopes;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public List<RawListing> Queue { 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 int PublishedShifts { 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? RawText { 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()
|
||||
{
|
||||
@@ -48,6 +60,14 @@ public class IndexModel : PageModel
|
||||
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()
|
||||
{
|
||||
var s = await _ingest.RunAsync();
|
||||
@@ -56,16 +76,139 @@ public class IndexModel : PageModel
|
||||
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
|
||||
.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
|
||||
.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;
|
||||
PublishedShifts = await _db.Shifts.CountAsync(s => s.Source != ShiftSource.Direct);
|
||||
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/Reports">گزارشها</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>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<span class="badge @(r.Status == ReportStatus.Open ? "badge-day" : "badge-type")">@StatusLabel(r.Status)</span>
|
||||
</div>
|
||||
<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;">
|
||||
<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)
|
||||
|
||||
@@ -10,11 +10,25 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="card card-pad">
|
||||
<h3 style="margin-top:0;">متن خام</h3>
|
||||
<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>
|
||||
|
||||
@if (Model.Parsed is not null)
|
||||
@@ -42,25 +56,33 @@
|
||||
<select name="Kind" id="kindSelect">
|
||||
<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="2" selected="@(Model.Kind == JobsMedical.Web.Models.ListingKind.Talent)">آماده به کار (معرفی نیرو)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<div class="filter-group" id="facilityGroup">
|
||||
<label>مرکز درمانی</label>
|
||||
<select name="FacilityId">
|
||||
<option value="0" selected="@(Model.FacilityId == 0)">— انتخاب نشده —</option>
|
||||
@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>
|
||||
<input type="text" name="NewFacilityName" placeholder="یا نام مرکز جدید را وارد کن…" style="margin-top:6px;" />
|
||||
<p class="muted" style="font-size:11px; margin:4px 0 0;">اگر مرکز در فهرست نیست، نامش را اینجا بنویس تا بهصورت «تأییدنشده» ساخته شود.</p>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>نقش</label>
|
||||
<select name="RoleId">
|
||||
<label>نقشها (میتوانی چند مورد انتخاب کنی)</label>
|
||||
<div class="role-checks">
|
||||
@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 class="filter-group">
|
||||
@@ -116,6 +138,40 @@
|
||||
</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">
|
||||
<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" /> توافقی
|
||||
@@ -136,10 +192,20 @@
|
||||
@section Scripts {
|
||||
<script>
|
||||
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() {
|
||||
var isJob = kind.value === '1';
|
||||
document.getElementById('jobFields').style.display = isJob ? 'block' : 'none';
|
||||
document.getElementById('shiftFields').style.display = isJob ? 'none' : 'block';
|
||||
var v = kind.value;
|
||||
setSection(document.getElementById('shiftFields'), v === '0');
|
||||
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);
|
||||
toggleKind();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using JobsMedical.Web.Services;
|
||||
using JobsMedical.Web.Services.Scraping;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
@@ -26,11 +27,16 @@ public class ReviewModel : PageModel
|
||||
public ParsedListing? Parsed { get; private set; }
|
||||
public List<Facility> Facilities { 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).
|
||||
[BindProperty] public ListingKind Kind { 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; }
|
||||
// Shift fields
|
||||
[BindProperty] public DateOnly ShiftDate { get; set; }
|
||||
@@ -46,6 +52,13 @@ public class ReviewModel : PageModel
|
||||
[BindProperty] public EmploymentType EmploymentType { get; set; }
|
||||
[BindProperty] public long? SalaryMin { 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)
|
||||
{
|
||||
@@ -58,7 +71,8 @@ public class ReviewModel : PageModel
|
||||
|
||||
// Prefill the form from the parser's best guess.
|
||||
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;
|
||||
EmploymentType = Parsed.EmploymentType ?? EmploymentType.FullTime;
|
||||
(StartTime, EndTime) = DefaultTimes(ShiftType);
|
||||
@@ -69,6 +83,34 @@ public class ReviewModel : PageModel
|
||||
if (Parsed.PayAmount is not null) { PayAmount = Parsed.PayAmount; SalaryMin = Parsed.PayAmount; }
|
||||
Description = Raw.RawText;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -77,59 +119,130 @@ public class ReviewModel : PageModel
|
||||
Raw = await _db.RawListings.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (Raw is null) return NotFound();
|
||||
|
||||
Shift? createdShift = null;
|
||||
JobOpening? createdJob = null;
|
||||
if (Kind == ListingKind.Shift)
|
||||
// One or more roles — publish a separate listing per selected role.
|
||||
var validRoles = await _db.Roles.Where(r => RoleIds.Contains(r.Id)).ToListAsync();
|
||||
if (validRoles.Count == 0)
|
||||
{
|
||||
var role = await _db.Roles.FindAsync(RoleId);
|
||||
var shift = new Shift
|
||||
Error = "حداقل یک نقش معتبر انتخاب کن.";
|
||||
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)
|
||||
{
|
||||
var cityId = TalentCityId > 0 && await _db.Cities.AnyAsync(c => c.Id == TalentCityId)
|
||||
? TalentCityId
|
||||
: await _db.Cities.OrderByDescending(c => c.IsActive).Select(c => (int?)c.Id).FirstOrDefaultAsync();
|
||||
if (cityId is null)
|
||||
{
|
||||
FacilityId = FacilityId,
|
||||
RoleId = RoleId,
|
||||
Date = ShiftDate,
|
||||
StartTime = StartTime,
|
||||
EndTime = EndTime,
|
||||
ShiftType = ShiftType,
|
||||
SpecialtyRequired = role?.Name ?? "",
|
||||
Description = Description,
|
||||
PayType = Negotiable ? PayType.Negotiable
|
||||
: (PayAmount is null && SharePercent is not null ? PayType.Percentage : PayType.PerShift),
|
||||
PayAmount = Negotiable ? null : PayAmount,
|
||||
SharePercent = Negotiable ? null : SharePercent,
|
||||
GenderRequirement = GenderRequirement,
|
||||
Status = ShiftStatus.Open,
|
||||
Source = ShiftSource.Aggregated,
|
||||
SourceUrl = Raw.SourceUrl,
|
||||
};
|
||||
_db.Shifts.Add(shift);
|
||||
Error = "شهری برای انتشار آگهی «آماده به کار» موجود نیست.";
|
||||
return RedirectToPage(new { id });
|
||||
}
|
||||
var roleNames = await _db.Roles.Select(r => r.Name).ToListAsync();
|
||||
var reparsed = _parser.Parse(Raw.RawText, roleNames, await CityNamesAsync(), await DistrictNamesAsync());
|
||||
var contactSpecs = reparsed.Contacts.Select((c, i) => (c.Type, c.Value, Order: i)).ToList();
|
||||
var adminPhone = string.IsNullOrWhiteSpace(Phone) ? null : Phone.Trim();
|
||||
var tags = string.Join(" ", reparsed.Tags.Distinct());
|
||||
|
||||
// Fresh ContactMethod instances per listing (EF can't share children across parents).
|
||||
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.TalentListings.Add(t);
|
||||
firstTalent ??= t;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
Raw.Status = RawListingStatus.Normalized;
|
||||
Raw.LinkedShiftId = shift.Id;
|
||||
createdShift = shift;
|
||||
Raw.LinkedTalentId = firstTalent!.Id;
|
||||
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
|
||||
{
|
||||
var job = new JobOpening
|
||||
var created = new List<JobOpening>();
|
||||
foreach (var role in validRoles)
|
||||
{
|
||||
FacilityId = FacilityId,
|
||||
RoleId = RoleId,
|
||||
Title = string.IsNullOrWhiteSpace(Title) ? "موقعیت استخدامی" : Title.Trim(),
|
||||
EmploymentType = EmploymentType,
|
||||
SalaryMin = Negotiable ? null : SalaryMin,
|
||||
SalaryMax = Negotiable ? null : SalaryMax,
|
||||
GenderRequirement = GenderRequirement,
|
||||
Description = Description,
|
||||
Status = ShiftStatus.Open,
|
||||
Source = ShiftSource.Aggregated,
|
||||
SourceUrl = Raw.SourceUrl,
|
||||
};
|
||||
_db.JobOpenings.Add(job);
|
||||
var job = new JobOpening
|
||||
{
|
||||
FacilityId = facilityId.Value, RoleId = role.Id,
|
||||
// With several roles, give each a role-specific title; with one, honor the typed title.
|
||||
Title = many || string.IsNullOrWhiteSpace(Title) ? $"استخدام {role.Name}" : Title.Trim(),
|
||||
EmploymentType = EmploymentType,
|
||||
SalaryMin = Negotiable ? null : SalaryMin, SalaryMax = Negotiable ? null : SalaryMax,
|
||||
GenderRequirement = GenderRequirement, Description = Description,
|
||||
Status = ShiftStatus.Open, Source = ShiftSource.Aggregated, SourceUrl = Raw.SourceUrl,
|
||||
};
|
||||
_db.JobOpenings.Add(job);
|
||||
created.Add(job);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
Raw.Status = RawListingStatus.Normalized;
|
||||
createdJob = job;
|
||||
await _db.SaveChangesAsync();
|
||||
foreach (var j in created) await _notify.NotifyNewJobAsync(j.Id);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
if (createdShift is not null) await _notify.NotifyNewShiftAsync(createdShift.Id);
|
||||
if (createdJob is not null) await _notify.NotifyNewJobAsync(createdJob.Id);
|
||||
return RedirectToPage("/Admin/Index");
|
||||
}
|
||||
|
||||
@@ -150,10 +263,66 @@ public class ReviewModel : PageModel
|
||||
_ => (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()
|
||||
{
|
||||
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();
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,223 +1,290 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Admin.SettingsModel
|
||||
@{
|
||||
ViewData["Title"] = "تنظیمات جمعآوری و هوش مصنوعی";
|
||||
ViewData["Title"] = "تنظیمات سامانه";
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>تنظیمات جمعآوری و هوش مصنوعی</h1>
|
||||
<h1>تنظیمات سامانه</h1>
|
||||
<p class="muted"><a asp-page="/Admin/Index">← بازگشت به صف</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section" style="max-width:680px;">
|
||||
<div class="container section">
|
||||
@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> }
|
||||
<div class="card card-pad" style="margin-bottom:14px;">
|
||||
<h3 style="margin-top:0;">حالت نمایشی (Demo)</h3>
|
||||
<p class="muted" style="font-size:13px; margin-top:0;">دادههای نمونهی تهران را برای نمایش/دمو روی سایت قرار بده یا حذف کن. (تیک «حالت نمایشی» پایین را هم بزن تا پس از هر استقرار دوباره ساخته شود.)</p>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<form method="post"><button asp-page-handler="SeedDemo" class="btn btn-outline">ساخت داده نمونه</button></form>
|
||||
<form method="post" onsubmit="return confirm('همه دادههای نمونه حذف شوند؟');"><button asp-page-handler="ClearDemo" class="btn btn-outline" style="color:var(--danger); border-color:var(--danger);">حذف داده نمونه</button></form>
|
||||
</div>
|
||||
</div>
|
||||
@if (Model.SmsTest is not null) { <div class="alert alert-success">@Model.SmsTest</div> }
|
||||
<form method="post" class="card card-pad" style="margin-bottom:14px; display:flex; gap:8px; align-items:end; flex-wrap:wrap;">
|
||||
<div class="filter-group" style="margin:0; flex:1; min-width:160px;">
|
||||
<label>ارسال پیامک آزمایشی به</label>
|
||||
<input type="tel" name="TestPhone" dir="ltr" placeholder="۰۹۱۲ ..." />
|
||||
</div>
|
||||
<button type="submit" asp-page-handler="TestSms" class="btn btn-outline">ارسال آزمایشی</button>
|
||||
</form>
|
||||
@if (Model.Saved is not null)
|
||||
@if (Model.ProxyTest is not null) { <div class="alert alert-success">@Model.ProxyTest</div> }
|
||||
@if (Model.AiTest is not null)
|
||||
{
|
||||
<div class="alert alert-success">✓ @Model.Saved</div>
|
||||
<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" class="card card-pad">
|
||||
<h3 style="margin-top:0;">کانالهای اعلان (فعال / غیرفعال)</h3>
|
||||
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموشکردن هر کانال ارسال اعلان به کاربران. کلیدها و تنظیمات هر کانال در بخشهای پایینتر.</p>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="WebNotificationsEnabled" value="true" style="width:auto;" checked="@Model.WebNotificationsEnabled" />
|
||||
اعلانهای وب / درونبرنامهای (زنگوله + نوتیف زنده) — توصیهشده برای ایران
|
||||
</label>
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">از طریق سرور خودمان ارسال میشود؛ نیازی به سرویسهای گوگل ندارد و در ایران کار میکند.</p>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="SmsEnabled" value="true" style="width:auto;" checked="@Model.SmsEnabled" />
|
||||
پیامک (SMS) — کاوهنگار
|
||||
</label>
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای کد ورود و اعلانهای مهم. کلید و تمپلیت را در بخش «پیامک ورود» پایین وارد کن.</p>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="PushEnabled" value="true" style="width:auto;" checked="@Model.PushEnabled" />
|
||||
پوش مرورگر (Web Push) — بهترین تلاش
|
||||
</label>
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای اعلان هنگام بستهبودن برنامه؛ ولی از سرویس مرورگر (گوگل) عبور میکند که در ایران اغلب فیلتر است.</p>
|
||||
</div>
|
||||
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||
<form method="post">
|
||||
<div class="settings-layout">
|
||||
|
||||
<h3 style="margin-top:0;">حالت انتشار</h3>
|
||||
<div class="filter-group">
|
||||
<label>نحوه افزودن آگهیها به سایت</label>
|
||||
<select name="Mode">
|
||||
<option value="0" selected="@(Model.Mode == JobsMedical.Web.Models.IngestionMode.Manual)">دستی — همه به صف بررسی میروند</option>
|
||||
<option value="1" selected="@(Model.Mode == JobsMedical.Web.Models.IngestionMode.Automatic)">خودکار — موارد تأییدشده مستقیم منتشر میشوند</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>حداقل درصد اطمینان برای انتشار خودکار (بدون هوش مصنوعی)</label>
|
||||
<input type="number" name="AutoPublishMinConfidence" min="0" max="100" value="@Model.AutoPublishMinConfidence" dir="ltr" />
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">در حالت خودکار و بدون AI، آگهیهایی با اطمینان بالاتر از این مقدار خودکار منتشر میشوند.</p>
|
||||
</div>
|
||||
<!-- Sidebar tabs -->
|
||||
<nav class="settings-tabs" id="settingsTabs">
|
||||
<button type="button" data-tab="publish" class="active"><span class="tab-ico">📢</span> انتشار و هوش مصنوعی</button>
|
||||
<button type="button" data-tab="sources"><span class="tab-ico">📡</span> منابع جمعآوری</button>
|
||||
<button type="button" data-tab="channels"><span class="tab-ico">🔔</span> کانالهای اعلان</button>
|
||||
<button type="button" data-tab="sms"><span class="tab-ico">✉️</span> پیامک</button>
|
||||
<button type="button" data-tab="push"><span class="tab-ico">📲</span> پوش مرورگری</button>
|
||||
<button type="button" data-tab="map"><span class="tab-ico">🗺️</span> نقشه</button>
|
||||
<button type="button" data-tab="demo"><span class="tab-ico">🧪</span> حالت نمایشی</button>
|
||||
</nav>
|
||||
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||
<!-- Panels -->
|
||||
<div>
|
||||
<!-- PUBLISH + AI -->
|
||||
<section class="card card-pad settings-panel active" data-panel="publish">
|
||||
<h3>حالت انتشار</h3>
|
||||
<div class="filter-group">
|
||||
<label>نحوه افزودن آگهیها به سایت</label>
|
||||
<select name="Mode">
|
||||
<option value="0" selected="@(Model.Mode == JobsMedical.Web.Models.IngestionMode.Manual)">دستی — همه به صف بررسی میروند</option>
|
||||
<option value="1" selected="@(Model.Mode == JobsMedical.Web.Models.IngestionMode.Automatic)">خودکار — موارد تأییدشده مستقیم منتشر میشوند</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>حداقل درصد اطمینان برای انتشار خودکار (بدون هوش مصنوعی)</label>
|
||||
<input type="number" name="AutoPublishMinConfidence" min="0" max="100" value="@Model.AutoPublishMinConfidence" dir="ltr" />
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">در حالت خودکار و بدون AI، آگهیهایی با اطمینان بالاتر از این مقدار خودکار منتشر میشوند.</p>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:0;">لایه هوش مصنوعی (اختیاری)</h3>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="AiEnabled" value="true" style="width:auto;" checked="@Model.AiEnabled" />
|
||||
فعالسازی بررسی با هوش مصنوعی قبل از انتشار
|
||||
</label>
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">در صورت فعال بودن، هر آگهی پیش از انتشار توسط مدل بررسی و تأیید/رد/ساختارمند میشود.</p>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>آدرس سرویس (سازگار با OpenAI)</label>
|
||||
<input type="text" name="AiEndpoint" value="@Model.AiEndpoint" placeholder="https://host/v1/chat/completions" dir="ltr" />
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">میتواند یک مدل self-hosted یا سرویس داخلی باشد (OpenAI/Anthropic در ایران مسدودند).</p>
|
||||
</div>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;"><label>کلید API</label><input type="password" name="AiApiKey" value="@Model.AiApiKey" dir="ltr" /></div>
|
||||
<div style="flex:1;"><label>نام مدل</label><input type="text" name="AiModel" value="@Model.AiModel" dir="ltr" /></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework)</label>
|
||||
<textarea name="AiSystemPrompt" rows="10" dir="rtl">@Model.AiSystemPrompt</textarea>
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">به مدل بگو چطور تأیید/رد کند و چه فیلدهایی را استخراج کند. خروجی باید JSON باشد.</p>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="AiAutoApprove" value="true" style="width:auto;" checked="@Model.AiAutoApprove" />
|
||||
در حالت خودکار، آگهیهایی که AI تأیید میکند مستقیم منتشر شوند
|
||||
</label>
|
||||
</div>
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||
<h3>لایه هوش مصنوعی (اختیاری)</h3>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="AiEnabled" value="true" checked="@Model.AiEnabled" />
|
||||
<span class="t-body"><span>فعالسازی بررسی با هوش مصنوعی قبل از انتشار</span>
|
||||
<span class="t-hint">هر آگهی پیش از انتشار توسط مدل بررسی و تأیید/رد/ساختارمند میشود.</span></span>
|
||||
</label>
|
||||
<div class="filter-group">
|
||||
<label>آدرس سرویس (سازگار با OpenAI)</label>
|
||||
<input type="text" name="AiEndpoint" value="@Model.AiEndpoint" placeholder="https://host/v1/chat/completions" dir="ltr" />
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">میتواند یک مدل self-hosted یا سرویس داخلی باشد (OpenAI/Anthropic در ایران مسدودند).</p>
|
||||
</div>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;"><label>کلید API</label><input type="password" name="AiApiKey" value="@Model.AiApiKey" dir="ltr" /></div>
|
||||
<div style="flex:1;"><label>نام مدل</label><input type="text" name="AiModel" value="@Model.AiModel" dir="ltr" /></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>دستور و چارچوب هوش مصنوعی (Prompt / Framework) — ثابت 🔒</label>
|
||||
<textarea rows="14" dir="rtl" readonly
|
||||
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>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="AiAutoApprove" value="true" checked="@Model.AiAutoApprove" />
|
||||
<span class="t-body"><span>در حالت خودکار، آگهیهایی که AI تأیید میکند مستقیم منتشر شوند</span></span>
|
||||
</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>
|
||||
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||
<!-- SOURCES -->
|
||||
<section class="card card-pad settings-panel" data-panel="sources">
|
||||
<h3>منابع جمعآوری</h3>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="AutoIngestEnabled" value="true" checked="@Model.AutoIngestEnabled" />
|
||||
<span class="t-body"><span>اجرای خودکار جمعآوری روی زمانبند</span></span>
|
||||
</label>
|
||||
<div class="filter-group">
|
||||
<label>فاصله اجرای خودکار (دقیقه)</label>
|
||||
<input type="number" name="IngestIntervalMinutes" min="1" value="@Model.IngestIntervalMinutes" dir="ltr" />
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:0;">منابع جمعآوری (اسکرپ کانالها)</h3>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="AutoIngestEnabled" value="true" style="width:auto;" checked="@Model.AutoIngestEnabled" />
|
||||
اجرای خودکار جمعآوری روی زمانبند
|
||||
</label>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>فاصله اجرای خودکار (دقیقه)</label>
|
||||
<input type="number" name="IngestIntervalMinutes" min="1" value="@Model.IngestIntervalMinutes" dir="ltr" />
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px; margin:0 0 4px;">هر منبع را جداگانه روشن/خاموش و تنظیم کن.</p>
|
||||
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="TelegramEnabled" value="true" style="width:auto;" checked="@Model.TelegramEnabled" />
|
||||
تلگرام (کانالهای عمومی — بدون توکن)
|
||||
</label>
|
||||
<label style="margin-top:6px;">یوزرنیم کانالها (هر خط یک کانال)</label>
|
||||
<textarea name="TelegramChannels" rows="3" dir="ltr" placeholder="shift_channel another_channel">@Model.TelegramChannels</textarea>
|
||||
</div>
|
||||
<div class="source-box">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="TelegramEnabled" value="true" checked="@Model.TelegramEnabled" />
|
||||
<span class="t-body"><span>📨 تلگرام</span><span class="t-hint">کانالهای عمومی — بدون توکن.</span></span>
|
||||
</label>
|
||||
<div class="filter-group">
|
||||
<label>یوزرنیم کانالها (هر خط یک کانال)</label>
|
||||
<textarea name="TelegramChannels" rows="3" dir="ltr" placeholder="shift_channel another_channel">@Model.TelegramChannels</textarea>
|
||||
<label class="proxy-toggle"><input type="checkbox" name="TelegramUseProxy" value="true" checked="@Model.TelegramUseProxy" /> از پروکسی استفاده شود</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="BaleEnabled" value="true" style="width:auto;" checked="@Model.BaleEnabled" />
|
||||
بله (بات باید عضو کانال باشد)
|
||||
</label>
|
||||
<label style="margin-top:6px;">توکن بات بله</label>
|
||||
<input type="password" name="BaleBotToken" value="@Model.BaleBotToken" dir="ltr" />
|
||||
</div>
|
||||
<div class="source-box">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="BaleEnabled" value="true" checked="@Model.BaleEnabled" />
|
||||
<span class="t-body"><span>💬 بله</span><span class="t-hint">بات باید عضو کانال باشد.</span></span>
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="DivarEnabled" value="true" style="width:auto;" checked="@Model.DivarEnabled" />
|
||||
دیوار
|
||||
</label>
|
||||
<div style="display:flex; gap:8px; margin-top:6px;">
|
||||
<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 class="source-box">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="DivarEnabled" value="true" checked="@Model.DivarEnabled" />
|
||||
<span class="t-body"><span>🟥 دیوار</span></span>
|
||||
</label>
|
||||
<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:1;"><label>عبارتهای جستجو (هر خط یکی)</label><textarea name="DivarQueries" rows="3">@Model.DivarQueries</textarea></div>
|
||||
</div>
|
||||
<label class="proxy-toggle"><input type="checkbox" name="DivarUseProxy" value="true" checked="@Model.DivarUseProxy" /> از پروکسی استفاده شود</label>
|
||||
</div>
|
||||
|
||||
<div class="source-box">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="MedjobsEnabled" value="true" checked="@Model.MedjobsEnabled" />
|
||||
<span class="t-body"><span>🩺 مدجابز (medjobs.ir)</span><span class="t-hint">خواندن کامل آگهیها از سایتمپ + استخراج شماره.</span></span>
|
||||
</label>
|
||||
<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>
|
||||
</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">
|
||||
<input type="checkbox" name="WebsitesEnabled" value="true" checked="@Model.WebsitesEnabled" />
|
||||
<span class="t-body"><span>🌐 وبسایتها</span><span class="t-hint">آدرسهای دلخواه.</span></span>
|
||||
</label>
|
||||
<div class="filter-group">
|
||||
<label>آدرس صفحهها (هر خط یک URL)</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="source-box">
|
||||
<h4 style="margin:0 0 8px;">🛡️ پروکسی (Xray/V2Ray)</h4>
|
||||
<div class="filter-group">
|
||||
<label>آدرس پروکسی محلی</label>
|
||||
<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>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<!-- CHANNELS -->
|
||||
<section class="card card-pad settings-panel" data-panel="channels">
|
||||
<h3>کانالهای اعلان (فعال / غیرفعال)</h3>
|
||||
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموشکردن هر کانال ارسال اعلان به کاربران. کلیدها در تبهای «پیامک» و «پوش مرورگری».</p>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="WebNotificationsEnabled" value="true" checked="@Model.WebNotificationsEnabled" />
|
||||
<span class="t-body"><span>اعلانهای وب / درونبرنامهای (زنگوله + نوتیف زنده)</span>
|
||||
<span class="t-hint">توصیهشده برای ایران — از سرور خودمان، بدون سرویس گوگل.</span></span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="SmsEnabled" value="true" checked="@Model.SmsEnabled" />
|
||||
<span class="t-body"><span>پیامک (SMS) — کاوهنگار</span>
|
||||
<span class="t-hint">برای کد ورود و اعلانهای مهم.</span></span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="PushEnabled" value="true" checked="@Model.PushEnabled" />
|
||||
<span class="t-body"><span>پوش مرورگر (Web Push)</span>
|
||||
<span class="t-hint">برای اعلان هنگام بستهبودن برنامه؛ از سرویس مرورگر (گوگل) عبور میکند و در ایران اغلب فیلتر است.</span></span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<!-- SMS -->
|
||||
<section class="card card-pad settings-panel" data-panel="sms">
|
||||
<h3>پیامک ورود (OTP) — کاوهنگار</h3>
|
||||
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموشکردن این کانال در تب «کانالهای اعلان». (در صورت خاموش بودن، کد ورود روی صفحه نمایش داده میشود.)</p>
|
||||
<div class="filter-group"><label>کلید API کاوهنگار</label><input type="password" name="SmsApiKey" value="@Model.SmsApiKey" dir="ltr" /></div>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;"><label>نام تمپلیت verify (ترجیحی)</label><input type="text" name="SmsTemplate" value="@Model.SmsTemplate" dir="ltr" placeholder="otp" /></div>
|
||||
<div style="flex:1;"><label>خط ارسال (در نبود تمپلیت)</label><input type="text" name="SmsSender" value="@Model.SmsSender" dir="ltr" placeholder="10008..." /></div>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;">روش پیشنهادی: تمپلیت verify/lookup با متغیر %token. اگر تمپلیت خالی باشد، پیامک ساده با خط ارسال فرستاده میشود.</p>
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:14px 0;" />
|
||||
<div class="filter-group" style="display:flex; gap:8px; align-items:end; flex-wrap:wrap;">
|
||||
<div style="flex:1; min-width:160px;"><label>ارسال پیامک آزمایشی به</label><input type="tel" name="TestPhone" dir="ltr" placeholder="۰۹۱۲ ..." /></div>
|
||||
<button type="submit" asp-page-handler="TestSms" class="btn btn-outline">ارسال آزمایشی</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PUSH -->
|
||||
<section class="card card-pad settings-panel" data-panel="push">
|
||||
<h3>اعلانها (Web Push / PWA)</h3>
|
||||
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموشکردن این کانال در تب «کانالهای اعلان». اینجا فقط کلیدهای VAPID را وارد کن.</p>
|
||||
<div class="filter-group"><label>VAPID Public Key</label><input type="text" name="VapidPublicKey" value="@Model.VapidPublicKey" dir="ltr" /></div>
|
||||
<div class="filter-group"><label>VAPID Private Key</label><input type="password" name="VapidPrivateKey" value="@Model.VapidPrivateKey" dir="ltr" /></div>
|
||||
<div class="filter-group"><label>VAPID Subject</label><input type="text" name="VapidSubject" value="@Model.VapidSubject" dir="ltr" placeholder="mailto:admin@@hamkadr.ir" /></div>
|
||||
<p class="muted" style="font-size:12px;">جفتکلید VAPID را یکبار بساز (web-push). بدون آن، اعلان محلی روی دستگاه کار میکند ولی ارسال از سرور نیاز به کلید دارد.</p>
|
||||
</section>
|
||||
|
||||
<!-- MAP -->
|
||||
<section class="card card-pad settings-panel" data-panel="map">
|
||||
<h3>نقشه (نشان)</h3>
|
||||
<div class="filter-group">
|
||||
<label>کلید API نقشه نشان (web map.js)</label>
|
||||
<input type="text" name="NeshanMapKey" value="@Model.NeshanMapKey" dir="ltr" placeholder="web.xxxxx" />
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای انتخاب موقعیت روی نقشه در فرم ثبت مرکز. بدون کلید، فقط دکمه «موقعیت فعلی من» نمایش داده میشود.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DEMO -->
|
||||
<section class="card card-pad settings-panel" data-panel="demo">
|
||||
<h3>حالت نمایشی (Demo)</h3>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" name="DemoMode" value="true" checked="@Model.DemoMode" />
|
||||
<span class="t-body"><span>حالت نمایشی فعال باشد</span>
|
||||
<span class="t-hint">دادههای نمونه پس از هر استقرار بهصورت خودکار ساخته شوند.</span></span>
|
||||
</label>
|
||||
<p class="muted" style="font-size:13px;">ساخت/حذف فوری دادههای نمونهی تهران:</p>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||
<button type="submit" asp-page-handler="SeedDemo" class="btn btn-outline">ساخت داده نمونه</button>
|
||||
<button type="submit" asp-page-handler="ClearDemo" class="btn btn-outline" style="color:var(--danger); border-color:var(--danger);" onclick="return confirm('همه دادههای نمونه حذف شوند؟');">حذف داده نمونه</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="settings-save">
|
||||
<button type="submit" class="btn btn-accent btn-block btn-lg">ذخیره تنظیمات</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="MedjobsEnabled" value="true" style="width:auto;" checked="@Model.MedjobsEnabled" />
|
||||
مدجابز (medjobs.ir) — خواندن کامل آگهیها از سایتمپ
|
||||
</label>
|
||||
<label style="margin-top:6px;">حداکثر آگهی در هر اجرا</label>
|
||||
<input type="number" name="MedjobsMaxAds" min="1" max="500" value="@Model.MedjobsMaxAds" dir="ltr" />
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">آگهیهای تکراری بهصورت خودکار رد میشوند؛ هر اجرا فقط آگهیهای جدید را میآورد.</p>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="WebsitesEnabled" value="true" style="width:auto;" checked="@Model.WebsitesEnabled" />
|
||||
وبسایتها (آدرسهای دلخواه)
|
||||
</label>
|
||||
<label style="margin-top:6px;">آدرس صفحهها (هر خط یک URL)</label>
|
||||
<textarea name="WebsiteUrls" rows="3" dir="ltr" placeholder="https://example.ir/jobs/123">@Model.WebsiteUrls</textarea>
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">موتور هر آدرس را میخواند و متن آگهی را استخراج میکند (عنوان og + بدنه محتوا). برای هر صفحه شغلی، آرشیو کانال یا آگهی طبقهبندی.</p>
|
||||
</div>
|
||||
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||
|
||||
<h3 style="margin-top:0;">حالت نمایشی (Demo)</h3>
|
||||
<div class="filter-group">
|
||||
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
|
||||
<input type="checkbox" name="DemoMode" value="true" style="width:auto;" checked="@Model.DemoMode" />
|
||||
حالت نمایشی فعال باشد — دادههای نمونه پس از هر استقرار بهصورت خودکار ساخته شوند
|
||||
</label>
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای ساخت/حذف فوری دادههای نمونه از کارت بالای همین صفحه استفاده کن.</p>
|
||||
</div>
|
||||
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||
|
||||
<h3 style="margin-top:0;">پیامک ورود (OTP) — کاوهنگار</h3>
|
||||
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموشکردن این کانال در بخش «کانالهای اعلان» بالا. (در صورت خاموش بودن، کد ورود روی صفحه نمایش داده میشود.)</p>
|
||||
<div class="filter-group">
|
||||
<label>کلید API کاوهنگار</label>
|
||||
<input type="password" name="SmsApiKey" value="@Model.SmsApiKey" dir="ltr" />
|
||||
</div>
|
||||
<div class="filter-group" style="display:flex; gap:8px;">
|
||||
<div style="flex:1;"><label>نام تمپلیت verify (ترجیحی)</label><input type="text" name="SmsTemplate" value="@Model.SmsTemplate" dir="ltr" placeholder="otp" /></div>
|
||||
<div style="flex:1;"><label>خط ارسال (در نبود تمپلیت)</label><input type="text" name="SmsSender" value="@Model.SmsSender" dir="ltr" placeholder="10008..." /></div>
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;">روش پیشنهادی: تمپلیت verify/lookup با متغیر %token. اگر تمپلیت خالی باشد، پیامک ساده با خط ارسال فرستاده میشود.</p>
|
||||
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||
<h3 style="margin-top:0;">نقشه (نشان)</h3>
|
||||
<div class="filter-group">
|
||||
<label>کلید API نقشه نشان (web map.js)</label>
|
||||
<input type="text" name="NeshanMapKey" value="@Model.NeshanMapKey" dir="ltr" placeholder="web.xxxxx" />
|
||||
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای انتخاب موقعیت روی نقشه در فرم ثبت مرکز. بدون کلید، فقط دکمه «موقعیت فعلی من» نمایش داده میشود.</p>
|
||||
</div>
|
||||
|
||||
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
|
||||
<h3 style="margin-top:0;">اعلانها (Web Push / PWA)</h3>
|
||||
<p class="muted" style="font-size:12px; margin-top:0;">روشن/خاموشکردن این کانال در بخش «کانالهای اعلان» بالا. اینجا فقط کلیدهای VAPID را وارد کن.</p>
|
||||
<div class="filter-group">
|
||||
<label>VAPID Public Key</label>
|
||||
<input type="text" name="VapidPublicKey" value="@Model.VapidPublicKey" dir="ltr" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>VAPID Private Key</label>
|
||||
<input type="password" name="VapidPrivateKey" value="@Model.VapidPrivateKey" dir="ltr" />
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>VAPID Subject</label>
|
||||
<input type="text" name="VapidSubject" value="@Model.VapidSubject" dir="ltr" placeholder="mailto:admin@hamkadr.ir" />
|
||||
</div>
|
||||
<p class="muted" style="font-size:12px;">جفتکلید VAPID را یکبار بساز (web-push). بدون آن، اعلان محلی روی دستگاه کار میکند ولی ارسال از سرور نیاز به کلید دارد.</p>
|
||||
|
||||
<button type="submit" class="btn btn-accent btn-block btn-lg">ذخیره تنظیمات</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
(function () {
|
||||
var tabs = document.querySelectorAll('#settingsTabs button');
|
||||
var panels = document.querySelectorAll('.settings-panel');
|
||||
function show(name) {
|
||||
tabs.forEach(function (t) { t.classList.toggle('active', t.dataset.tab === name); });
|
||||
panels.forEach(function (p) { p.classList.toggle('active', p.dataset.panel === name); });
|
||||
try { sessionStorage.setItem('adminTab', name); } catch (e) {}
|
||||
}
|
||||
tabs.forEach(function (t) { t.addEventListener('click', function () { show(t.dataset.tab); }); });
|
||||
var saved; try { saved = sessionStorage.getItem('adminTab'); } catch (e) {}
|
||||
if (saved && document.querySelector('[data-panel="' + saved + '"]')) show(saved);
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
|
||||
@@ -14,11 +14,16 @@ public class SettingsModel : PageModel
|
||||
private readonly SettingsService _settings;
|
||||
private readonly ISmsSender _sms;
|
||||
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;
|
||||
_sms = sms;
|
||||
_db = db;
|
||||
_clients = clients;
|
||||
_ai = ai;
|
||||
}
|
||||
|
||||
[BindProperty] public IngestionMode Mode { get; set; }
|
||||
@@ -27,8 +32,9 @@ public class SettingsModel : PageModel
|
||||
[BindProperty] public string? AiEndpoint { get; set; }
|
||||
[BindProperty] public string? AiApiKey { 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 AiUseProxy { get; set; }
|
||||
// Channel scraping sources
|
||||
[BindProperty] public bool AutoIngestEnabled { get; set; }
|
||||
[BindProperty] public int IngestIntervalMinutes { get; set; } = 30;
|
||||
@@ -41,6 +47,12 @@ public class SettingsModel : PageModel
|
||||
[BindProperty] public string? DivarQueries { get; set; }
|
||||
[BindProperty] public bool MedjobsEnabled { get; set; }
|
||||
[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 string? SmsApiKey { get; set; }
|
||||
[BindProperty] public string? SmsTemplate { get; set; }
|
||||
@@ -55,9 +67,17 @@ public class SettingsModel : PageModel
|
||||
[BindProperty] public bool DemoMode { get; set; }
|
||||
[BindProperty] public bool WebsitesEnabled { get; set; }
|
||||
[BindProperty] public string? WebsiteUrls { get; set; }
|
||||
[BindProperty] public string? IngestProxyUrl { get; set; }
|
||||
[BindProperty] public bool TelegramUseProxy { get; set; }
|
||||
[BindProperty] public bool BaleUseProxy { get; set; }
|
||||
[BindProperty] public bool DivarUseProxy { get; set; }
|
||||
[BindProperty] public bool MedjobsUseProxy { get; set; }
|
||||
[BindProperty] public bool WebsitesUseProxy { get; set; }
|
||||
[TempData] public string? Saved { get; set; }
|
||||
[TempData] public string? SmsTest { get; set; }
|
||||
[TempData] public string? DemoMsg { get; set; }
|
||||
[TempData] public string? ProxyTest { get; set; }
|
||||
[TempData] public string? AiTest { get; set; }
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
@@ -68,8 +88,8 @@ public class SettingsModel : PageModel
|
||||
AiEndpoint = s.AiEndpoint;
|
||||
AiApiKey = s.AiApiKey;
|
||||
AiModel = s.AiModel;
|
||||
AiSystemPrompt = s.AiSystemPrompt;
|
||||
AiAutoApprove = s.AiAutoApprove;
|
||||
AiUseProxy = s.AiUseProxy;
|
||||
AutoIngestEnabled = s.AutoIngestEnabled;
|
||||
IngestIntervalMinutes = s.IngestIntervalMinutes;
|
||||
TelegramEnabled = s.TelegramEnabled;
|
||||
@@ -81,6 +101,12 @@ public class SettingsModel : PageModel
|
||||
DivarQueries = s.DivarQueries;
|
||||
MedjobsEnabled = s.MedjobsEnabled;
|
||||
MedjobsMaxAds = s.MedjobsMaxAds;
|
||||
IranEstekhdamEnabled = s.IranEstekhdamEnabled;
|
||||
IranEstekhdamMaxAds = s.IranEstekhdamMaxAds;
|
||||
IranEstekhdamUseProxy = s.IranEstekhdamUseProxy;
|
||||
MedboomEnabled = s.MedboomEnabled;
|
||||
MedboomMaxAds = s.MedboomMaxAds;
|
||||
MedboomUseProxy = s.MedboomUseProxy;
|
||||
SmsEnabled = s.SmsEnabled;
|
||||
SmsApiKey = s.SmsApiKey;
|
||||
SmsTemplate = s.SmsTemplate;
|
||||
@@ -89,6 +115,12 @@ public class SettingsModel : PageModel
|
||||
DemoMode = s.DemoMode;
|
||||
WebsitesEnabled = s.WebsitesEnabled;
|
||||
WebsiteUrls = s.WebsiteUrls;
|
||||
IngestProxyUrl = s.IngestProxyUrl;
|
||||
TelegramUseProxy = s.TelegramUseProxy;
|
||||
BaleUseProxy = s.BaleUseProxy;
|
||||
DivarUseProxy = s.DivarUseProxy;
|
||||
MedjobsUseProxy = s.MedjobsUseProxy;
|
||||
WebsitesUseProxy = s.WebsitesUseProxy;
|
||||
WebNotificationsEnabled = s.WebNotificationsEnabled;
|
||||
PushEnabled = s.PushEnabled;
|
||||
VapidPublicKey = s.VapidPublicKey;
|
||||
@@ -106,8 +138,9 @@ public class SettingsModel : PageModel
|
||||
AiEndpoint = AiEndpoint,
|
||||
AiApiKey = AiApiKey,
|
||||
AiModel = AiModel,
|
||||
AiSystemPrompt = AiSystemPrompt,
|
||||
// AiSystemPrompt intentionally omitted — AppSetting defaults it to DefaultPrompt (hardcoded).
|
||||
AiAutoApprove = AiAutoApprove,
|
||||
AiUseProxy = AiUseProxy,
|
||||
AutoIngestEnabled = AutoIngestEnabled,
|
||||
IngestIntervalMinutes = IngestIntervalMinutes,
|
||||
TelegramEnabled = TelegramEnabled,
|
||||
@@ -119,6 +152,12 @@ public class SettingsModel : PageModel
|
||||
DivarQueries = DivarQueries,
|
||||
MedjobsEnabled = MedjobsEnabled,
|
||||
MedjobsMaxAds = MedjobsMaxAds,
|
||||
IranEstekhdamEnabled = IranEstekhdamEnabled,
|
||||
IranEstekhdamMaxAds = IranEstekhdamMaxAds,
|
||||
IranEstekhdamUseProxy = IranEstekhdamUseProxy,
|
||||
MedboomEnabled = MedboomEnabled,
|
||||
MedboomMaxAds = MedboomMaxAds,
|
||||
MedboomUseProxy = MedboomUseProxy,
|
||||
SmsEnabled = SmsEnabled,
|
||||
SmsApiKey = SmsApiKey,
|
||||
SmsTemplate = SmsTemplate,
|
||||
@@ -127,6 +166,12 @@ public class SettingsModel : PageModel
|
||||
DemoMode = DemoMode,
|
||||
WebsitesEnabled = WebsitesEnabled,
|
||||
WebsiteUrls = WebsiteUrls,
|
||||
IngestProxyUrl = IngestProxyUrl,
|
||||
TelegramUseProxy = TelegramUseProxy,
|
||||
BaleUseProxy = BaleUseProxy,
|
||||
DivarUseProxy = DivarUseProxy,
|
||||
MedjobsUseProxy = MedjobsUseProxy,
|
||||
WebsitesUseProxy = WebsitesUseProxy,
|
||||
WebNotificationsEnabled = WebNotificationsEnabled,
|
||||
PushEnabled = PushEnabled,
|
||||
VapidPublicKey = VapidPublicKey,
|
||||
@@ -151,6 +196,45 @@ public class SettingsModel : PageModel
|
||||
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()
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
<ul style="margin:6px 0 0; padding-inline-start:18px; font-size:13.5px;">
|
||||
@foreach (var a in row.Applicants)
|
||||
{
|
||||
<li>@(a.Name ?? "کاربر") — <span dir="ltr">@JalaliDate.ToPersianDigits(a.Phone)</span></li>
|
||||
<partial name="_ApplicantRow" model="a" />
|
||||
}
|
||||
@if (row.Guests > 0)
|
||||
{
|
||||
@@ -102,7 +102,7 @@
|
||||
<ul style="margin:6px 0 0; padding-inline-start:18px; font-size:13.5px;">
|
||||
@foreach (var a in row.Applicants)
|
||||
{
|
||||
<li>@(a.Name ?? "کاربر") — <span dir="ltr">@JalaliDate.ToPersianDigits(a.Phone)</span></li>
|
||||
<partial name="_ApplicantRow" model="a" />
|
||||
}
|
||||
@if (row.Guests > 0)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
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;
|
||||
@@ -12,9 +13,15 @@ namespace JobsMedical.Web.Pages.Employer;
|
||||
public class ListingsModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public ListingsModel(AppDbContext db) => _db = db;
|
||||
private readonly NotificationService _notify;
|
||||
public ListingsModel(AppDbContext db, NotificationService notify)
|
||||
{
|
||||
_db = db;
|
||||
_notify = notify;
|
||||
}
|
||||
|
||||
public record Applicant(string? Name, string Phone, DateTime When);
|
||||
public record Applicant(string? Name, string Phone, DateTime When, long EventId, int UserId,
|
||||
ApplicationStatus Status, bool HasAvatar, bool HasResume);
|
||||
public record ShiftRow(Shift Shift, List<Applicant> Applicants, int Guests);
|
||||
public record JobRow(JobOpening Job, List<Applicant> Applicants, int Guests);
|
||||
|
||||
@@ -57,6 +64,37 @@ public class ListingsModel : PageModel
|
||||
return RedirectToPage(new { FacilityId = j.FacilityId });
|
||||
}
|
||||
|
||||
// --- Applicant decisions ---
|
||||
public Task<IActionResult> OnPostAcceptAsync(long eventId) => SetStatus(eventId, ApplicationStatus.Accepted, true);
|
||||
public Task<IActionResult> OnPostRejectAsync(long eventId) => SetStatus(eventId, ApplicationStatus.Rejected, false);
|
||||
|
||||
private async Task<IActionResult> SetStatus(long eventId, ApplicationStatus status, bool accepted)
|
||||
{
|
||||
var ev = await _db.InterestEvents
|
||||
.Include(e => e.Shift).ThenInclude(s => s!.Facility)
|
||||
.Include(e => e.Shift).ThenInclude(s => s!.Role)
|
||||
.Include(e => e.JobOpening).ThenInclude(j => j!.Facility)
|
||||
.FirstOrDefaultAsync(e => e.Id == eventId && e.EventType == InterestEventType.Apply);
|
||||
if (ev is null) return NotFound();
|
||||
|
||||
var facilityId = ev.Shift?.FacilityId ?? ev.JobOpening?.FacilityId ?? 0;
|
||||
if (!await OwnsAsync(facilityId)) return Forbid();
|
||||
|
||||
ev.Status = status;
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Notify the applicant (only when they're a registered user we can reach).
|
||||
var applicantId = await _db.Visitors.Where(v => v.Id == ev.VisitorId).Select(v => v.UserId).FirstOrDefaultAsync();
|
||||
if (applicantId is int uid)
|
||||
{
|
||||
var (title, url) = ev.JobOpening is not null
|
||||
? (ev.JobOpening.Title, $"/Jobs/Details/{ev.JobOpeningId}")
|
||||
: ($"شیفت {ev.Shift?.Role?.Name} — {ev.Shift?.Facility?.Name}", $"/Shifts/Details/{ev.ShiftId}");
|
||||
await _notify.NotifyApplicantStatusAsync(uid, title, accepted, url);
|
||||
}
|
||||
return RedirectToPage(new { FacilityId = facilityId });
|
||||
}
|
||||
|
||||
private async Task<bool> OwnsAsync(int facilityId)
|
||||
{
|
||||
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
@@ -87,7 +125,9 @@ public class ListingsModel : PageModel
|
||||
var visitorUser = await _db.Visitors.Where(v => visitorIds.Contains(v.Id))
|
||||
.ToDictionaryAsync(v => v.Id, v => v.UserId);
|
||||
var userIds = visitorUser.Values.Where(u => u != null).Select(u => u!.Value).Distinct().ToList();
|
||||
var users = await _db.Users.Where(u => userIds.Contains(u.Id)).ToDictionaryAsync(u => u.Id);
|
||||
var users = await _db.Users.Where(u => userIds.Contains(u.Id))
|
||||
.Select(u => new { u.Id, u.FullName, u.Phone, HasAvatar = u.Avatar != null, HasResume = u.Resume != null })
|
||||
.ToDictionaryAsync(u => u.Id);
|
||||
|
||||
(List<Applicant> applicants, int guests) Resolve(IEnumerable<InterestEvent> evs)
|
||||
{
|
||||
@@ -99,7 +139,7 @@ public class ListingsModel : PageModel
|
||||
var uid = visitorUser.GetValueOrDefault(e.VisitorId);
|
||||
if (uid is int id && users.TryGetValue(id, out var u))
|
||||
{
|
||||
if (seen.Add(id)) applicants.Add(new Applicant(u.FullName, u.Phone, e.CreatedAt));
|
||||
if (seen.Add(id)) applicants.Add(new Applicant(u.FullName, u.Phone, e.CreatedAt, e.Id, id, e.Status, u.HasAvatar, u.HasResume));
|
||||
}
|
||||
else guests++;
|
||||
}
|
||||
|
||||
@@ -88,8 +88,8 @@
|
||||
@section Scripts {
|
||||
@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" />
|
||||
<script src="https://static.neshan.org/sdk/leaflet/1.4.0/neshan-sdk/v1.0.8/index.js"></script>
|
||||
<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/leaflet.js"></script>
|
||||
}
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
@model JobsMedical.Web.Pages.Employer.ListingsModel.Applicant
|
||||
@{
|
||||
var s = Model.Status;
|
||||
var nm = (Model.Name ?? Model.Phone).Trim();
|
||||
var initial = nm.Length > 0 ? nm.Substring(0, 1) : "؟";
|
||||
}
|
||||
<li class="applicant-row">
|
||||
<span class="avatar-sm">
|
||||
@if (Model.HasAvatar)
|
||||
{
|
||||
<img src="/avatar/@Model.UserId" alt="" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="avatar-fallback">@initial</span>
|
||||
}
|
||||
</span>
|
||||
<span class="applicant-info">
|
||||
<span>@(Model.Name ?? "کاربر") — <span dir="ltr">@JalaliDate.ToPersianDigits(Model.Phone)</span></span>
|
||||
@if (Model.HasResume)
|
||||
{
|
||||
<a href="/resume/@Model.UserId" target="_blank" class="resume-link">📎 مشاهده رزومه</a>
|
||||
}
|
||||
</span>
|
||||
<span class="applicant-actions">
|
||||
@if (s == JobsMedical.Web.Models.ApplicationStatus.Accepted)
|
||||
{
|
||||
<span class="badge badge-verified">✓ پذیرفته شد</span>
|
||||
}
|
||||
else if (s == JobsMedical.Web.Models.ApplicationStatus.Rejected)
|
||||
{
|
||||
<span class="badge badge-gender">رد شد</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form method="post" asp-page-handler="Accept" asp-route-eventId="@Model.EventId" style="display:inline;">
|
||||
<button type="submit" class="btn btn-accent" style="padding:3px 12px; font-size:12px;">پذیرفتن</button>
|
||||
</form>
|
||||
<form method="post" asp-page-handler="Reject" asp-route-eventId="@Model.EventId" style="display:inline;">
|
||||
<button type="submit" class="btn btn-outline" style="padding:3px 12px; font-size:12px; color:var(--danger); border-color:var(--danger);">رد</button>
|
||||
</form>
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
@@ -14,6 +14,15 @@
|
||||
</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>
|
||||
<p>
|
||||
Swapping to the <strong>Development</strong> environment displays detailed information about the error that occurred.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
@@ -12,9 +13,24 @@ public class ErrorModel : PageModel
|
||||
|
||||
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()
|
||||
{
|
||||
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">
|
||||
@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;">
|
||||
<span class="facility" style="font-weight:800; font-size:16px;">@row.Facility.Name</span>
|
||||
@if (row.Facility.IsVerified)
|
||||
@@ -32,12 +32,11 @@
|
||||
</p>
|
||||
<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;">
|
||||
@JalaliDate.ToPersianDigits(row.OpenShifts.ToString()) شیفت باز
|
||||
@JalaliDate.ToPersianDigits(row.OpenListings.ToString()) آگهی فعال
|
||||
</span>
|
||||
<a class="btn btn-outline" style="padding:6px 14px;"
|
||||
asp-page="/Calendar/Index" asp-route-FacilityId="@row.Facility.Id">تقویم</a>
|
||||
<span class="btn btn-outline" style="padding:6px 14px;">مشاهده مرکز</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,21 +10,36 @@ public class IndexModel : PageModel
|
||||
private readonly AppDbContext _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();
|
||||
|
||||
// The shared placeholder for unnamed aggregated ads is not a real, browseable facility.
|
||||
private const string PlaceholderName = "نامشخص / ثبت نشده";
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var facilities = await _db.Facilities.Include(f => f.City).OrderBy(f => f.Name).ToListAsync();
|
||||
var counts = await _db.Shifts
|
||||
var jobCutoff = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc;
|
||||
|
||||
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)
|
||||
.GroupBy(s => s.FacilityId)
|
||||
.Select(g => new { g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||
.GroupBy(s => s.FacilityId).Select(g => new { g.Key, C = g.Count() })
|
||||
.ToDictionaryAsync(x => x.Key, x => x.C);
|
||||
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
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
@page
|
||||
@{
|
||||
ViewData["Title"] = "راهنما و آموزش";
|
||||
ViewData["Description"] = "راهنمای کامل استفاده از همکادر برای کادر درمان و مراکز درمانی: جستجوی شیفت و استخدام، اعلام تمایل، انتشار آگهی، تأیید مرکز و اعلانها.";
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>راهنما و آموزش</h1>
|
||||
<p class="muted">یاد بگیر چطور از همکادر بیشترین استفاده را ببری — برای کادر درمان و مراکز درمانی.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container section" style="max-width:820px;">
|
||||
|
||||
<div class="rec-banner" style="margin-bottom:18px;">
|
||||
<div>
|
||||
<h2 style="margin:0 0 4px;">تور تعاملی برنامه</h2>
|
||||
<span style="opacity:.9; font-size:14px;">در چند ثانیه بخشهای اصلی را روی همین صفحه نشانت میدهیم.</span>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-outline" onclick="window.hamkadrTour && window.hamkadrTour.start()">▶ شروع تور راهنما</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="margin:6px 0 12px;">امکانات همکادر</h2>
|
||||
<div class="grid grid-3" style="margin-bottom:18px;">
|
||||
<div class="card card-pad">
|
||||
<div style="font-size:26px;">🗓️</div>
|
||||
<strong>شیفت و استخدام</strong>
|
||||
<p class="muted" style="font-size:13px; margin:6px 0 0;">فرصتهای شیفت کاری و موقعیتهای استخدامی کادر درمان، یکجا.</p>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<div style="font-size:26px;">🎯</div>
|
||||
<strong>پیشنهاد هوشمند</strong>
|
||||
<p class="muted" style="font-size:13px; margin:6px 0 0;">بر اساس نقش، شهر و علاقهمندیات، بهترین فرصتها را میبینی.</p>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<div style="font-size:26px;">📍</div>
|
||||
<strong>نزدیک من</strong>
|
||||
<p class="muted" style="font-size:13px; margin:6px 0 0;">فرصتها را بر اساس فاصله از موقعیت فعلیات مرتب کن.</p>
|
||||
</div>
|
||||
<div class="card card-pad" style="border:1px solid var(--accent);">
|
||||
<div style="font-size:26px;">🔎</div>
|
||||
<strong>هشدار شغلی</strong>
|
||||
<p class="muted" style="font-size:13px; margin:6px 0 8px;">بگو دنبال چه فرصتی هستی؛ تا آگهی متناسب ثبت شد، فوری باخبر شو.</p>
|
||||
<a class="btn btn-accent" style="padding:6px 14px;" asp-page="/Me/Alerts">ساخت هشدار شغلی</a>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<div style="font-size:26px;">🔔</div>
|
||||
<strong>اعلان زنده</strong>
|
||||
<p class="muted" style="font-size:13px; margin:6px 0 0;">اعلان درونبرنامهای که در ایران بدون سرویسهای خارجی کار میکند.</p>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<div style="font-size:26px;">✓</div>
|
||||
<strong>مراکز تأییدشده</strong>
|
||||
<p class="muted" style="font-size:13px; margin:6px 0 0;">نشان «تأیید شده» روی مراکزی که مدارکشان بررسی شده است.</p>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<div style="font-size:26px;">📲</div>
|
||||
<strong>نصب بهصورت اپ (PWA)</strong>
|
||||
<p class="muted" style="font-size:13px; margin:6px 0 0;">روی اندروید، iOS، ویندوز و وب نصب میشود. <a asp-page="/Download">دریافت اپ</a></p>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<div style="font-size:26px;">🛡️</div>
|
||||
<strong>گزارش و شکایت</strong>
|
||||
<p class="muted" style="font-size:13px; margin:6px 0 0;">آگهی نادرست یا مرکز متخلف را گزارش کن تا بررسی شود.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad legal" style="margin-bottom:14px;">
|
||||
<h2>شروع سریع (۳ گام)</h2>
|
||||
<ol style="padding-inline-start:20px; line-height:2;">
|
||||
<li><strong>وارد شو</strong> — با شماره موبایل و کد پیامکی. هنگام ورود نوع حساب را انتخاب کن: «کادر درمان» یا «کارفرما / مرکز درمانی».</li>
|
||||
<li><strong>تنظیم کن</strong> — اگر کادر درمانی، در «علاقهمندیها» نقش و شهر را مشخص کن؛ اگر کارفرمایی، مرکزت را ثبت کن.</li>
|
||||
<li><strong>شروع کن</strong> — فرصتها را ببین و «اعلام تمایل» بده، یا آگهی منتشر کن.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad legal" style="margin-bottom:14px;">
|
||||
<h2>👩⚕️ برای کادر درمان (کارجو)</h2>
|
||||
<ul>
|
||||
<li><strong>یافتن شیفت/استخدام:</strong> از منو وارد «شیفتها» یا «استخدام» شو و با فیلترِ شهر، محله، نقش و نوع شیفت نتایج را محدود کن.</li>
|
||||
<li><strong>نزدیک من:</strong> با اجازهی دسترسی به موقعیت، نزدیکترین فرصتها بر اساس فاصله مرتب میشوند.</li>
|
||||
<li><strong>علاقهمندیها:</strong> نقش/شهر/نوع شیفت موردنظرت را ذخیره کن تا پیشنهادهای شخصیسازیشده و اعلانِ فرصتهای تازه بگیری.</li>
|
||||
<li><strong>اعلام تمایل:</strong> روی آگهی، دکمهی «اعلام تمایل و مشاهده راه ارتباطی» را بزن تا اطلاعات تماس مرکز نمایش داده شود.</li>
|
||||
<li><strong>ذخیره و حذف:</strong> فرصتهای جالب را ذخیره کن یا «علاقهمند نیستم» را بزن تا پیشنهادها دقیقتر شوند.</li>
|
||||
<li><strong>پنل کارجو:</strong> فرصتهای ذخیرهشده و فعالیتهایت را اینجا دنبال کن.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad legal" style="margin-bottom:14px;">
|
||||
<h2>🏥 برای مراکز درمانی (کارفرما)</h2>
|
||||
<ul>
|
||||
<li><strong>ثبت مرکز:</strong> از «پنل کارفرما» مرکزت را ثبت کن و موقعیت را روی نقشه یا با «موقعیت فعلی من» مشخص کن.</li>
|
||||
<li><strong>انتشار آگهی:</strong> شیفت یا موقعیت استخدامی منتشر کن؛ نوع پرداخت (مبلغ ثابت یا درصد سهم) و شرط جنسیت را تعیین کن.</li>
|
||||
<li><strong>تأیید مرکز:</strong> در پنل، «درخواست تأیید و بارگذاری مدارک» را بزن و مجوز/پروانه را آپلود کن. پس از بررسی، نشان «✓ تأیید شده» روی آگهیهایت نمایش داده میشود.</li>
|
||||
<li><strong>مدیریت متقاضیان:</strong> فهرست افرادی که «اعلام تمایل» کردهاند را با نام و شماره ببین و هماهنگ کن.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad legal" style="margin-bottom:14px;">
|
||||
<h2>🔔 اعلانها و نصب اپ</h2>
|
||||
<ul>
|
||||
<li>اعلانهای درونبرنامهای (زنگوله) بهصورت زنده کار میکند و در ایران بدون نیاز به سرویسهای خارجی در دسترس است.</li>
|
||||
<li>برای دریافت بهتر اعلانها، همکادر را بهصورت اپ نصب کن: <a asp-page="/Download">صفحهی دریافت اپلیکیشن</a> (اندروید، iOS، ویندوز، وب).</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad legal" style="margin-bottom:14px;">
|
||||
<h2>🛡️ گزارش و شکایت</h2>
|
||||
<p>
|
||||
اگر آگهی نادرست یا مرکز متخلفی دیدی، از دکمهی «گزارش تخلف» یا «شکایت از این مرکز» در صفحهی همان آگهی استفاده کن.
|
||||
تیم همکادر بررسی میکند. قوانین کامل را در <a asp-page="/Rules">قوانین و مقررات</a> ببین.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad legal">
|
||||
<h2>❓ سؤالات متداول</h2>
|
||||
|
||||
<details>
|
||||
<summary>ورود به همکادر هزینه دارد؟</summary>
|
||||
<p>خیر؛ استفاده برای کادر درمان و انتشار آگهی برای مراکز در حال حاضر رایگان است.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>کد ورود برایم نیامد، چه کنم؟</summary>
|
||||
<p>چند ثانیه صبر کن و «ارسال مجدد کد» را بزن. از درستبودن شماره موبایل مطمئن شو. اگر مشکل ادامه داشت، بعداً دوباره تلاش کن.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>«اعلام تمایل» یعنی چه؟</summary>
|
||||
<p>یعنی به آن فرصت علاقهمندی؛ با زدن آن، اطلاعات تماس مرکز برایت نمایش داده میشود و فرصت در سوابق تو ثبت میشود تا پیشنهادها دقیقتر شوند.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>نشان «✓ تأیید شده» چه معنایی دارد؟</summary>
|
||||
<p>یعنی مدارک آن مرکز توسط همکادر بررسی شده است. این نشان بررسی اولیه است و جای راستیآزمایی مستقیم پیش از توافق را نمیگیرد.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>چطور مرکزم را تأیید کنم؟</summary>
|
||||
<p>وارد «پنل کارفرما» شو، روی مرکز موردنظر «درخواست تأیید و بارگذاری مدارک» را بزن و مجوز/پروانه را آپلود کن تا بررسی شود.</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>چطور این تور را دوباره ببینم؟</summary>
|
||||
<p>دکمهی «▶ شروع تور راهنما» در بالای همین صفحه را بزن.</p>
|
||||
</details>
|
||||
|
||||
<p class="muted" style="font-size:13px; margin-top:14px; margin-bottom:0;">
|
||||
پاسخت را نیافتی؟ با ما در تماس باش: <span dir="ltr">support@@hamkadr.ir</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
@model IndexModel
|
||||
@{
|
||||
ViewData["Title"] = null; // use default site title for the home page (best for SEO)
|
||||
ViewData["Description"] = "همکادر؛ سریعترین راه برای کادر درمان (پزشک، پرستار، ماما، تکنسین) جهت یافتن شیفت و موقعیت استخدامی در بیمارستانها و کلینیکهای تهران. بهجای گشتن در کانالهای تلگرام و بله، همه فرصتها یکجا.";
|
||||
ViewData["Description"] = "یافتن شیفت و موقعیت استخدامی کادر درمان (پزشک، پرستار، ماما، تکنسین) در بیمارستانها و کلینیکهای تهران — همهٔ فرصتها یکجا در همکادر.";
|
||||
}
|
||||
|
||||
<section class="hero">
|
||||
@@ -14,106 +14,64 @@
|
||||
مرکز درمانی، محل و تقویم هفتگی — یکجا.
|
||||
</p>
|
||||
|
||||
<form class="search-card" method="get" asp-page="/Shifts/Index">
|
||||
<div class="field">
|
||||
<label>شهر</label>
|
||||
<select name="cityId">
|
||||
<option value="">همه شهرها</option>
|
||||
@foreach (var c in Model.Cities)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
<form class="hero-search" method="get" action="/Search" role="search" data-suggest>
|
||||
<div class="hero-search-pill">
|
||||
<span class="hs-ico">🔎</span>
|
||||
<input type="search" name="Q" autocomplete="off"
|
||||
placeholder="جستجو: پرستار، mmt، دندانپزشک…" />
|
||||
<button type="submit" class="btn btn-accent btn-lg hs-submit" aria-label="جستجو">
|
||||
<span class="hs-submit-txt">جستجو</span>
|
||||
<span class="hs-submit-ico" aria-hidden="true">🔎</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>نقش</label>
|
||||
<select name="roleId">
|
||||
<option value="">همه نقشها</option>
|
||||
@foreach (var r in Model.Roles)
|
||||
{
|
||||
<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> </label>
|
||||
<button type="submit" class="btn btn-accent btn-block btn-lg">جستجوی فرصتها</button>
|
||||
<div class="hero-chips">
|
||||
<span class="hc-label">جستجوهای پرطرفدار:</span>
|
||||
<a href="/Search?Q=%D9%BE%D8%B1%D8%B3%D8%AA%D8%A7%D8%B1">پرستار</a>
|
||||
<a href="/Search?Q=%D9%BE%D8%B2%D8%B4%DA%A9">پزشک</a>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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.CityCount.ToString())</span><span class="l">شهر فعال</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (Model.Recommendations.Count > 0)
|
||||
{
|
||||
<section class="section" style="padding-bottom:0;">
|
||||
<div class="container">
|
||||
@if (Model.HasPersonalization)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
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>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="section">
|
||||
<section class="section" style="padding-bottom:0;">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<h2>جدیدترین شیفتها</h2>
|
||||
<a asp-page="/Shifts/Index">مشاهده همه ←</a>
|
||||
</div>
|
||||
@if (Model.LatestShifts.Count == 0)
|
||||
{
|
||||
<div class="empty-state">فعلاً شیفت بازی ثبت نشده است.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-page="/Recommendations/Index" class="rec-banner" style="text-decoration:none; color:#fff;">
|
||||
<div>
|
||||
<h2 style="margin:0 0 4px;">✨ پیشنهادهای ویژه شما</h2>
|
||||
<span style="opacity:.9; font-size:14px;">فرصتهای متناسب با نقش، شهر و فعالیت شما — همه یکجا</span>
|
||||
</div>
|
||||
<span class="btn btn-outline">مشاهده پیشنهادها ←</span>
|
||||
</a>
|
||||
</div>
|
||||
</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">
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<h2>جدیدترین شیفتها</h2>
|
||||
<a href="/Shifts">مشاهده همه ←</a>
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var s in Model.LatestShifts)
|
||||
{
|
||||
<partial name="_ShiftCard" model="s" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (Model.LatestJobs.Count > 0)
|
||||
{
|
||||
@@ -121,7 +79,7 @@
|
||||
<div class="container">
|
||||
<div class="section-head">
|
||||
<h2>فرصتهای استخدامی</h2>
|
||||
<a asp-page="/Jobs/Index">مشاهده همه ←</a>
|
||||
<a href="/Jobs">مشاهده همه ←</a>
|
||||
</div>
|
||||
<div class="grid grid-3">
|
||||
@foreach (var j in Model.LatestJobs)
|
||||
@@ -133,6 +91,24 @@
|
||||
</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);">
|
||||
<div class="container">
|
||||
<div class="section-head"><h2>چطور کار میکند؟</h2></div>
|
||||
@@ -152,3 +128,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>")
|
||||
}
|
||||
|
||||
@@ -9,23 +9,19 @@ namespace JobsMedical.Web.Pages;
|
||||
public class IndexModel : PageModel
|
||||
{
|
||||
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;
|
||||
_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<JobOpening> LatestJobs { get; private set; } = new();
|
||||
public List<TalentListing> LatestTalent { get; private set; } = new();
|
||||
public List<City> Cities { get; private set; } = new();
|
||||
public List<Role> Roles { get; private set; } = new();
|
||||
public int OpenShiftCount { get; private set; }
|
||||
public int OpenJobCount { get; private set; }
|
||||
public int FacilityCount { get; private set; }
|
||||
public int CityCount { get; private set; }
|
||||
|
||||
@@ -33,11 +29,6 @@ public class IndexModel : PageModel
|
||||
{
|
||||
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
|
||||
.Include(s => s.Facility).ThenInclude(f => f.City)
|
||||
.Include(s => s.Role)
|
||||
@@ -56,9 +47,19 @@ public class IndexModel : PageModel
|
||||
.Take(3)
|
||||
.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();
|
||||
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);
|
||||
OpenJobCount = await _db.JobOpenings.CountAsync(j => j.Status == ShiftStatus.Open
|
||||
&& j.CreatedAt >= JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc);
|
||||
FacilityCount = await _db.Facilities.CountAsync();
|
||||
CityCount = await _db.Cities.CountAsync(c => c.IsActive);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user