diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0369333
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,23 @@
+# کپی کنید: cp .env.example .env — و همه مقادیر change-me را عوض کنید.
+# docker compose بهطور خودکار این فایل را میخواند؛ بدون آن استک بالا نمیآید.
+
+# --- رمزهای اصلی (اجباری) ---
+# تولید کلید تصادفی: python -c "import secrets; print(secrets.token_urlsafe(48))"
+SECRET_KEY=change-me-to-a-long-random-string
+POSTGRES_PASSWORD=change-me-db-password
+MINIO_ROOT_USER=aramesh
+MINIO_ROOT_PASSWORD=change-me-minio-password
+ADMIN_USERNAME=admin
+ADMIN_PASSWORD=change-me-admin-password
+
+# --- اختیاری ---
+# پورت ورود Traefik در توسعه (پیشفرض 5173)
+TRAEFIK_HTTP_PORT=5173
+# نشانی عمومی سایت برای sitemap و آدرس مطلق OG — در تولید مثل https://aramesh.example
+SITE_URL=
+# آدرسهای مجاز CORS (لیست JSON) — پیشفرض فقط لوکالهاست
+# CORS_ORIGINS=["http://localhost:5173"]
+
+# --- فقط برای docker-compose.prod.yml (حالت تولید با TLS) ---
+# DOMAIN=aramesh.example
+# ACME_EMAIL=admin@aramesh.example
diff --git a/.gitignore b/.gitignore
index 1c8491b..da36e75 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,9 @@ env/
# Environment
.env
+# پشتیبانگیری محلی
+backups/
+
# Node / Vite
node_modules/
dist/
@@ -23,4 +26,6 @@ Thumbs.db
# Database
*.sqlite3
*.db
-frontend/public/images/originals/
+
+# تصاویر محلی — از پنل/کنسول MinIO مدیریت میشوند
+frontend/public/images/
diff --git a/README.md b/README.md
index 0afa5c3..0a77754 100644
--- a/README.md
+++ b/README.md
@@ -2,35 +2,68 @@
مونوریپوی سایت گروه ساختمانی آرامش شامل:
-- **`frontend/`** — سایت React + Vite + Tailwind (همه محتوا از API خوانده میشود)
-- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT
+- **`frontend/`** — سایت React + Vite + Tailwind (تمام متنها و تصاویر از API خوانده میشود)
+- **`backend/`** — بکاند FastAPI + SQLAlchemy 2 + PostgreSQL + JWT + MinIO
+
+## معماری
+
+```
+Traefik (پورت ۵۱۷۳ — تنها نقطه ورود)
+├── / → Caddy (فایلهای استاتیک SPA)
+├── /api → FastAPI (بکاند)
+└── /media → MinIO (باکت aramesh-media — تصاویر آپلودی)
+```
+
+- **همه محتوای متنی سایت در PostgreSQL است** — هیچ متنی در کد فرانتاند هاردکد نشده؛
+ با یک درخواست `GET /api/site` بارگذاری و از پنل مدیریت ویرایش میشود
+- **تصاویر آپلودی در MinIO** (Object Storage سازگار با S3) ذخیره میشوند و با مسیر نسبی
+ `/media/...` برگردانده میشوند — بکاند ذخیرهسازی بدون تغییر محتوای دیتابیس قابل تعویض است
+- مسیریابی همهچیز روی **Traefik** است (با Docker labels) — nginx در پروژه وجود ندارد
## ساختار
```
├── frontend/ # سایت React
│ ├── src/ # کد منبع (api.tsx = لایه اتصال به بکاند)
-│ ├── Dockerfile # بیلد Node + سرو با nginx
-│ └── nginx.conf # پروکسی /api به بکاند
+│ ├── Dockerfile # بیلد Node + سرو استاتیک با Caddy
+│ └── Caddyfile # فقط سرو فایلهای استاتیک (مسیریابی روی Traefik است)
├── backend/ # بکاند FastAPI
-│ ├── app/ # کد منبع
+│ ├── app/ # کد منبع (آپلود تصویر: app/api/admin.py — سئوی سرور: app/seo_pages.py)
+│ ├── tests/ # تستهای API (pytest)
│ ├── requirements.txt
│ ├── Dockerfile
│ └── .env.example
-└── docker-compose.yml # اجرای کامل با یک دستور
+├── scripts/backup.sh # پشتیبانگیری دیتابیس و تصاویر
+├── docker-compose.yml # اجرای کامل با یک دستور
+└── docker-compose.prod.yml # لایه تولید: TLS با Let's Encrypt
```
## اجرا با Docker (سادهترین راه)
```bash
+cp .env.example .env # مقادیر change-me را عوض کنید — بدون .env استک بالا نمیآید
docker compose up --build
```
-- سایت: http://localhost:5173
-- مستندات API: http://localhost:8000/docs
-- دیتابیس PostgreSQL در کانتینر جدا (دادهها در volume ذخیره میشوند)
-- بکاند هنگام شروع، خودش جداول و دادههای اولیه سایت را seed میکند
-- nginx درخواستهای `/api` را خودش به بکاند پروکسی میکند — نیازی به تنظیم CORS نیست
+- سایت: http://localhost:5173 (از طریق Traefik)
+- مستندات API: http://localhost:8001/docs (با `docker-compose.override.yml` محلی)
+- کنسول MinIO: http://localhost:9001 — کاربر/رمز از متغیرهای `MINIO_ROOT_*` در compose
+- دیتابیس PostgreSQL و آبجکتاستور MinIO هر دو در کانتینر جدا با volume دائمی
+- بکاند هنگام شروع، خودش جداول را میسازد و **فقط جدولهای خالی** را seed میکند —
+ ارتقای نسخه دادههای فعلی را از بین نمیبرد
+- در اولین اجرا، سرویس `minio-setup` باکت `aramesh-media` را میسازد و خواندن عمومی را فعال میکند
+
+## انتشار واقعی (TLS)
+
+برای بالا آوردن با HTTPS روی دامنه واقعی، لایه `docker-compose.prod.yml` را هم سوار کنید:
+
+```bash
+# در .env: DOMAIN=example.com و ACME_EMAIL=you@example.com
+docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
+```
+
+- Traefik گواهی Let's Encrypt میگیرد و تمدید میکند؛ همه درخواستهای HTTP به HTTPS هدایت میشوند
+- در این حالت `SITE_URL=https://example.com` را هم در `.env` بگذارید تا sitemap و آدرسهای OG مطلق شوند
## اجرای محلی (توسعه)
@@ -52,34 +85,108 @@ uvicorn app.main:app --reload
```bash
cd frontend
npm install
-npm run dev # http://localhost:5173
+npm run dev
```
-آدرس بکاند در `frontend/src/config.ts` از `VITE_API_URL` خوانده میشود؛
-اگر تنظیم نشود پیشفرض `http://localhost:8000` است.
+در حالت توسعه، Vite درخواستهای `/api` و `/media` را به Traefik (پورت ۵۱۷۳) پروکسی میکند؛
+پس استک Docker باید در حال اجرا باشد. آدرس بکاند در `frontend/src/config.ts` از
+`VITE_API_URL` خوانده میشود — پیشفرض خالی یعنی درخواستهای هممبدأ.
## امکانات بکاند
-- **API عمومی**: برند، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم، تایملاین، آمار و...
+- **API عمومی**: برند، منو، هیرو، خدمات، پروژهها، مقالات، نظرات، سوالات متداول، تیم،
+ تایملاین، آمار، دفاتر، مدلهای سرمایهگذاری و... — همه در دیتابیس
- **فرم تماس** `POST /api/contact` و **خبرنامه** `POST /api/newsletter`
-- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`) و سپس CRUD کامل همه محتواها در `/api/admin/...`
-- همه محتوا در یک درخواست: `GET /api/site`
+- **پنل مدیریت** با JWT: ورود `POST /api/auth/login` (پیشفرض: `admin` / `aramesh123`)
+ و سپس CRUD کامل همه محتواها در `/api/admin/...`
+- **تغییر رمز** `POST /api/auth/change-password` — از نوار مدیریت پایین سایت (دکمه «رمز») هم در دسترس است
+- **محدودسازی ورود**: بعد از ۵ ورود ناموفق در ۱۵ دقیقه از یک IP، درخواست با 429 رد میشود
+- **آپلود تصویر** `POST /api/admin/upload` (فقط توکن ادمین، حداکثر ۱۰MB) — فایل در MinIO
+ ذخیره و URL نسبی `/media/...` برمیگردد
+- **ویرایش جزئی** `PATCH /api/admin/{resource}/{id}` — فقط فیلدهای ارسالشده عوض میشوند
+- **ترتیببندی** `POST /api/admin/{resource}/reorder` — با `{"ids": [...]}`
+- همه محتوا در یک درخواست: `GET /api/site` (مقالههای پیشنویس در آن نمیآیند)
+- **تستها**: `cd backend && pip install -r requirements-dev.txt && pytest tests`
-> در محیط واقعی حتماً `SECRET_KEY` و `ADMIN_PASSWORD` را تغییر دهید.
+> در محیط واقعی حتماً `SECRET_KEY`، `ADMIN_PASSWORD` و `MINIO_ROOT_PASSWORD` را تغییر دهید
+> (از `.env`) و بعد از اولین ورود، رمز را از نوار مدیریت عوض کنید.
+
+## ویرایش زنده سایت (دوقلوی دیجیتال)
+
+ادمینها نیازی به داشبورد جدا ندارند — خود سایت ویرایشگر است:
+
+1. به `/admin/login` بروید و وارد شوید
+2. نوار شناور پایین صفحه ظاهر میشود؛ **حالت ویرایش** را روشن کنید
+3. روی هر متن کلیک کنید و ویرایش کنید — **خروج از کادر، بلافاصله ذخیره میشود**
+ (Enter ذخیره، Esc لغو)؛ وضعیت ذخیره در نوار پایین دیده میشود
+4. آیتمهای مجموعهها (خدمات، پروژهها، مقالات، نظرات، تیم و...) با هاور، دکمههای
+ ویرایش / حذف / جابهجایی ترتیب نشان میدهند؛ دکمه «افزودن» هم انتهای هر لیست هست
+5. تصویرها از همان کشوها به MinIO آپلود میشوند
+
+- در حالت ویرایش، لینکها کلیک نمیشوند تا ویرایش متن ممکن باشد
+- بازدیدکنندههای عادی هیچ اثری از این لایه نمیبینند (بدون توکن، همه درخواستهای
+ مدیریتی `401` برمیگردند)
+- تمام تغییرها بلافاصله در PostgreSQL ثبت میشوند — با Ctrl+F5 هم میمانند
+
+## وبلاگ — نوشتن با ویرایشگر WYSIWYG
+
+مقالات با ویرایشگر غنی **TipTap** نوشته میشوند (همان تجربه Medium):
+
+- در حالت ویرایش، روی هر مقاله در صفحه وبلاگ دکمه ویرایش را بزنید — کشوی نوشتن باز میشود
+- تیتر (H2/H3)، بولد/ایتالیک/زیرخط، لیست نقطهای و شمارهای، نقلقول، لینک و واگرد
+- **درج تصویر داخل متن**: دکمه تصویر، یا چسباندن/کشیدن تصویر داخل نوشته — فایل مستقیم
+ در MinIO ذخیره و در متن قرار میگیرد
+- راستبهچپ کامل با فونت و تم سایت
+- ذخیره خودکار: هر مکث کوتاه در تایپ، تغییرها را ذخیره میکند؛ بستن کشو هم ذخیره میکند
+
+**پیشنویس و انتشار**: مقاله تازه همیشه **پیشنویس** شروع میشود — در سایت عمومی دیده
+نمیشود و تا انتشار، تغییرهای خودکار آن چیزی را خراب نمیکند. با دکمه «انتشار در سایت»
+عمومی میشود؛ دوباره میتوان آن را به پیشنویس برگرداند. پیشنویسها فقط برای ادمین
+(در صفحه وبلاگ، با برچسب «پیشنویس») دیده میشوند و در `GET /api/site` هرگز نمیآیند.
+
+**فرمت ذخیره**: بدنه مقاله یک سند ساختاریافته TipTap (JSON) در PostgreSQL است — نه HTML خام.
+صفحه مقاله آن را با رندر استاتیک TipTap به HTML تبدیل میکند، پس فقط تگهای شناختهشده
+(پاراگراف، تیتر، لیست، نقلقول، تصویر، لینک) تولید میشوند. مقالات قدیمی (لیست پاراگراف)
+در اولین اجرا بهصورت خودکار به این فرمت تبدیل میشوند.
+
+## سئو
+
+- **بررسی سئو در صفحه نوشتن**: فهرست سبز/نارنجی/قرمز به سبک یوست ولی سازگار با فارسی —
+ طول عنوان، کلمه کلیدی هدف (در عنوان/پاراگراف اول/تیترها)، طول چکیده، تعداد واژه،
+ تیتربندی H2، متن جایگزین تصاویر، پیوندها و نشانی (slug) — کلمه کلیدی هدف در دیتابیس
+ ذخیره میشود (فیلد `seo_keyphrase` مقاله)
+- **سئوی هر صفحه**: در حالت ویرایش، دکمه سئو روی هیرو هر صفحه (و کنار هیرو صفحه اصلی) —
+ عنوان تب و توضیح متای هر صفحه از دیتابیس خوانده و اعمال میشود؛ اگر ردیف سئوی صفحهای
+ هنوز ساخته نشده باشد، دکمه + همانجا آن را میسازد
+- صفحه مقاله و پروژه بهطور خودکار عنوان و توضیح متا را از محتوای خودشان میگیرند
+- **تگهای OG و توییتر و canonical** برای همه صفحات از `src/seo.ts` ست میشود و
+ مقالهها اسکیمای JSON-LD نوع Article میگیرند
+- **robots.txt و sitemap.xml** از بکاند سرو میشوند و مقاله/پروژههای منتشرشده را
+ شامل میشوند (پیشنویسها نمیآیند) — برای آدرس مطلق، `SITE_URL` را در `.env` بگذارید
+- **متا برای خزندههای بدون جاوااسکریپت** (تلگرام، واتساپ و...): بکاند صفحات
+ `/blog`، `/blog/{slug}`، `/projects` و `/projects/{slug}` را با همان `index.html`
+ فرانتاند سرو میکند و پیش از ارسال، title / description / OG / JSON-LD را از
+ دیتابیس داخل آن تزریق میکند (`app/seo_pages.py`) — بازدیدکنندههای عادی همان SPA
+ همیشگی را میبینند
+
+## پشتیبانگیری
+
+همه محتوای سایت در PostgreSQL و تصاویر در MinIO است — هر دو را با یک دستور پشتیبان بگیرید:
+
+```bash
+bash scripts/backup.sh # در backups/ — نگهداری پیشفرض ۱۴ روز
+```
+
+- خروجی: `backups/db-<زمان>.sql.gz` (فشرده) و `backups/images-<زمان>/` (آینه باکت)
+- زمانبندی با cron یا Task Scheduler: نمونه دستور داخل خود اسکریپت هست
+- بازیابی دیتابیس: `gunzip -c backups/db-<زمان>.sql.gz | docker compose exec -T db psql -U aramesh arameshplaza`
## نکات امنیتی
-- فایل `.env` هرگز commit نمیشود (در `.gitignore` است)
-- رمز عبور ادمین با bcrypt هش میشود
+- مقادیر محرمانه در `.env` ریشه هستند (در `.gitignore`) و در `docker-compose.yml`
+ با سینتکس `${VAR:?}` اجباری شدهاند — بدون `.env` استک بالا نمیآید
+- رمز عبور ادمین با bcrypt هش میشود و از نوار مدیریت پایین سایت (دکمه «رمز») قابل تعویض است
+- ورود با محدودیت تلاش محافظت میشود: ۵ ورود ناموفق در ۱۵ دقیقه از هر IP → خطای 429
- توکنهای JWT امضا دارند و مدت اعتبارشان قابل تنظیم است
-
-## تغییرات اخیر
-
-### بهبود سرعت (کامیت e0ebae4)
-- **حذف کامل وابستگیهای خارجی**: همه عکسهای سایت بهصورت محلی در `frontend/public/images/` قرار گرفتند (بدون وابستگی به سرورهای خارجی مثل postimg و housedesign)
-- **حذف فونتهای خارجی** (Google Fonts و fontcdn که رندر صفحه را قفل میکردند) — استفاده از فونت سیستمی سریع
-- **favicon واقعی** اضافه شد (قبلاً خراب بود)
-- **کش ۳۰ روزه** برای تصاویر و فایلهای استاتیک در nginx
-- نتیجه: صفحهها در چند میلیثانیه لود میشوند و سایت هیچ درخواست خارجی ندارد
-
-> اگر نسخه قبلی سایت را اجرا کردهاید، برای دریافت نسخه جدید یک بار `docker compose down -v` و سپس `docker compose up --build -d` را اجرا کنید.
+- تصاویر باکت MinIO عمومی (فقط خواندن) هستند؛ آپلود فقط با توکن ادمین ممکن است
+- مقالههای پیشنویس در هیچ انتهای عمومی نمیآیند و در HTML تزریقشده سئو هم افشا نمیشوند
diff --git a/backend/.env.example b/backend/.env.example
index f88754f..1429894 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -15,3 +15,15 @@ CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
# مشخصات ادمین پیشفرض که هنگام seed ساخته میشود
ADMIN_USERNAME=admin
ADMIN_PASSWORD=aramesh123
+
+# Object Storage (MinIO) برای آپلود تصاویر پنل مدیریت
+S3_ENDPOINT=http://localhost:9000
+S3_ACCESS_KEY=aramesh
+S3_SECRET_KEY=change-me-minio-secret
+S3_BUCKET=aramesh-media
+
+# پیشوند عمومی مسیر تصاویر — پروکسی Reverse باید آن را به باکت برساند
+MEDIA_PUBLIC_BASE=/media
+
+# حداکثر حجم آپلود تصویر (مگابایت)
+MEDIA_MAX_UPLOAD_MB=10
diff --git a/backend/README.md b/backend/README.md
index 1407cdb..7f6b098 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -1,14 +1,15 @@
# بکاند گروه ساختمانی آرامش (Aramesh Building Group API)
-بکاند سایت گروه ساختمانی آرامش — ساختهشده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT**.
+بکاند سایت گروه ساختمانی آرامش — ساختهشده با **FastAPI** + **PostgreSQL** + **SQLAlchemy 2** + **JWT** + **MinIO** (ذخیره تصاویر) + **Traefik** (مسیریابی).
## امکانات
-- **API عمومی محتوا**: برند، خدمات، پروژهها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایملاین، آمار، چرا آرامش، و بخش قبل/بعد
+- **API عمومی محتوا**: برند، منو، هیرو، داستان، تیتر بخشها، هیرو صفحات، خدمات، پروژهها، مقالات، نظرات مشتریان، سوالات متداول، تیم، تایملاین، آمار، چرا آرامش، قبل/بعد، مدلهای سرمایهگذاری، مراحل کار، دفاتر، گالری، فرم تماس، خبرنامه و فوتر — **تمام متنهای سایت در دیتابیس است**
- **فرم تماس**: ثبت پیام مشتریان از سایت (`POST /api/contact`)
- **خبرنامه**: عضویت و مدیریت مشترکین (`POST /api/newsletter`)
- **پنل مدیریت**: ورود با نام کاربری/رمز عبور و دریافت توکن JWT، سپس CRUD کامل همه محتواها، مدیریت پیامهای تماس و مشترکین
-- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار میگیرد
+- **آپلود تصویر**: `POST /api/admin/upload` — فایل در MinIO ذخیره و URL نسبی `/media/...` برمیگردد
+- **Seed خودکار**: همه محتوای فعلی سایت با یک دستور در دیتابیس قرار میگیرد (فقط جدولهای خالی پر میشوند — ارتقا بدون از دست رفتن داده)
- مستندات تعاملی Swagger در مسیر `/docs`
## راهاندازی
@@ -85,6 +86,13 @@ Authorization: Bearer
> نام کاربری و رمز عبور پیشفرض از تنظیمات `.env` خوانده میشود — حتماً در محیط تولید تغییرشان دهید.
+تغییر رمز (با توکن):
+
+```
+POST /api/auth/change-password
+{ "current_password": "...", "new_password": "..." }
+```
+
## فهرست انتهاپوینتها
### عمومی (بدون نیاز به توکن)
@@ -96,8 +104,8 @@ Authorization: Bearer
| GET | `/api/services` | خدمات |
| GET | `/api/projects` | همه پروژهها |
| GET | `/api/projects/{slug}` | جزئیات یک پروژه |
-| GET | `/api/articles` | همه مقالات |
-| GET | `/api/articles/{slug}` | جزئیات یک مقاله |
+| GET | `/api/articles` | مقالههای منتشرشده (پیشنویس نمیآید) |
+| GET | `/api/articles/{slug}` | جزئیات یک مقاله منتشرشده |
| GET | `/api/testimonials` | نظرات مشتریان |
| GET | `/api/faqs` | سوالات متداول |
| GET | `/api/team` | اعضای تیم |
@@ -108,7 +116,11 @@ Authorization: Bearer
| GET | `/api/site` | **همه محتوا در یک درخواست** |
| POST | `/api/contact` | ثبت پیام تماس |
| POST | `/api/newsletter` | عضویت در خبرنامه |
-| POST | `/api/auth/login` | ورود ادمین و دریافت توکن |
+| POST | `/api/auth/login` | ورود ادمین و دریافت توکن — بعد از ۵ تلاش ناموفق در ۱۵ دقیقه، 429 |
+| GET | `/robots.txt` | قوانین خزندهها |
+| GET | `/sitemap.xml` | نقشه سایت — مقاله/پروژههای منتشرشده |
+| GET | `/blog/{slug}` | HTML صفحه مقاله با متای سئوی تزریقشده |
+| GET | `/projects/{slug}` | HTML صفحه پروژه با متای سئوی تزریقشده |
### مدیریت (نیاز به توکن Bearer)
@@ -118,12 +130,35 @@ Authorization: Bearer
| متد | مسیر | توضیح |
|---|---|---|
| GET/POST | `/api/admin/{resource}` | لیست / ساخت |
-| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش / حذف |
+| GET/PUT/DELETE | `/api/admin/{resource}/{id}` | دریافت / ویرایش کامل / حذف |
+| PATCH | `/api/admin/{resource}/{id}` | **ویرایش جزئی** — فقط فیلدهای ارسالشده بهروزرسانی میشوند |
+| POST | `/api/admin/{resource}/reorder` | ذخیره ترتیب: `{"ids": [3, 1, 2, ...]}` |
| GET | `/api/admin/messages` | پیامهای تماس (با `?unread_only=true` فقط خواندهنشدهها) |
| PATCH | `/api/admin/messages/{id}` | تغییر وضعیت خواندهشدن |
| DELETE | `/api/admin/messages/{id}` | حذف پیام |
| GET | `/api/admin/subscribers` | مشترکین خبرنامه |
| DELETE | `/api/admin/subscribers/{id}` | حذف مشترک |
+| POST | `/api/admin/upload` | آپلود تصویر (multipart، حداکثر ۱۰MB — jpg/png/webp/gif/avif) → `{"url": "/media/..."}` |
+
+### بدنه مقاله — سند TipTap
+
+`body` مقاله یک سند ساختاریافته TipTap (ProseMirror JSON) است: `{"type": "doc", "content": [...]}`.
+- فقط ساختار اعتبارسنجی میشود (`type: "doc"` + `content` لیست)؛ امنیت رندر بر عهده
+ رندر استاتیک TipTap در فرانتاند است که فقط تگهای شناختهشده تولید میکند
+- ردیفهای قدیمی (لیست پاراگراف) با مهاجرت idempotent `migrate_article_bodies`
+ در startup/seed خودکار به سند تبدیل میشوند
+- seed هم بدنههای لیستی را قبل از درج تبدیل میکند
+
+| POST/PATCH | `/api/admin/seo-meta` | عنوان و توضیح متای صفحات — `page_key`: home / about / services / projects / investment / blog / faq / contact |
+
+منابعی که `reorder` دارند (ستون `position`): `services`، `projects`، `articles`، `testimonials`، `faqs`، `team`، `timeline`، `stats`، `why-us`، `investment-models`، `process-steps`، `offices`، `gallery`، `nav`. ستون با مهاجرت سبک idempotent هنگام startup/seed به دیتابیسهای موجود اضافه میشود.
+
+### تصاویر و MinIO
+
+فایلهای آپلودشده در باکت `aramesh-media` روی MinIO ذخیره میشوند و با مسیر نسبی `/media/<سال>/<ماه>/<فایل>` برگردانده میشوند. Traefik مسیر `/media` را به MinIO میبرد، پس URLها مستقل از بکاند ذخیرهسازی هستند — بعداً برای مهاجرت به R2/S3 فقط تنظیمات را عوض کنید، نه محتوای دیتابیس را.
+
+- کنسول MinIO: `http://localhost:9001` (کاربر/رمز از `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` در compose)
+- تصاویر عمومی و قابل نمایشاند؛ آپلود فقط با توکن ادمین انجام میشود.
## اتصال به فرانتاند
diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py
index 82a8887..eedbea4 100644
--- a/backend/app/api/admin.py
+++ b/backend/app/api/admin.py
@@ -1,40 +1,103 @@
"""انتهای پنل مدیریت — همه مسیرها نیازمند توکن JWT معتبر هستند."""
-from fastapi import APIRouter, Depends, HTTPException, status
+import uuid
+from datetime import datetime, timezone
+
+import boto3
+from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
+from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .. import models, schemas
-from ..auth import create_access_token, get_current_admin, verify_password
+from ..auth import create_access_token, get_current_admin, hash_password, verify_password
+from ..config import settings
from ..database import get_db
+from ..rate_limit import clear as clear_rate_limit
+from ..rate_limit import ensure_allowed, record_failure
from ..schemas import (
ArticleIn,
ArticleOut,
+ ArticlePatch,
BeforeAfterIn,
BeforeAfterOut,
+ BeforeAfterPatch,
BrandIn,
BrandOut,
+ BrandPatch,
+ ChangePasswordIn,
+ ContactSectionIn,
+ ContactSectionOut,
+ ContactSectionPatch,
ContactMessageOut,
FaqIn,
FaqOut,
+ FaqPatch,
+ FooterIn,
+ FooterOut,
+ FooterPatch,
+ GalleryImageIn,
+ GalleryImageOut,
+ GalleryImagePatch,
+ HeroIn,
+ HeroOut,
+ HeroPatch,
+ InvestmentModelIn,
+ InvestmentModelOut,
+ InvestmentModelPatch,
LoginIn,
MessageReadIn,
+ NavItemIn,
+ NavItemOut,
+ NavItemPatch,
+ NewsletterSectionIn,
+ NewsletterOut,
+ NewsletterSectionPatch,
+ OfficeIn,
+ OfficeOut,
+ OfficePatch,
+ PageHeroIn,
+ PageHeroOut,
+ PageHeroPatch,
+ ProcessStepIn,
+ ProcessStepOut,
+ ProcessStepPatch,
ProjectIn,
ProjectOut,
+ ProjectPatch,
+ ReorderIn,
+ SeoMetaIn,
+ SeoMetaOut,
+ SeoMetaPatch,
+ SectionHeadingIn,
+ SectionHeadingOut,
+ SectionHeadingPatch,
ServiceIn,
ServiceOut,
+ ServicePatch,
StatIn,
StatOut,
+ StatPatch,
+ StatusLabelIn,
+ StatusLabelOut,
+ StatusLabelPatch,
+ StoryIn,
+ StoryOut,
+ StoryPatch,
SubscriberOut,
TeamMemberIn,
TeamMemberOut,
+ TeamMemberPatch,
TestimonialIn,
TestimonialOut,
+ TestimonialPatch,
TimelineItemIn,
TimelineItemOut,
+ TimelineItemPatch,
TokenOut,
WhyUsItemIn,
WhyUsItemOut,
+ WhyUsItemPatch,
)
# روتر عمومی برای ورود — بدون نیاز به توکن
@@ -45,26 +108,52 @@ router = APIRouter(dependencies=[Depends(get_current_admin)])
@auth_router.post("/auth/login", response_model=TokenOut)
-def login(payload: LoginIn, db: Session = Depends(get_db)):
+def login(payload: LoginIn, request: Request, db: Session = Depends(get_db)):
+ ensure_allowed(request)
user = db.query(models.AdminUser).filter(models.AdminUser.username == payload.username).first()
if user is None or not verify_password(payload.password, user.password_hash):
+ record_failure(request)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="نام کاربری یا رمز عبور اشتباه است",
)
+ clear_rate_limit(request)
return TokenOut(access_token=create_access_token(user.username))
-def register_crud(path: str, model, out_schema, create_schema) -> None:
- """ثبت پنج مسیر CRUD استاندارد (لیست، ساخت، دریافت، ویرایش، حذف) روی روتر مدیریت."""
+@auth_router.post("/auth/change-password")
+def change_password(
+ payload: ChangePasswordIn,
+ admin: models.AdminUser = Depends(get_current_admin),
+ db: Session = Depends(get_db),
+):
+ """تغییر رمز ادمین واردشده — رمز فعلی باید درست باشد."""
+ if not verify_password(payload.current_password, admin.password_hash):
+ raise HTTPException(status_code=400, detail="رمز عبور فعلی اشتباه است")
+ admin.password_hash = hash_password(payload.new_password)
+ db.commit()
+ return {"ok": True}
+
+
+def register_crud(path: str, model, out_schema, create_schema, patch_schema=None) -> None:
+ """ثبت مسیرهای استاندارد CRUD (لیست، ساخت، دریافت، ویرایش کامل، ویرایش جزئی،
+ حذف و ترتیببندی) روی روتر مدیریت."""
@router.get(f"/{path}", response_model=list[out_schema])
def list_items(db: Session = Depends(get_db)):
- return db.query(model).order_by(model.id).all()
+ query = db.query(model)
+ if hasattr(model, "position"):
+ query = query.order_by(model.position, model.id)
+ else:
+ query = query.order_by(model.id)
+ return query.all()
@router.post(f"/{path}", response_model=out_schema, status_code=201)
def create_item(payload: create_schema, db: Session = Depends(get_db)):
item = model(**payload.model_dump())
+ if hasattr(model, "position"):
+ # آیتم جدید در انتهای لیست قرار میگیرد
+ item.position = (db.query(func.max(model.position)).scalar() or 0) + 1
db.add(item)
try:
db.commit()
@@ -96,6 +185,38 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
db.refresh(item)
return item
+ if patch_schema is not None:
+
+ @router.patch(f"/{path}/{{item_id}}", response_model=out_schema)
+ def patch_item(item_id: int, payload: patch_schema, db: Session = Depends(get_db)):
+ """ویرایش جزئی — فقط فیلدهای ارسالشده بهروزرسانی میشوند (برای ویرایش زنده)."""
+ item = db.get(model, item_id)
+ if item is None:
+ raise HTTPException(status_code=404, detail="رکورد یافت نشد")
+ for key, value in payload.model_dump(exclude_unset=True).items():
+ setattr(item, key, value)
+ try:
+ db.commit()
+ except IntegrityError:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="رکوردی با این مقدار تکراری وجود دارد (مثلاً slug یا ایمیل)")
+ db.refresh(item)
+ return item
+
+ @router.post(f"/{path}/reorder", response_model=list[out_schema])
+ def reorder_items(payload: ReorderIn, db: Session = Depends(get_db)):
+ """ذخیره ترتیب جدید آیتمها بر اساس لیست idهای ارسالی."""
+ if not hasattr(model, "position"):
+ raise HTTPException(status_code=400, detail="این منبع از ترتیببندی پشتیبانی نمیکند")
+ items = db.query(model).filter(model.id.in_(payload.ids)).all()
+ if len(items) != len(payload.ids):
+ raise HTTPException(status_code=404, detail="برخی رکوردها یافت نشدند")
+ by_id = {item.id: item for item in items}
+ for pos, item_id in enumerate(payload.ids):
+ by_id[item_id].position = pos
+ db.commit()
+ return db.query(model).order_by(model.position, model.id).all()
+
@router.delete(f"/{path}/{{item_id}}", status_code=204)
def delete_item(item_id: int, db: Session = Depends(get_db)):
item = db.get(model, item_id)
@@ -106,17 +227,87 @@ def register_crud(path: str, model, out_schema, create_schema) -> None:
# CRUD محتوا
-register_crud("brand", models.Brand, BrandOut, BrandIn)
-register_crud("services", models.Service, ServiceOut, ServiceIn)
-register_crud("projects", models.Project, ProjectOut, ProjectIn)
-register_crud("articles", models.Article, ArticleOut, ArticleIn)
-register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn)
-register_crud("faqs", models.Faq, FaqOut, FaqIn)
-register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn)
-register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn)
-register_crud("stats", models.Stat, StatOut, StatIn)
-register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn)
-register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn)
+register_crud("brand", models.Brand, BrandOut, BrandIn, BrandPatch)
+register_crud("nav", models.NavItem, NavItemOut, NavItemIn, NavItemPatch)
+register_crud("hero", models.Hero, HeroOut, HeroIn, HeroPatch)
+register_crud("story", models.StorySection, StoryOut, StoryIn, StoryPatch)
+register_crud("section-headings", models.SectionHeadingContent, SectionHeadingOut, SectionHeadingIn, SectionHeadingPatch)
+register_crud("page-heroes", models.PageHeroContent, PageHeroOut, PageHeroIn, PageHeroPatch)
+register_crud("services", models.Service, ServiceOut, ServiceIn, ServicePatch)
+register_crud("projects", models.Project, ProjectOut, ProjectIn, ProjectPatch)
+register_crud("articles", models.Article, ArticleOut, ArticleIn, ArticlePatch)
+register_crud("testimonials", models.Testimonial, TestimonialOut, TestimonialIn, TestimonialPatch)
+register_crud("faqs", models.Faq, FaqOut, FaqIn, FaqPatch)
+register_crud("team", models.TeamMember, TeamMemberOut, TeamMemberIn, TeamMemberPatch)
+register_crud("timeline", models.TimelineItem, TimelineItemOut, TimelineItemIn, TimelineItemPatch)
+register_crud("stats", models.Stat, StatOut, StatIn, StatPatch)
+register_crud("why-us", models.WhyUsItem, WhyUsItemOut, WhyUsItemIn, WhyUsItemPatch)
+register_crud("before-after", models.BeforeAfter, BeforeAfterOut, BeforeAfterIn, BeforeAfterPatch)
+register_crud("investment-models", models.InvestmentModel, InvestmentModelOut, InvestmentModelIn, InvestmentModelPatch)
+register_crud("process-steps", models.ProcessStep, ProcessStepOut, ProcessStepIn, ProcessStepPatch)
+register_crud("offices", models.Office, OfficeOut, OfficeIn, OfficePatch)
+register_crud("gallery", models.GalleryImage, GalleryImageOut, GalleryImageIn, GalleryImagePatch)
+register_crud("status-labels", models.StatusLabel, StatusLabelOut, StatusLabelIn, StatusLabelPatch)
+register_crud("contact-section", models.ContactSectionContent, ContactSectionOut, ContactSectionIn, ContactSectionPatch)
+register_crud("newsletter", models.NewsletterSectionContent, NewsletterOut, NewsletterSectionIn, NewsletterSectionPatch)
+register_crud("footer", models.FooterContent, FooterOut, FooterIn, FooterPatch)
+register_crud("seo-meta", models.SeoMeta, SeoMetaOut, SeoMetaIn, SeoMetaPatch)
+
+
+# آپلود تصویر به Object Storage (MinIO) — URL نسبی برمیگرداند تا بکاند ذخیرهسازی قابل تعویض باشد
+ALLOWED_IMAGE_TYPES = {
+ "image/jpeg": "jpg",
+ "image/png": "png",
+ "image/webp": "webp",
+ "image/gif": "gif",
+ "image/avif": "avif",
+}
+
+
+@router.post("/upload")
+async def upload_media(
+ file: UploadFile = File(...),
+ _admin: models.AdminUser = Depends(get_current_admin),
+):
+ content_type = (file.content_type or "").lower()
+ ext = ALLOWED_IMAGE_TYPES.get(content_type)
+ if ext is None:
+ raise HTTPException(
+ status_code=415,
+ detail="فرمت فایل پشتیبانی نمیشود (مجاز: jpg، png، webp، gif، avif)",
+ )
+
+ max_bytes = settings.media_max_upload_mb * 1024 * 1024
+ data = await file.read()
+ if len(data) == 0:
+ raise HTTPException(status_code=400, detail="فایل خالی است")
+ if len(data) > max_bytes:
+ raise HTTPException(
+ status_code=413,
+ detail=f"حجم فایل بیش از {settings.media_max_upload_mb} مگابایت است",
+ )
+
+ now = datetime.now(timezone.utc)
+ key = f"{now:%Y/%m}/{uuid.uuid4().hex}.{ext}"
+
+ s3 = boto3.client(
+ "s3",
+ endpoint_url=settings.s3_endpoint,
+ aws_access_key_id=settings.s3_access_key,
+ aws_secret_access_key=settings.s3_secret_key,
+ region_name="us-east-1",
+ )
+ try:
+ s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=data, ContentType=content_type)
+ except Exception:
+ raise HTTPException(status_code=502, detail="ذخیره فایل در Object Storage ناموفق بود")
+
+ return {
+ "url": f"{settings.media_public_base}/{key}",
+ "key": key,
+ "size": len(data),
+ "content_type": content_type,
+ }
# پیامهای تماس
diff --git a/backend/app/api/public.py b/backend/app/api/public.py
index 895111a..25a312d 100644
--- a/backend/app/api/public.py
+++ b/backend/app/api/public.py
@@ -37,12 +37,12 @@ def get_brand(db: Session = Depends(get_db)):
@router.get("/services", response_model=list[ServiceOut])
def get_services(db: Session = Depends(get_db)):
- return db.query(models.Service).all()
+ return db.query(models.Service).order_by(models.Service.position, models.Service.id).all()
@router.get("/projects", response_model=list[ProjectOut])
def get_projects(db: Session = Depends(get_db)):
- return db.query(models.Project).order_by(models.Project.id).all()
+ return db.query(models.Project).order_by(models.Project.position, models.Project.id).all()
@router.get("/projects/{slug}", response_model=ProjectOut)
@@ -55,12 +55,21 @@ def get_project(slug: str, db: Session = Depends(get_db)):
@router.get("/articles", response_model=list[ArticleOut])
def get_articles(db: Session = Depends(get_db)):
- return db.query(models.Article).order_by(models.Article.id).all()
+ return (
+ db.query(models.Article)
+ .filter(models.Article.status == "published")
+ .order_by(models.Article.position, models.Article.id)
+ .all()
+ )
@router.get("/articles/{slug}", response_model=ArticleOut)
def get_article(slug: str, db: Session = Depends(get_db)):
- article = db.query(models.Article).filter(models.Article.slug == slug).first()
+ article = (
+ db.query(models.Article)
+ .filter(models.Article.slug == slug, models.Article.status == "published")
+ .first()
+ )
if article is None:
raise HTTPException(status_code=404, detail="مقاله یافت نشد")
return article
@@ -68,32 +77,32 @@ def get_article(slug: str, db: Session = Depends(get_db)):
@router.get("/testimonials", response_model=list[TestimonialOut])
def get_testimonials(db: Session = Depends(get_db)):
- return db.query(models.Testimonial).all()
+ return db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all()
@router.get("/faqs", response_model=list[FaqOut])
def get_faqs(db: Session = Depends(get_db)):
- return db.query(models.Faq).all()
+ return db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all()
@router.get("/team", response_model=list[TeamMemberOut])
def get_team(db: Session = Depends(get_db)):
- return db.query(models.TeamMember).all()
+ return db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all()
@router.get("/timeline", response_model=list[TimelineItemOut])
def get_timeline(db: Session = Depends(get_db)):
- return db.query(models.TimelineItem).order_by(models.TimelineItem.id).all()
+ return db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all()
@router.get("/stats", response_model=list[StatOut])
def get_stats(db: Session = Depends(get_db)):
- return db.query(models.Stat).all()
+ return db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all()
@router.get("/why-us", response_model=list[WhyUsItemOut])
def get_why_us(db: Session = Depends(get_db)):
- return db.query(models.WhyUsItem).all()
+ return db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all()
@router.get("/before-after", response_model=BeforeAfterOut)
@@ -109,14 +118,32 @@ def get_site(db: Session = Depends(get_db)):
"""همه محتوای سایت در یک درخواست — مناسب برای بارگذاری اولیه فرانتاند."""
return {
"brand": db.query(models.Brand).first(),
- "services": db.query(models.Service).all(),
- "projects": db.query(models.Project).order_by(models.Project.id).all(),
- "articles": db.query(models.Article).order_by(models.Article.id).all(),
- "testimonials": db.query(models.Testimonial).all(),
- "faqs": db.query(models.Faq).all(),
- "team": db.query(models.TeamMember).all(),
- "timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.id).all(),
- "stats": db.query(models.Stat).all(),
- "why_us": db.query(models.WhyUsItem).all(),
+ "nav": db.query(models.NavItem).order_by(models.NavItem.position, models.NavItem.id).all(),
+ "hero": db.query(models.Hero).first(),
+ "story": db.query(models.StorySection).first(),
+ "section_headings": db.query(models.SectionHeadingContent).order_by(models.SectionHeadingContent.id).all(),
+ "page_heroes": db.query(models.PageHeroContent).order_by(models.PageHeroContent.id).all(),
+ "services": db.query(models.Service).order_by(models.Service.position, models.Service.id).all(),
+ "projects": db.query(models.Project).order_by(models.Project.position, models.Project.id).all(),
+ # فقط مقالههای منتشرشده — پیشنویسها از انتهای مدیریتی خوانده میشوند
+ "articles": db.query(models.Article)
+ .filter(models.Article.status == "published")
+ .order_by(models.Article.position, models.Article.id)
+ .all(),
+ "testimonials": db.query(models.Testimonial).order_by(models.Testimonial.position, models.Testimonial.id).all(),
+ "faqs": db.query(models.Faq).order_by(models.Faq.position, models.Faq.id).all(),
+ "team": db.query(models.TeamMember).order_by(models.TeamMember.position, models.TeamMember.id).all(),
+ "timeline": db.query(models.TimelineItem).order_by(models.TimelineItem.position, models.TimelineItem.id).all(),
+ "stats": db.query(models.Stat).order_by(models.Stat.position, models.Stat.id).all(),
+ "why_us": db.query(models.WhyUsItem).order_by(models.WhyUsItem.position, models.WhyUsItem.id).all(),
"before_after": db.query(models.BeforeAfter).first(),
+ "investment_models": db.query(models.InvestmentModel).order_by(models.InvestmentModel.position, models.InvestmentModel.id).all(),
+ "process_steps": db.query(models.ProcessStep).order_by(models.ProcessStep.position, models.ProcessStep.id).all(),
+ "offices": db.query(models.Office).order_by(models.Office.position, models.Office.id).all(),
+ "gallery": db.query(models.GalleryImage).order_by(models.GalleryImage.position, models.GalleryImage.id).all(),
+ "status_labels": db.query(models.StatusLabel).order_by(models.StatusLabel.id).all(),
+ "contact_section": db.query(models.ContactSectionContent).first(),
+ "newsletter": db.query(models.NewsletterSectionContent).first(),
+ "footer": db.query(models.FooterContent).first(),
+ "seo_meta": db.query(models.SeoMeta).order_by(models.SeoMeta.id).all(),
}
diff --git a/backend/app/config.py b/backend/app/config.py
index 641e5aa..2d97aeb 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -18,5 +18,19 @@ class Settings(BaseSettings):
admin_username: str = "admin"
admin_password: str = "aramesh123"
+ # Object Storage (MinIO / هر S3 سازگار) برای آپلود تصاویر
+ s3_endpoint: str = "http://localhost:9000"
+ s3_access_key: str = "aramesh"
+ s3_secret_key: str = "aramesh-minio-secret"
+ s3_bucket: str = "aramesh-media"
+ # پیشوند عمومی مسیر تصاویر — Traefik آن را به باکت پروکسی میکند
+ media_public_base: str = "/media"
+ media_max_upload_mb: int = 10
+
+ # نشانی کامل سایت (مثل https://aramesh.example) — برای sitemap و آدرس مطلق OG
+ site_url: str = ""
+ # مسیر بیلد فرانتاند (شامل index.html) — برای سرو HTML با متای سئو در صفحات جزئیات
+ frontend_dist: str = "../frontend/dist"
+
settings = Settings()
diff --git a/backend/app/main.py b/backend/app/main.py
index a5c8a02..e04fea7 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -5,8 +5,11 @@ from . import models # noqa: F401 — ثبت مدلها پیش از ساخت
from .api import admin, forms, public
from .config import settings
from .database import Base, engine
+from .migrations import run_all as run_migrations
+from .seo_pages import router as seo_router
Base.metadata.create_all(bind=engine)
+run_migrations()
app = FastAPI(
title="Aramesh Building Group API",
@@ -31,6 +34,8 @@ app.include_router(public.router, prefix="/api", tags=["عمومی"])
app.include_router(forms.router, prefix="/api", tags=["فرمها"])
app.include_router(admin.auth_router, prefix="/api", tags=["احراز هویت"])
app.include_router(admin.router, prefix="/api/admin", tags=["پنل مدیریت"])
+# robots.txt، sitemap.xml و HTML صفحات وبلاگ/پروژه با متای سئو — در ریشه دامنه
+app.include_router(seo_router, tags=["سئو"])
@app.get("/")
diff --git a/backend/app/migrations.py b/backend/app/migrations.py
new file mode 100644
index 0000000..3e4cd48
--- /dev/null
+++ b/backend/app/migrations.py
@@ -0,0 +1,71 @@
+"""مهاجرتهای سبک دیتابیس — DDL و تبدیل دادههای قدیمی؛ همه idempotent.
+
+هم main.py (startup) و هم seed.py اینها را صدا میزنند تا هر مسیر اجرایی
+دیتابیس موجود را بدون از دست رفتن داده ارتقا دهد.
+"""
+
+from sqlalchemy import text
+
+from .database import SessionLocal, engine
+
+# جدولهای مجموعهای که ستون position دارند
+POSITION_TABLES = [
+ "services", "projects", "articles", "testimonials", "faqs", "team",
+ "timeline", "stats", "why_us", "investment_models", "process_steps",
+ "offices", "gallery_images", "nav_items",
+]
+
+
+def run_ddl_migrations() -> None:
+ """ستونهای جدید — برای PostgreSQL با ADD COLUMN IF NOT EXISTS."""
+ if engine.dialect.name != "postgresql":
+ return
+ with engine.begin() as conn:
+ for table in POSITION_TABLES:
+ conn.execute(
+ text(f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0")
+ )
+ # مقالات موجود منتشر شدهاند؛ فقط مقالههای تازه میتوانند پیشنویس باشند
+ conn.execute(
+ text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS status VARCHAR(16) NOT NULL DEFAULT 'published'")
+ )
+ conn.execute(
+ text("ALTER TABLE articles ADD COLUMN IF NOT EXISTS seo_keyphrase VARCHAR(128) NOT NULL DEFAULT ''")
+ )
+
+
+def paragraphs_to_doc(paragraphs: list) -> dict:
+ """تبدیل لیست پاراگرافهای متنی (فرمت قدیمی بدنه مقاله) به سند TipTap."""
+ content = []
+ for para in paragraphs:
+ if isinstance(para, str) and para.strip():
+ content.append({
+ "type": "paragraph",
+ "content": [{"type": "text", "text": para}],
+ })
+ if not content:
+ content = [{"type": "paragraph"}]
+ return {"type": "doc", "content": content}
+
+
+def migrate_article_bodies() -> None:
+ """تبدیل بدنه مقالات از لیست پاراگراف به سند JSON — idempotent."""
+ from . import models # lazy — جلوگیری از import حلقهای
+
+ db = SessionLocal()
+ try:
+ changed = 0
+ for article in db.query(models.Article).all():
+ if isinstance(article.body, list):
+ article.body = paragraphs_to_doc(article.body)
+ changed += 1
+ db.commit()
+ if changed:
+ print(f"مقالات تبدیلشده به فرمت سند: {changed}")
+ finally:
+ db.close()
+
+
+def run_all() -> None:
+ run_ddl_migrations()
+ migrate_article_bodies()
diff --git a/backend/app/models.py b/backend/app/models.py
index d8b3d02..f5929a7 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -35,6 +35,8 @@ class Brand(Base):
class Service(Base):
__tablename__ = "services"
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256))
desc: Mapped[str] = mapped_column(Text, default="")
@@ -44,6 +46,8 @@ class Service(Base):
class Project(Base):
__tablename__ = "projects"
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256))
@@ -63,6 +67,8 @@ class Project(Base):
class Article(Base):
__tablename__ = "articles"
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
id: Mapped[int] = mapped_column(primary_key=True)
slug: Mapped[str] = mapped_column(String(128), unique=True, index=True)
title: Mapped[str] = mapped_column(String(256))
@@ -72,11 +78,17 @@ class Article(Base):
read: Mapped[str] = mapped_column(String(32), default="")
excerpt: Mapped[str] = mapped_column(Text, default="")
body: Mapped[list] = mapped_column(JSON, default=list)
+ # draft | published — پیشنویس فقط برای ادمین دیده میشود
+ status: Mapped[str] = mapped_column(String(16), default="published")
+ # کلمه کلیدی هدف چکلیست سئو — راهنمای نوشتن، در سایت نمایش داده نمیشود
+ seo_keyphrase: Mapped[str] = mapped_column(String(128), default="")
class Testimonial(Base):
__tablename__ = "testimonials"
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(String(128), default="")
@@ -86,6 +98,8 @@ class Testimonial(Base):
class Faq(Base):
__tablename__ = "faqs"
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
id: Mapped[int] = mapped_column(primary_key=True)
question: Mapped[str] = mapped_column(Text)
answer: Mapped[str] = mapped_column(Text, default="")
@@ -94,6 +108,8 @@ class Faq(Base):
class TeamMember(Base):
__tablename__ = "team"
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(String(128), default="")
@@ -103,6 +119,8 @@ class TeamMember(Base):
class TimelineItem(Base):
__tablename__ = "timeline"
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
id: Mapped[int] = mapped_column(primary_key=True)
year: Mapped[str] = mapped_column(String(32))
title: Mapped[str] = mapped_column(String(256))
@@ -113,6 +131,8 @@ class TimelineItem(Base):
class Stat(Base):
__tablename__ = "stats"
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
id: Mapped[int] = mapped_column(primary_key=True)
value: Mapped[int] = mapped_column(Integer)
suffix: Mapped[str] = mapped_column(String(16), default="")
@@ -122,6 +142,8 @@ class Stat(Base):
class WhyUsItem(Base):
__tablename__ = "why_us"
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(256))
desc: Mapped[str] = mapped_column(Text, default="")
@@ -139,6 +161,200 @@ class BeforeAfter(Base):
after_label: Mapped[str] = mapped_column(String(128), default="")
+class NavItem(Base):
+ __tablename__ = "nav_items"
+
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ label: Mapped[str] = mapped_column(String(64))
+ href: Mapped[str] = mapped_column(String(128))
+
+
+class Hero(Base):
+ """محتوای هیرو صفحه اصلی — رکورد یگانه."""
+
+ __tablename__ = "hero"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ kicker: Mapped[str] = mapped_column(Text, default="")
+ title: Mapped[str] = mapped_column(Text, default="")
+ title_highlight: Mapped[str] = mapped_column(String(128), default="")
+ subtitle: Mapped[str] = mapped_column(Text, default="")
+ badge_value: Mapped[str] = mapped_column(String(32), default="")
+ badge_label: Mapped[str] = mapped_column(Text, default="")
+ cta_primary_label: Mapped[str] = mapped_column(String(128), default="")
+ cta_primary_href: Mapped[str] = mapped_column(String(128), default="")
+ cta_secondary_label: Mapped[str] = mapped_column(String(128), default="")
+ cta_secondary_href: Mapped[str] = mapped_column(String(128), default="")
+ scroll_label: Mapped[str] = mapped_column(String(64), default="")
+
+
+class StorySection(Base):
+ """بخش «درباره ما» در خانه و صفحه درباره — رکورد یگانه."""
+
+ __tablename__ = "story"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(String(256), default="")
+ paragraphs: Mapped[list] = mapped_column(JSON, default=list)
+ quote: Mapped[str] = mapped_column(Text, default="")
+ quote_author: Mapped[str] = mapped_column(Text, default="")
+ stat1_value: Mapped[str] = mapped_column(String(32), default="")
+ stat1_label: Mapped[str] = mapped_column(String(128), default="")
+ stat2_value: Mapped[str] = mapped_column(String(32), default="")
+ stat2_label: Mapped[str] = mapped_column(String(128), default="")
+ link_label: Mapped[str] = mapped_column(String(128), default="")
+ link_href: Mapped[str] = mapped_column(String(128), default="")
+ bento: Mapped[list] = mapped_column(JSON, default=list) # [{src, alt}]
+
+
+class SectionHeadingContent(Base):
+ """تیتر و توضیح بخشها — فرانتاند بر اساس key آن را پیدا میکند."""
+
+ __tablename__ = "section_headings"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ key: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+ label: Mapped[str] = mapped_column(String(64), default="")
+ title: Mapped[str] = mapped_column(String(256), default="")
+ lead: Mapped[str] = mapped_column(Text, default="")
+ image: Mapped[str] = mapped_column(Text, default="")
+ cta_label: Mapped[str] = mapped_column(String(128), default="")
+ cta_href: Mapped[str] = mapped_column(String(128), default="")
+
+
+class PageHeroContent(Base):
+ """هیرو بالای هر صفحه — key: about | services | projects | investment | blog | faq | contact."""
+
+ __tablename__ = "page_heroes"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
+ title: Mapped[str] = mapped_column(String(256), default="")
+ lead: Mapped[str] = mapped_column(Text, default="")
+ image: Mapped[str] = mapped_column(Text, default="")
+
+
+class InvestmentModel(Base):
+ __tablename__ = "investment_models"
+
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(String(256))
+ desc: Mapped[str] = mapped_column(Text, default="")
+ points: Mapped[list] = mapped_column(JSON, default=list)
+
+
+class ProcessStep(Base):
+ __tablename__ = "process_steps"
+
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ step: Mapped[str] = mapped_column(String(16), default="")
+ title: Mapped[str] = mapped_column(String(256))
+ desc: Mapped[str] = mapped_column(Text, default="")
+
+
+class Office(Base):
+ __tablename__ = "offices"
+
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ city: Mapped[str] = mapped_column(String(64))
+ address: Mapped[str] = mapped_column(Text, default="")
+ hours: Mapped[str] = mapped_column(String(128), default="")
+
+
+class GalleryImage(Base):
+ __tablename__ = "gallery_images"
+
+ position: Mapped[int] = mapped_column(Integer, default=0)
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ src: Mapped[str] = mapped_column(Text, default="")
+ alt: Mapped[str] = mapped_column(Text, default="")
+
+
+class StatusLabel(Base):
+ """برچسب فارسی وضعیت پروژه — key: done | progress."""
+
+ __tablename__ = "status_labels"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ key: Mapped[str] = mapped_column(String(16), unique=True, index=True)
+ label: Mapped[str] = mapped_column(String(64))
+
+
+class ContactSectionContent(Base):
+ """بخش فرم تماس — رکورد یگانه."""
+
+ __tablename__ = "contact_section"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ label: Mapped[str] = mapped_column(String(64), default="")
+ title: Mapped[str] = mapped_column(String(256), default="")
+ lead: Mapped[str] = mapped_column(Text, default="")
+ address_label: Mapped[str] = mapped_column(String(64), default="")
+ phone_label: Mapped[str] = mapped_column(String(64), default="")
+ email_label: Mapped[str] = mapped_column(String(64), default="")
+ name_label: Mapped[str] = mapped_column(String(64), default="")
+ name_placeholder: Mapped[str] = mapped_column(String(128), default="")
+ phone_field_label: Mapped[str] = mapped_column(String(64), default="")
+ phone_placeholder: Mapped[str] = mapped_column(String(64), default="")
+ note_label: Mapped[str] = mapped_column(String(64), default="")
+ note_optional_label: Mapped[str] = mapped_column(String(64), default="")
+ note_placeholder: Mapped[str] = mapped_column(String(256), default="")
+ submit_label: Mapped[str] = mapped_column(String(128), default="")
+ sending_label: Mapped[str] = mapped_column(String(64), default="")
+ success_title: Mapped[str] = mapped_column(String(256), default="")
+ success_text: Mapped[str] = mapped_column(Text, default="")
+ success_button_label: Mapped[str] = mapped_column(String(128), default="")
+
+
+class NewsletterSectionContent(Base):
+ """بخش خبرنامه — رکورد یگانه."""
+
+ __tablename__ = "newsletter"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ title: Mapped[str] = mapped_column(String(256), default="")
+ lead: Mapped[str] = mapped_column(Text, default="")
+ placeholder: Mapped[str] = mapped_column(String(128), default="")
+ button_label: Mapped[str] = mapped_column(String(64), default="")
+ sending_label: Mapped[str] = mapped_column(String(64), default="")
+ success_text: Mapped[str] = mapped_column(String(256), default="")
+
+
+class FooterContent(Base):
+ """متنهای فوتر — رکورد یگانه."""
+
+ __tablename__ = "footer"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ about_text: Mapped[str] = mapped_column(Text, default="")
+ pages_heading: Mapped[str] = mapped_column(String(64), default="")
+ services_heading: Mapped[str] = mapped_column(String(64), default="")
+ contact_heading: Mapped[str] = mapped_column(String(64), default="")
+ copyright: Mapped[str] = mapped_column(String(256), default="")
+ tagline: Mapped[str] = mapped_column(String(256), default="")
+ socials: Mapped[list] = mapped_column(JSON, default=list) # [{label, href, icon}]
+
+
+class SeoMeta(Base):
+ """عنوان و توضیح سئوی هر صفحه — page_key: home / about / services / ..."""
+
+ __tablename__ = "seo_meta"
+
+ id: Mapped[int] = mapped_column(primary_key=True)
+ page_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
+ title: Mapped[str] = mapped_column(String(256), default="")
+ description: Mapped[str] = mapped_column(Text, default="")
+
+
class ContactMessage(Base):
__tablename__ = "contact_messages"
diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py
new file mode 100644
index 0000000..ec35dc3
--- /dev/null
+++ b/backend/app/rate_limit.py
@@ -0,0 +1,49 @@
+"""محدودسازی تلاش ورود — جلوگیری از حمله brute-force روی /api/auth/login.
+
+پیشفرض: حداکثر ۵ ورود ناموفق در هر ۱۵ دقیقه برای هر IP؛ ورود موفق شمارنده را پاک میکند.
+شمارنده در حافظه همین پروسه است — با ریاستارت بکاند پاک میشود و همین کافی است.
+"""
+
+import time
+
+from fastapi import HTTPException, Request, status
+
+MAX_FAILED_ATTEMPTS = 5
+WINDOW_SECONDS = 15 * 60
+
+_failed: dict[str, list[float]] = {}
+
+
+def client_ip(request: Request) -> str:
+ forwarded = request.headers.get("x-forwarded-for")
+ if forwarded:
+ return forwarded.split(",")[0].strip()
+ return request.client.host if request.client else "unknown"
+
+
+def _prune(key: str, now: float) -> list[float]:
+ recent = [t for t in _failed.get(key, []) if now - t < WINDOW_SECONDS]
+ _failed[key] = recent
+ return recent
+
+
+def ensure_allowed(request: Request) -> None:
+ """اگر این IP سهمیه تلاش ناموفقش پر شده باشد، درخواست با 429 رد میشود."""
+ key = client_ip(request)
+ recent = _prune(key, time.monotonic())
+ if len(recent) >= MAX_FAILED_ATTEMPTS:
+ retry_after = max(1, int(WINDOW_SECONDS - (time.monotonic() - recent[0])))
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail="تلاشهای ناموفق زیاد است — بعداً دوباره امتحان کنید",
+ headers={"Retry-After": str(retry_after)},
+ )
+
+
+def record_failure(request: Request) -> None:
+ _failed.setdefault(client_ip(request), []).append(time.monotonic())
+
+
+def clear(request: Request) -> None:
+ """با ورود موفق، تاریخچه تلاشهای ناموفق IP پاک میشود."""
+ _failed.pop(client_ip(request), None)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index d9815db..0e5811e 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -1,7 +1,7 @@
from datetime import datetime
-from typing import Literal
+from typing import Literal, Optional
-from pydantic import BaseModel, ConfigDict, EmailStr, Field
+from pydantic import BaseModel, ConfigDict, EmailStr, Field, create_model, field_validator
# ---------- پایه ----------
@@ -58,7 +58,9 @@ class ArticleOut(ORMModel):
image: str
read: str
excerpt: str
- body: list[str]
+ body: dict
+ status: str
+ seo_keyphrase: str
class TestimonialOut(ORMModel):
@@ -112,6 +114,144 @@ class BeforeAfterOut(ORMModel):
after_label: str
+class NavItemOut(ORMModel):
+ id: int
+ label: str
+ href: str
+
+
+class HeroOut(ORMModel):
+ id: int
+ kicker: str
+ title: str
+ title_highlight: str
+ subtitle: str
+ badge_value: str
+ badge_label: str
+ cta_primary_label: str
+ cta_primary_href: str
+ cta_secondary_label: str
+ cta_secondary_href: str
+ scroll_label: str
+
+
+class StoryOut(ORMModel):
+ id: int
+ title: str
+ paragraphs: list[str]
+ quote: str
+ quote_author: str
+ stat1_value: str
+ stat1_label: str
+ stat2_value: str
+ stat2_label: str
+ link_label: str
+ link_href: str
+ bento: list[dict]
+
+
+class SectionHeadingOut(ORMModel):
+ id: int
+ key: str
+ label: str
+ title: str
+ lead: str
+ image: str
+ cta_label: str
+ cta_href: str
+
+
+class PageHeroOut(ORMModel):
+ id: int
+ key: str
+ title: str
+ lead: str
+ image: str
+
+
+class InvestmentModelOut(ORMModel):
+ id: int
+ title: str
+ desc: str
+ points: list[str]
+
+
+class ProcessStepOut(ORMModel):
+ id: int
+ step: str
+ title: str
+ desc: str
+
+
+class OfficeOut(ORMModel):
+ id: int
+ city: str
+ address: str
+ hours: str
+
+
+class GalleryImageOut(ORMModel):
+ id: int
+ src: str
+ alt: str
+
+
+class StatusLabelOut(ORMModel):
+ id: int
+ key: str
+ label: str
+
+
+class ContactSectionOut(ORMModel):
+ id: int
+ label: str
+ title: str
+ lead: str
+ address_label: str
+ phone_label: str
+ email_label: str
+ name_label: str
+ name_placeholder: str
+ phone_field_label: str
+ phone_placeholder: str
+ note_label: str
+ note_optional_label: str
+ note_placeholder: str
+ submit_label: str
+ sending_label: str
+ success_title: str
+ success_text: str
+ success_button_label: str
+
+
+class NewsletterOut(ORMModel):
+ id: int
+ title: str
+ lead: str
+ placeholder: str
+ button_label: str
+ sending_label: str
+ success_text: str
+
+
+class FooterOut(ORMModel):
+ id: int
+ about_text: str
+ pages_heading: str
+ services_heading: str
+ contact_heading: str
+ copyright: str
+ tagline: str
+ socials: list[dict]
+
+
+class SeoMetaOut(ORMModel):
+ id: int
+ page_key: str
+ title: str
+ description: str
+
+
class ContactMessageOut(ORMModel):
id: int
name: str
@@ -154,6 +294,11 @@ class TokenOut(BaseModel):
token_type: str = "bearer"
+class ChangePasswordIn(BaseModel):
+ current_password: str
+ new_password: str = Field(min_length=8, max_length=128)
+
+
class BrandIn(BaseModel):
name: str = ""
logo: str = ""
@@ -187,6 +332,18 @@ class ProjectIn(BaseModel):
body: list[str] = []
+EMPTY_DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
+
+
+def validate_doc_body(value: object) -> dict:
+ """بدنه مقاله باید یک سند TipTap (ProseMirror) معتبر از نظر ساختار باشد."""
+ if not value:
+ return dict(EMPTY_DOC)
+ if not isinstance(value, dict) or value.get("type") != "doc" or not isinstance(value.get("content"), list):
+ raise ValueError("body must be a TipTap doc: {type: 'doc', content: [...]}")
+ return value
+
+
class ArticleIn(BaseModel):
slug: str = Field(min_length=1, max_length=128)
title: str = Field(min_length=1, max_length=256)
@@ -195,7 +352,10 @@ class ArticleIn(BaseModel):
image: str = ""
read: str = ""
excerpt: str = ""
- body: list[str] = []
+ body: dict = Field(default_factory=lambda: dict(EMPTY_DOC))
+ status: Literal["draft", "published"] = "draft"
+ seo_keyphrase: str = Field(default="", max_length=128)
+ _validate_body = field_validator("body")(validate_doc_body)
class TestimonialIn(BaseModel):
@@ -242,5 +402,187 @@ class BeforeAfterIn(BaseModel):
after_label: str = ""
+class NavItemIn(BaseModel):
+ label: str = Field(min_length=1, max_length=64)
+ href: str = Field(min_length=1, max_length=128)
+
+
+class HeroIn(BaseModel):
+ kicker: str = ""
+ title: str = ""
+ title_highlight: str = ""
+ subtitle: str = ""
+ badge_value: str = ""
+ badge_label: str = ""
+ cta_primary_label: str = ""
+ cta_primary_href: str = ""
+ cta_secondary_label: str = ""
+ cta_secondary_href: str = ""
+ scroll_label: str = ""
+
+
+class StoryIn(BaseModel):
+ title: str = ""
+ paragraphs: list[str] = []
+ quote: str = ""
+ quote_author: str = ""
+ stat1_value: str = ""
+ stat1_label: str = ""
+ stat2_value: str = ""
+ stat2_label: str = ""
+ link_label: str = ""
+ link_href: str = ""
+ bento: list[dict] = []
+
+
+class SectionHeadingIn(BaseModel):
+ key: str = Field(min_length=1, max_length=64)
+ label: str = ""
+ title: str = ""
+ lead: str = ""
+ image: str = ""
+ cta_label: str = ""
+ cta_href: str = ""
+
+
+class PageHeroIn(BaseModel):
+ key: str = Field(min_length=1, max_length=32)
+ title: str = ""
+ lead: str = ""
+ image: str = ""
+
+
+class InvestmentModelIn(BaseModel):
+ title: str = Field(min_length=1, max_length=256)
+ desc: str = ""
+ points: list[str] = []
+
+
+class ProcessStepIn(BaseModel):
+ step: str = ""
+ title: str = Field(min_length=1, max_length=256)
+ desc: str = ""
+
+
+class OfficeIn(BaseModel):
+ city: str = Field(min_length=1, max_length=64)
+ address: str = ""
+ hours: str = ""
+
+
+class GalleryImageIn(BaseModel):
+ src: str = ""
+ alt: str = ""
+
+
+class StatusLabelIn(BaseModel):
+ key: str = Field(min_length=1, max_length=16)
+ label: str = Field(min_length=1, max_length=64)
+
+
+class ContactSectionIn(BaseModel):
+ label: str = ""
+ title: str = ""
+ lead: str = ""
+ address_label: str = ""
+ phone_label: str = ""
+ email_label: str = ""
+ name_label: str = ""
+ name_placeholder: str = ""
+ phone_field_label: str = ""
+ phone_placeholder: str = ""
+ note_label: str = ""
+ note_optional_label: str = ""
+ note_placeholder: str = ""
+ submit_label: str = ""
+ sending_label: str = ""
+ success_title: str = ""
+ success_text: str = ""
+ success_button_label: str = ""
+
+
+class NewsletterSectionIn(BaseModel):
+ title: str = ""
+ lead: str = ""
+ placeholder: str = ""
+ button_label: str = ""
+ sending_label: str = ""
+ success_text: str = ""
+
+
+class FooterIn(BaseModel):
+ about_text: str = ""
+ pages_heading: str = ""
+ services_heading: str = ""
+ contact_heading: str = ""
+ copyright: str = ""
+ tagline: str = ""
+ socials: list[dict] = []
+
+
+class SeoMetaIn(BaseModel):
+ page_key: str = Field(min_length=1, max_length=32)
+ title: str = ""
+ description: str = ""
+
+
+class SeoMetaPatch(BaseModel):
+ page_key: Optional[str] = None
+ title: Optional[str] = None
+ description: Optional[str] = None
+
+
class MessageReadIn(BaseModel):
is_read: bool = True
+
+
+# ---------- اسکیماهای PATCH (ویرایش جزئی — همه فیلدها اختیاری) ----------
+
+
+def _make_patch(name: str, model_cls: type[BaseModel]) -> type[BaseModel]:
+ """از اسکیمای ورودی، اسکیمای PATCH میسازد؛ فیلدهای ارسالنشده دستنخورده میمانند."""
+ fields = {fname: (Optional[f.annotation], None) for fname, f in model_cls.model_fields.items()}
+ return create_model(name, **fields)
+
+
+BrandPatch = _make_patch("BrandPatch", BrandIn)
+ServicePatch = _make_patch("ServicePatch", ServiceIn)
+ProjectPatch = _make_patch("ProjectPatch", ProjectIn)
+class ArticlePatch(BaseModel):
+ slug: Optional[str] = None
+ title: Optional[str] = None
+ date: Optional[str] = None
+ category: Optional[str] = None
+ image: Optional[str] = None
+ read: Optional[str] = None
+ excerpt: Optional[str] = None
+ body: Optional[dict] = None
+ status: Optional[Literal["draft", "published"]] = None
+ seo_keyphrase: Optional[str] = None
+ _validate_body = field_validator("body")(validate_doc_body)
+TestimonialPatch = _make_patch("TestimonialPatch", TestimonialIn)
+FaqPatch = _make_patch("FaqPatch", FaqIn)
+TeamMemberPatch = _make_patch("TeamMemberPatch", TeamMemberIn)
+TimelineItemPatch = _make_patch("TimelineItemPatch", TimelineItemIn)
+StatPatch = _make_patch("StatPatch", StatIn)
+WhyUsItemPatch = _make_patch("WhyUsItemPatch", WhyUsItemIn)
+BeforeAfterPatch = _make_patch("BeforeAfterPatch", BeforeAfterIn)
+NavItemPatch = _make_patch("NavItemPatch", NavItemIn)
+HeroPatch = _make_patch("HeroPatch", HeroIn)
+StoryPatch = _make_patch("StoryPatch", StoryIn)
+SectionHeadingPatch = _make_patch("SectionHeadingPatch", SectionHeadingIn)
+PageHeroPatch = _make_patch("PageHeroPatch", PageHeroIn)
+InvestmentModelPatch = _make_patch("InvestmentModelPatch", InvestmentModelIn)
+ProcessStepPatch = _make_patch("ProcessStepPatch", ProcessStepIn)
+OfficePatch = _make_patch("OfficePatch", OfficeIn)
+GalleryImagePatch = _make_patch("GalleryImagePatch", GalleryImageIn)
+StatusLabelPatch = _make_patch("StatusLabelPatch", StatusLabelIn)
+ContactSectionPatch = _make_patch("ContactSectionPatch", ContactSectionIn)
+NewsletterSectionPatch = _make_patch("NewsletterSectionPatch", NewsletterSectionIn)
+FooterPatch = _make_patch("FooterPatch", FooterIn)
+
+
+class ReorderIn(BaseModel):
+ """ترتیب جدید آیتمهای یک مجموعه بر اساس id."""
+
+ ids: list[int]
diff --git a/backend/app/seed.py b/backend/app/seed.py
index 545f561..07b091e 100644
--- a/backend/app/seed.py
+++ b/backend/app/seed.py
@@ -1,16 +1,50 @@
"""اسکریپت seed دیتابیس.
اجرا:
- python -m app.seed # فقط اگر دیتابیس خالی باشد
+ python -m app.seed # فقط جدولهای خالی پر میشوند
python -m app.seed --force # حذف کامل دادهها و ساخت مجدد
+
+پر کردن هر جدول مستقل است: روی یک دیتابیس موجود، فقط جدولهای جدید یا خالی
+seed میشوند و دادههای قبلی دستنخورده میمانند.
"""
import argparse
+from typing import Any
from . import models, seed_data
from .auth import hash_password
from .config import settings
from .database import Base, SessionLocal, engine
+from .migrations import paragraphs_to_doc, run_all as run_migrations
+
+# (مدل، دادههای اولیه، برچسب گزارش)
+CONTENT: list[tuple[Any, list[dict], str]] = [
+ (models.Brand, [seed_data.BRAND], "برند"),
+ (models.Hero, [seed_data.HERO], "هیرو صفحه اصلی"),
+ (models.StorySection, [seed_data.STORY], "داستان ما"),
+ (models.SectionHeadingContent, seed_data.SECTION_HEADINGS, "تیتر بخشها"),
+ (models.PageHeroContent, seed_data.PAGE_HEROES, "هیرو صفحات"),
+ (models.NavItem, seed_data.NAV_ITEMS, "منوی ناوبری"),
+ (models.InvestmentModel, seed_data.INVESTMENT_MODELS, "مدلهای سرمایهگذاری"),
+ (models.ProcessStep, seed_data.PROCESS_STEPS, "مراحل فرآیند کار"),
+ (models.Office, seed_data.OFFICES, "دفاتر"),
+ (models.GalleryImage, seed_data.GALLERY_IMAGES, "گالری تصاویر"),
+ (models.StatusLabel, seed_data.STATUS_LABELS, "برچسب وضعیت پروژه"),
+ (models.ContactSectionContent, [seed_data.CONTACT_SECTION], "بخش تماس"),
+ (models.NewsletterSectionContent, [seed_data.NEWSLETTER], "خبرنامه"),
+ (models.FooterContent, [seed_data.FOOTER], "فوتر"),
+ (models.Service, seed_data.SERVICES, "خدمات"),
+ (models.Stat, seed_data.STATS, "آمار"),
+ (models.Project, seed_data.PROJECTS, "پروژهها"),
+ (models.Article, seed_data.ARTICLES, "مقالات"),
+ (models.Testimonial, seed_data.TESTIMONIALS, "نظرات"),
+ (models.Faq, seed_data.FAQS, "سوالات متداول"),
+ (models.TeamMember, seed_data.TEAM, "تیم"),
+ (models.TimelineItem, seed_data.TIMELINE, "تایملاین"),
+ (models.WhyUsItem, seed_data.WHY, "چرا آرامش"),
+ (models.BeforeAfter, [seed_data.BEFORE_AFTER], "قبل/بعد"),
+ (models.SeoMeta, seed_data.SEO_META, "سئوی صفحات"),
+]
def seed(force: bool = False) -> None:
@@ -21,32 +55,19 @@ def seed(force: bool = False) -> None:
print("جداول قبلی حذف شدند.")
Base.metadata.create_all(bind=engine)
+ run_migrations()
- if not force and db.query(models.Service).count() > 0:
- print("دیتابیس قبلاً seed شده است. برای بازنشانی کامل: python -m app.seed --force")
- return
-
- db.add(models.Brand(**seed_data.BRAND))
-
- for item in seed_data.SERVICES:
- db.add(models.Service(**item))
- for item in seed_data.STATS:
- db.add(models.Stat(**item))
- for item in seed_data.PROJECTS:
- db.add(models.Project(**item))
- for item in seed_data.ARTICLES:
- db.add(models.Article(**item))
- for item in seed_data.TESTIMONIALS:
- db.add(models.Testimonial(**item))
- for item in seed_data.FAQS:
- db.add(models.Faq(**item))
- for item in seed_data.TEAM:
- db.add(models.TeamMember(**item))
- for item in seed_data.TIMELINE:
- db.add(models.TimelineItem(**item))
- for item in seed_data.WHY:
- db.add(models.WhyUsItem(**item))
- db.add(models.BeforeAfter(**seed_data.BEFORE_AFTER))
+ for model, rows, label in CONTENT:
+ if db.query(model).count() > 0:
+ continue
+ for idx, row in enumerate(rows):
+ payload = dict(row)
+ if hasattr(model, "position") and "position" not in payload:
+ payload["position"] = idx
+ # بدنه مقالات در seed بهصورت لیست پاراگراف است — به سند تبدیل میشود
+ if model is models.Article and isinstance(payload.get("body"), list):
+ payload["body"] = paragraphs_to_doc(payload["body"])
+ db.add(model(**payload))
if db.query(models.AdminUser).count() == 0:
db.add(
@@ -59,19 +80,8 @@ def seed(force: bool = False) -> None:
db.commit()
- counts = {
- "خدمات": db.query(models.Service).count(),
- "پروژهها": db.query(models.Project).count(),
- "مقالات": db.query(models.Article).count(),
- "نظرات": db.query(models.Testimonial).count(),
- "سوالات متداول": db.query(models.Faq).count(),
- "تیم": db.query(models.TeamMember).count(),
- "تایملاین": db.query(models.TimelineItem).count(),
- "آمار": db.query(models.Stat).count(),
- "چرا آرامش": db.query(models.WhyUsItem).count(),
- }
- for label, count in counts.items():
- print(f" {label}: {count} رکورد")
+ for model, _, label in CONTENT:
+ print(f" {label}: {db.query(model).count()} رکورد")
print("✔ seed با موفقیت انجام شد.")
finally:
db.close()
diff --git a/backend/app/seed_data.py b/backend/app/seed_data.py
index 848619d..803bf8d 100644
--- a/backend/app/seed_data.py
+++ b/backend/app/seed_data.py
@@ -2,9 +2,9 @@
BRAND = {
"name": "گروه ساختمانی آرامش",
- "logo": "/images/logo.png",
- "hero": "/images/hero.png",
- "contact_bg": "/images/contact.png",
+ "logo": "https://i.postimg.cc/XYNrsHsM/Chat-GPT-Image-Aug-6-2026-04-34-46-PM.png",
+ "hero": "https://i.postimg.cc/J0bKgQVg/Gemini-Generated-Image-mdh969mdh969mdh9.png",
+ "contact_bg": "https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png",
"phone": "۰۲۱ ۸۸ ۷۴ ۲۲ ۱۰",
"phone_href": "tel:+982188742210",
"email": "info@arameshbuildinggroup.com",
@@ -12,11 +12,11 @@ BRAND = {
}
IMAGES = [
- "/images/img1.png",
- "/images/img2.png",
- "/images/contact.png",
- "/images/img4.png",
- "/images/img5.png",
+ "https://i.postimg.cc/wTQ0QFkG/Gemini-Generated-Image-1tjszd1tjszd1tjs.png",
+ "https://i.postimg.cc/1zfBPS1G/Gemini-Generated-Image-at69yhat69yhat69.png",
+ "https://i.postimg.cc/BbccNQHr/Gemini-Generated-Image-dafc2hdafc2hdafc.png",
+ "https://i.postimg.cc/4xFvW0HX/Gemini-Generated-Image-n7mhonn7mhonn7mh.png",
+ "https://i.postimg.cc/N036cr4s/Gemini-Generated-Image-ytbhvvytbhvvytbh.png",
]
SERVICES = [
@@ -154,8 +154,8 @@ PROJECTS = [
BEFORE_AFTER = {
"title": "از کانسپت تا خلق واقعیت",
"lead": "دستگیره را بکشید و ببینید نمای ساختمانی چگونه از حالت اولیه به فرم مدرن تبدیل میشود.",
- "before": "/images/before.webp",
- "after": "/images/after.webp",
+ "before": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_before.webp",
+ "after": "https://cdn.housedesign.ai/se/housedesign/assets/housedesign-home/compare/1_after.webp",
"before_label": "کانسپت اولیه",
"after_label": "پروژه تکمیلشده",
}
@@ -289,3 +289,170 @@ FAQS = [
{"question": "خدمات پس از تحویل شامل چه مواردی میشود؟", "answer": "پنج سال پشتیبانی فنی تأسیسات مکانیکی و برقی، بازدید دورهای سالانه و تضمین اجرای نما و عایقکاری."},
{"question": "پروژهها در چه شهرهایی اجرا میشوند؟", "answer": "در حال حاضر تهران، اصفهان و سواحل شمالی کشور مناطق اصلی فعالیت ما هستند و پروژهای نیز در جزیره کیش در دست ساخت است."},
]
+
+# ---------- محتوای قبلاً هاردکدشده در فرانتاند (الان در دیتابیس) ----------
+
+NAV_ITEMS = [
+ {"label": "صفحه اصلی", "href": "/"},
+ {"label": "درباره ما", "href": "/about"},
+ {"label": "خدمات", "href": "/services"},
+ {"label": "پروژهها", "href": "/projects"},
+ {"label": "سرمایهگذاری", "href": "/investment"},
+ {"label": "وبلاگ", "href": "/blog"},
+ {"label": "سوالات متداول", "href": "/faq"},
+ {"label": "تماس با ما", "href": "/contact"},
+]
+
+HERO = {
+ "kicker": "از سال ۱۳۸۳ — تهران، اصفهان، سواحل شمال",
+ "title": "ساختمانی که آرامش را معماری میکند",
+ "title_highlight": "معماری",
+ "subtitle": "طراحی، ساخت و سرمایهگذاری در پروژههای مسکونی و تجاری لوکس؛ با استانداردی که در جزئیات دیده میشود و در سالها دوام میآورد.",
+ "badge_value": "۴۷",
+ "badge_label": "پروژه تحویلشده در بیستویک سال فعالیت حرفهای",
+ "cta_primary_label": "مشاهده پروژهها",
+ "cta_primary_href": "/projects",
+ "cta_secondary_label": "مشاوره سرمایهگذاری",
+ "cta_secondary_href": "/contact",
+ "scroll_label": "اسکرول کنید",
+}
+
+STORY = {
+ "title": "درباره ی ما",
+ "paragraphs": [
+ "گروه ساختمانی آرامش با یک دفتر کوچک معماری آغاز شد و امروز یکی از مجموعههای شناختهشده ساختوساز لوکس در ایران است. در تمام این سالها یک اصل تغییر نکرده است: ساختمان باید برای زندگی ساخته شود، نه برای فروش سریع.",
+ "هر پروژه با مطالعه زمین، نور، اقلیم و الگوی زندگی ساکنان آغاز میشود؛ سپس تیم طراحی، سازه و اجرا در یک فرآیند واحد آن را تا تحویل کلید پیش میبرند.",
+ ],
+ "quote": "«ساختمان برای ما فقط آجر و بتن نیست؛ بلکه تعهدی است به کیفیت زندگی ساکنان. ما فضاهایی میسازیم که در گذر زمان ارزش و هویت خود را حفظ کنند.»",
+ "quote_author": "کامران آرامش — مدیرعامل شرکت ساختمانی آرامش",
+ "stat1_value": "۹۴٪",
+ "stat1_label": "تحویل بهموقع پروژهها",
+ "stat2_value": "۵ سال",
+ "stat2_label": "پشتیبانی پس از تحویل",
+ "link_label": "داستان کامل ما",
+ "link_href": "/about",
+ "bento": [
+ {"src": IMAGES[0], "alt": "نمای برج مسکونی آرامش در تهران"},
+ {"src": IMAGES[1], "alt": "آتریوم مرکزی مجتمع تجاری پارسه"},
+ {"src": IMAGES[2], "alt": "ویلاهای ساختهشده در سواحل شمال"},
+ {"src": IMAGES[3], "alt": "نمای شبانه برج دوقلوی نیاوران"},
+ ],
+}
+
+SECTION_HEADINGS = [
+ {"key": "services", "title": "خدمات", "lead": "از نخستین خط روی کاغذ تا تحویل کلید، مدیریت سرمایه و طراحی محوطه؛ همه مراحل در یک مجموعه و زیر یک نظام کنترل کیفیت."},
+ {"key": "featured_projects", "title": "پروژههای شاخص", "lead": "کارهایی که بیشترین چیز را درباره استاندارد ما میگویند؛ چه آنهایی که تحویل شدهاند و چه آنهایی که همین امروز در حال ساختاند.", "cta_label": "همه پروژهها", "cta_href": "/projects"},
+ {"key": "gallery", "title": "گالری تصاویر", "lead": "قابهایی از کارگاهها و پروژههای تحویلشده گروه ساختمانی آرامش."},
+ {"key": "timeline", "title": "مسیر ما", "lead": "از یک دفتر چهارنفره تا ۴۷ پروژه. خط زمان پروژههای ما را ببینید."},
+ {"key": "why_us", "title": "چرا گروه ساختمانی آرامش", "lead": "شش دلیلی که کارفرمایان و سرمایهگذاران بار دوم هم به ما برمیگردند.", "image": IMAGES[3]},
+ {"key": "testimonials", "title": "نظرات مشتریان"},
+ {"key": "team", "title": "تیم ما", "lead": "کسانی که تصمیمهای اصلی هر پروژه را میگیرند و پاسخگوی آن هستند."},
+ {"key": "articles", "label": "وبلاگ", "title": "آخرین مقالات", "lead": "یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد.", "cta_label": "همه مقالات", "cta_href": "/blog"},
+ {"key": "faq", "title": "سوالات متداول", "lead": "پاسخ کوتاه به پرتکرارترین پرسشهای سرمایهگذاران و خریداران."},
+ {"key": "investment_models", "title": "مدلهای همکاری", "lead": "بسته به میزان سرمایه، افق زمانی و سطح ریسکی که میپذیرید، یکی از این سه مسیر مناسب شماست."},
+ {"key": "process", "title": "فرآیند کار ما", "lead": "چهار مرحله شفاف، از نخستین جلسه تا تحویل کلید."},
+]
+
+PAGE_HEROES = [
+ {"key": "about", "title": "درباره ما", "lead": "گروه ساختمانی آرامش مجموعهای از معماران، مهندسان سازه و مدیران پروژه است که از سال ۱۳۸۳ در حوزه ساختوساز لوکس فعالیت میکند.", "image": IMAGES[3]},
+ {"key": "services", "title": "خدمات", "lead": "هشت حوزه تخصصی که چرخه کامل یک پروژه ساختمانی را از ایده تا بهرهبرداری پوشش میدهند.", "image": IMAGES[1]},
+ {"key": "projects", "title": "پروژهها", "lead": "مجموعهای از برجهای مسکونی، مراکز تجاری و ویلاهای اختصاصی؛ از پروژههای تحویلشده تا کارگاههای فعال امروز.", "image": IMAGES[0]},
+ {"key": "investment", "title": "سرمایهگذاری", "lead": "سه مسیر روشن برای ورود به بازار ساختوساز لوکس، با ساختار مالی شفاف و گزارشدهی منظم.", "image": IMAGES[4]},
+ {"key": "blog", "title": "وبلاگ", "lead": "یادداشتهای تیم معماری، اجرا و سرمایهگذاری گروه ساختمانی آرامش درباره آنچه در کارگاه و بازار میگذرد.", "image": IMAGES[2]},
+ {"key": "faq", "title": "سوالات متداول", "lead": "پیش از تماس، شاید پاسخ پرسش شما همینجا باشد. اگر نبود، خوشحال میشویم مستقیم صحبت کنیم.", "image": IMAGES[1]},
+ {"key": "contact", "title": "تماس با ما", "lead": "برای مشاوره ساخت، سرمایهگذاری یا بازدید از پروژهها، تیم ما در سه شهر آماده پاسخگویی است.", "image": IMAGES[1]},
+]
+
+INVESTMENT_MODELS = [
+ {
+ "title": "مشارکت در ساخت",
+ "desc": "مالک زمین و گروه ساختمانی آرامش در سود پروژه شریک میشوند؛ اجرا، مجوزها و فروش بر عهده ماست.",
+ "points": ["ارزیابی رایگان زمین", "قرارداد شفاف با ضمانت اجرا", "گزارش ماهانه پیشرفت"],
+ },
+ {
+ "title": "صندوق پروژهمحور",
+ "desc": "سرمایهگذاری روی یک پروژه مشخص با واحدهای قابل خرید و بازدهی محاسبهشده در پایان دوره.",
+ "points": ["حداقل دوره ۲۴ ماه", "گزارش ارزشگذاری فصلی", "امکان انتقال واحد سرمایه"],
+ },
+ {
+ "title": "پیشخرید واحد",
+ "desc": "خرید واحد مسکونی یا تجاری در مراحل ابتدایی ساخت با قیمت پیش از تکمیل و پرداخت مرحلهای.",
+ "points": ["پرداخت مرحلهای متناسب با پیشرفت", "امکان شخصیسازی پلان", "تضمین زمان تحویل"],
+ },
+]
+
+PROCESS_STEPS = [
+ {"step": "۰۱", "title": "مطالعه و امکانسنجی", "desc": "بررسی زمین، ضوابط شهری و تحلیل بازدهی اقتصادی پروژه."},
+ {"step": "۰۲", "title": "طراحی و فاز اجرایی", "desc": "کانسپت معماری، طراحی سازه و تأسیسات و تهیه نقشههای اجرایی."},
+ {"step": "۰۳", "title": "اجرا و کنترل کیفیت", "desc": "ساخت با نظارت مستمر، گزارش ماهانه پیشرفت و کنترل هزینه."},
+ {"step": "۰۴", "title": "تحویل و پشتیبانی", "desc": "تحویل کلید، آموزش بهرهبرداری و پنج سال پشتیبانی فنی."},
+]
+
+OFFICES = [
+ {"city": "تهران", "address": "خیابان ولیعصر، برج آرامش، طبقه هجدهم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۸"},
+ {"city": "اصفهان", "address": "خیابان چهارباغ بالا، مجتمع پارسه، طبقه ششم", "hours": "شنبه تا چهارشنبه، ۹ تا ۱۷"},
+ {"city": "نوشهر", "address": "بلوار دریا، دفتر پروژه دریاکنار", "hours": "شنبه تا پنجشنبه، ۱۰ تا ۱۶"},
+]
+
+GALLERY_IMAGES = [
+ {"src": IMAGES[0], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۱"},
+ {"src": IMAGES[1], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۲"},
+ {"src": IMAGES[2], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۳"},
+ {"src": IMAGES[3], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۴"},
+ {"src": IMAGES[4], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۵"},
+ {"src": IMAGES[1], "alt": "تصویری از پروژههای گروه ساختمانی آرامش، شماره ۶"},
+]
+
+STATUS_LABELS = [
+ {"key": "done", "label": "انجام شده"},
+ {"key": "progress", "label": "در حال انجام"},
+]
+
+CONTACT_SECTION = {
+ "label": "تماس با ما",
+ "title": "پروژهتان را با ما در میان بگذارید",
+ "lead": "شمارهتان را بگذارید؛ کارشناس ما در کمتر از یک روز کاری برای یک جلسه مشاوره رایگان تماس میگیرد.",
+ "address_label": "دفتر مرکزی",
+ "phone_label": "تلفن",
+ "email_label": "ایمیل",
+ "name_label": "نام",
+ "name_placeholder": "نام و نام خانوادگی",
+ "phone_field_label": "شماره تلفن",
+ "phone_placeholder": "09xx xxx xxxx",
+ "note_label": "توضیحات",
+ "note_optional_label": "(اختیاری)",
+ "note_placeholder": "درباره پروژه یا درخواست خود بنویسید…",
+ "submit_label": "ارسال درخواست مشاوره",
+ "sending_label": "در حال ارسال…",
+ "success_title": "درخواست شما ثبت شد",
+ "success_text": "از اعتماد شما سپاسگزاریم. کارشناسان ما بهزودی با شما تماس میگیرند.",
+ "success_button_label": "ثبت درخواست جدید",
+}
+
+NEWSLETTER = {
+ "title": "خبرنامه گروه ساختمانی آرامش",
+ "lead": "هر ماه یک ایمیل: پروژههای جدید، فرصتهای سرمایهگذاری و یادداشتهای تیم معماری.",
+ "placeholder": "نشانی ایمیل شما",
+ "button_label": "عضویت",
+ "sending_label": "در حال ارسال…",
+ "success_text": "عضویت شما ثبت شد.",
+}
+
+FOOTER = {
+ "about_text": "گروه ساختمانی آرامش از سال ۱۳۸۳ ساختمانهایی میسازد که قرار است چند نسل در آنها زندگی کنند؛ معماری آرام، اجرای دقیق و سرمایهگذاری شفاف.",
+ "pages_heading": "صفحات",
+ "services_heading": "خدمات",
+ "contact_heading": "ارتباط با ما",
+ "copyright": "© ۱۴۰۳ گروه ساختمانی آرامش — تمامی حقوق محفوظ است.",
+ "tagline": "طراحی و ساخت با دقتِ معماری",
+ "socials": [
+ {"label": "اینستاگرام", "href": "#", "icon": "instagram"},
+ {"label": "لینکدین", "href": "#", "icon": "linkedin"},
+ {"label": "تلگرام", "href": "#", "icon": "telegram"},
+ ],
+}
+
+SEO_META = [
+ {"page_key": key, "title": "", "description": ""}
+ for key in ["home", "about", "services", "projects", "investment", "blog", "faq", "contact"]
+]
diff --git a/backend/app/seo_pages.py b/backend/app/seo_pages.py
new file mode 100644
index 0000000..6df8cea
--- /dev/null
+++ b/backend/app/seo_pages.py
@@ -0,0 +1,239 @@
+"""سئوی سمت سرور — robots.txt، sitemap.xml و سرو HTML صفحات وبلاگ/پروژه با متای تزریقشده.
+
+فرانتاند یک SPA است؛ خزندههای اجتماعی (تلگرام، واتساپ و...) جاوااسکریپت اجرا نمیکنند.
+این ماژول همان index.html بیلد فرانتاند را میخواند و پیش از ارسال، تگهای
+title / description / OG / canonical / JSON-LD را از دیتابیس داخل آن تزریق میکند —
+بازدیدکنندههای عادی همان SPA همیشگی را میبینند و خزندهها متای درست را میخوانند.
+"""
+
+import html
+import json
+import re
+from pathlib import Path
+
+from fastapi import APIRouter, Depends, Response
+from fastapi.responses import HTMLResponse
+from sqlalchemy.orm import Session
+
+from . import models
+from .config import settings
+from .database import get_db
+
+router = APIRouter()
+
+STATIC_PATHS = ["/", "/about", "/services", "/projects", "/investment", "/blog", "/faq", "/contact"]
+
+
+def _absolute(path: str) -> str:
+ if not path:
+ return ""
+ if path.startswith(("http://", "https://")):
+ return path
+ site = settings.site_url.rstrip("/")
+ return f"{site}{path}" if site else path
+
+
+def _read_index() -> str | None:
+ try:
+ return (Path(settings.frontend_dist) / "index.html").read_text(encoding="utf-8")
+ except OSError:
+ return None
+
+
+def _doc_text(doc: object, limit: int = 300) -> str:
+ """متن ساده سند TipTap — برای noscript و پیشنمایش."""
+ words: list[str] = []
+
+ def walk(node: object) -> None:
+ if not isinstance(node, dict):
+ return
+ if node.get("type") == "text" and isinstance(node.get("text"), str):
+ words.append(node["text"])
+ for child in node.get("content") or []:
+ walk(child)
+
+ if isinstance(doc, dict):
+ for block in doc.get("content") or []:
+ walk(block)
+ text = " ".join(words).strip()
+ return text[:limit] + ("…" if len(text) > limit else "")
+
+
+def _shell(
+ *,
+ title: str,
+ description: str,
+ path: str,
+ og_image: str = "",
+ og_type: str = "website",
+ jsonld: dict | None = None,
+ noscript: str = "",
+) -> HTMLResponse:
+ """index.html فرانتاند با متای تزریقشده؛ اگر بیلد در دسترس نبود، صفحه حداقلی."""
+ title = title.strip() or "گروه ساختمانی آرامش"
+ description = description.strip()
+ canonical = _absolute(path)
+ image = _absolute(og_image)
+ esc = html.escape
+
+ # تگ title جای تگ موجود index.html را میگیرد و در بلوک تکرار نمیشود
+ tags = [
+ f'',
+ f'',
+ f'',
+ f'',
+ f'',
+ f'',
+ ]
+ if image:
+ tags.append(f'')
+ if jsonld:
+ payload = json.dumps(jsonld, ensure_ascii=False).replace("", "<\\/")
+ tags.append(f'')
+ block = "\n ".join(tags)
+
+ raw = _read_index()
+ if raw is None:
+ # بیلد فرانتاند سوار نشده — صفحه حداقلی با خودِ متا تا خزندهها دست خالی نمانند
+ body = f"{esc(title)}
{esc(description)}
"
+ return HTMLResponse(
+ f''
+ f"{esc(title)}{block}{body}"
+ )
+
+ raw = re.sub(r".*?", f"{esc(title)}", raw, count=1, flags=re.S)
+ raw = raw.replace("", f" {block}\n ", 1)
+ if noscript:
+ raw = raw.replace("
"
+ return _shell(
+ title=f"{article.title} | {brand}",
+ description=article.excerpt,
+ path=f"/blog/{article.slug}",
+ og_image=article.image,
+ og_type="article",
+ jsonld=jsonld,
+ noscript=noscript,
+ )
+
+
+# ---------- صفحات پروژه ----------
+
+
+@router.get("/projects", response_class=HTMLResponse)
+def projects_list(db: Session = Depends(get_db)):
+ meta = _page_meta(db, "projects")
+ return _shell(
+ title=meta.title if meta and meta.title else "پروژهها | " + _brand_name(db),
+ description=meta.description if meta else "",
+ path="/projects",
+ )
+
+
+@router.get("/projects/{slug}", response_class=HTMLResponse)
+def project_detail(slug: str, db: Session = Depends(get_db)):
+ project = db.query(models.Project).filter(models.Project.slug == slug).first()
+ if project is None:
+ return _shell(title=_brand_name(db), description="", path=f"/projects/{slug}")
+
+ brand = _brand_name(db)
+ return _shell(
+ title=f"{project.title} | {brand}",
+ description=project.summary,
+ path=f"/projects/{project.slug}",
+ og_image=project.image,
+ jsonld={
+ "@context": "https://schema.org",
+ "@type": "WebPage",
+ "name": project.title,
+ "description": project.summary,
+ "image": _absolute(project.image),
+ "inLanguage": "fa",
+ },
+ )
diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt
new file mode 100644
index 0000000..80e9d4a
--- /dev/null
+++ b/backend/requirements-dev.txt
@@ -0,0 +1,4 @@
+# وابستگیهای توسعه و تست — pip install -r requirements-dev.txt
+-r requirements.txt
+pytest==8.3.4
+httpx==0.27.2
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 76b1ecc..bc23690 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -1,9 +1,13 @@
-fastapi>=0.115.8
-uvicorn[standard]>=0.34.0
-sqlalchemy>=2.0.36
-psycopg[binary]>=3.2.3
-pydantic>=2.10.4
-pydantic-settings>=2.6.1
-PyJWT>=2.10.1
-bcrypt>=4.2.1
-email-validator>=2.2.0
+# نسخهها دقیق پین شدهاند تا ارتقای خودکار یک وابستگی، استقرار را نشکند.
+# ارتقا: نسخه را دستی بالا ببرید و روی محیط آمادهسازی تست کنید.
+fastapi==0.115.8
+uvicorn[standard]==0.34.0
+sqlalchemy==2.0.36
+psycopg[binary]==3.2.3
+pydantic==2.10.4
+pydantic-settings==2.6.1
+PyJWT==2.10.1
+bcrypt==4.2.1
+email-validator==2.2.0
+boto3==1.35.0
+python-multipart==0.0.18
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
new file mode 100644
index 0000000..7277948
--- /dev/null
+++ b/backend/tests/conftest.py
@@ -0,0 +1,93 @@
+"""پیکربندی تستها — دیتابیس SQLite فایلمحور و دادههای حداقلی.
+
+متغیرهای محیطی پیش از ایمپورت app تنظیم میشوند تا settings همانها را بخواند.
+"""
+
+import os
+import pathlib
+
+os.environ["DATABASE_URL"] = "sqlite:///./test.db?check_same_thread=false"
+os.environ["SECRET_KEY"] = "test-secret-key"
+os.environ["ADMIN_USERNAME"] = "admin"
+os.environ["ADMIN_PASSWORD"] = "test-admin-pass-1"
+os.environ["SITE_URL"] = "https://test.example"
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app import models
+from app.auth import hash_password
+from app.config import settings
+from app.database import Base, SessionLocal, engine
+from app.main import app
+
+TEST_DB = pathlib.Path("test.db")
+
+DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
+
+
+@pytest.fixture(scope="session", autouse=True)
+def seed_db(tmp_path_factory):
+ # اتصالات ساخت جدول در ایمپورت ماژول باید بسته شوند تا فایل قدیمی حذف شود
+ engine.dispose()
+ if TEST_DB.exists():
+ TEST_DB.unlink()
+ Base.metadata.create_all(bind=engine)
+ # dist ساختگی فرانتاند برای تست تزریق متا در صفحات جزئیات
+ dist = tmp_path_factory.mktemp("dist")
+ (dist / "index.html").write_text(
+ "