commit dba5ea21f9fa3ac1fd8c3d90348e64c5e59acd8c Author: Toornaa Date: Sat Aug 29 22:03:11 2026 +0330 Initial-MicroLearning-platform-with-MySQL-support diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d2d5513 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +.agents +.codex +**/.env +**/.env.* +!**/.env.*.example +**/node_modules +**/vendor +**/storage/logs/* +**/storage/framework/cache/* +**/storage/framework/sessions/* +**/storage/framework/views/* +**/bootstrap/cache/*.php +backups +coverage +frontend/dist diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..bb9f72b --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,63 @@ +name: quality +on: + push: + pull_request: +jobs: + backend: + runs-on: ubuntu-latest + services: + mysql: + image: mysql:8.4 + env: + MYSQL_DATABASE: microlearn_test + MYSQL_ROOT_PASSWORD: ci-only-mysql-password + ports: ['3306:3306'] + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -pci-only-mysql-password --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + env: + DB_CONNECTION: mysql + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: microlearn_test + DB_USERNAME: root + DB_PASSWORD: ci-only-mysql-password + defaults: { run: { working-directory: backend } } + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: { php-version: '8.3', extensions: mbstring, intl, pdo_mysql, dom, zip } + - run: composer install --no-interaction --prefer-dist + - run: cp .env.example .env && php artisan key:generate + - run: php artisan migrate --force + - run: composer exec pint -- --test + - run: php artisan test --compact + - run: composer audit + frontend: + runs-on: ubuntu-latest + defaults: { run: { working-directory: frontend } } + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: 22, cache: npm, cache-dependency-path: frontend/package-lock.json } + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm run build + - run: npm audit --audit-level=high + production-images: + runs-on: ubuntu-latest + env: + MICROLEARN_ENV_FILE: infrastructure/production/.env.production.example + MYSQL_PASSWORD: ci-only-mysql-password + MYSQL_ROOT_PASSWORD: ci-only-mysql-root-password + REDIS_PASSWORD: ci-only-redis-password + MINIO_ROOT_USER: ci-only-minio + MINIO_ROOT_PASSWORD: ci-only-minio-password + steps: + - uses: actions/checkout@v4 + - run: docker compose -f docker-compose.production.yml config --quiet + - run: docker compose -f docker-compose.production.yml build app web diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2315e96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +/.idea/ +/.vscode/ +/.DS_Store +/backend/.env +/backend/.env.testing +/backend/vendor/ +/backend/storage/*.key +/backend/storage/framework/cache/data/* +/backend/storage/framework/sessions/* +/backend/storage/framework/views/* +/backend/storage/logs/* +/frontend/node_modules/ +/frontend/dist/ +/frontend/.env.local +/frontend/playwright-report/ +/frontend/test-results/ +/.runtime/ +/infrastructure/production/.env.production +/infrastructure/on-premise/.env.on-premise +/backups/ +/*.log diff --git a/Prompt/Admin Prompt.md b/Prompt/Admin Prompt.md new file mode 100644 index 0000000..75a7869 --- /dev/null +++ b/Prompt/Admin Prompt.md @@ -0,0 +1,1770 @@ +# Master Prompt — MicroLearning Super Admin Panel Redesign + +می‌خواهم پنل **Super Admin / Platform Admin** پروژه MicroLearning را به‌صورت کامل، مرحله‌ای و کنترل‌شده اصلاح و ارتقا دهی. + +این پروژه نباید صرفاً یک UI Redesign باشد. + +هدف این است که پنل Super Admin به یک **Platform Operations & Administration Console** واقعی تبدیل شود. + +Super Admin باید بتواند کل پلتفرم را از نظر: + +* Organizations +* Subscriptions +* Platform Usage +* Storage +* AI +* Jobs & Queues +* Health +* Audit +* Security +* Platform Settings + +مدیریت و مانیتور کند. + +--- + +# اجرای پروژه + +این پروژه باید در **۶ فاز** انجام شود: + +```text +PHASE 0 +Admin Role, Security & Critical Bug Stabilization + +PHASE 1 +Organizations & Subscription Management + +PHASE 2 +Admin Dashboard & Platform Overview + +PHASE 3 +Platform Operations: Storage, AI, Health, Jobs & Backups + +PHASE 4 +Audit, Security, Administrators & Platform Settings + +PHASE 5 +Responsive, Accessibility, QA & Production Hardening +``` + +هر Phase را جداگانه انجام بده. + +بعد از پایان هر Phase: + +1. تغییرات را کامل کن. +2. Regression را بررسی کن. +3. Backend Tests را اجرا کن. +4. Frontend Tests را اجرا کن. +5. Lint را اجرا کن. +6. Typecheck را اجرا کن. +7. Build را اجرا کن. +8. `git diff` را بررسی کن. +9. فایل‌های تغییرکرده را اعلام کن. +10. Bugs Fixed را گزارش کن. +11. Remaining Issues را گزارش کن. +12. متوقف شو. + +بدون دستور: + +> برو فاز بعد + +وارد Phase بعد نشو. + +--- + +# CHANGE SAFETY PROTOCOL + +## Working Code Is Sacred + +اگر بخشی: + +* سالم است، +* Test آن سبز است، +* Security issue ندارد، +* و مانع Phase فعلی نیست، + +فقط برای Clean Code یا Preference معماری آن را تغییر نده. + +--- + +# Minimum Necessary Change + +برای هر Issue کمترین تغییر لازم را انجام بده. + +از Refactor گسترده unrelated جلوگیری کن. + +--- + +# Scope Lock + +در ابتدای هر Phase اعلام کن: + +```text +Expected files to change +Affected modules +Affected APIs +Affected permissions +Expected behavior changes +Behavior that must remain unchanged +``` + +اگر Issue دیگری پیدا شد که ضروری نیست: + +```text +Deferred Technical Debt +``` + +ثبتش کن و فعلاً تغییر نده. + +--- + +# Never Hide Security in Frontend + +Hide کردن Menu یا Route امنیت نیست. + +تمام محدودیت‌های Super Admin / Tenant باید در Backend نیز enforce شوند. + +--- + +# Super Admin Architecture + +قاعده اصلی: + +```text +Super Admin + ↓ +Platform Scope + ↓ +All Organizations Metadata +``` + +Super Admin نباید به‌عنوان User یک Tenant عمل کند مگر Feature مشخص و امنی مانند Impersonation بعداً طراحی شود. + +Super Admin: + +```text +organization_id = null +``` + +را به‌عنوان حالت معتبر Platform Level در نظر بگیر. + +--- + +# PHASE 0 — Admin Role, Security & Critical Bug Stabilization + +## هدف + +قبل از Redesign، رفتار Role و باگ‌های فعلی پنل Admin تثبیت شوند. + +--- + +# 1. Admin Role Audit + +تمام موارد مربوط به `super_admin` را پیدا کن: + +```text +Role +Permissions +Middleware +Policies +Route Guards +Platform Routes +Tenant Routes +Frontend navigation +Auth Context +``` + +بررسی کن Super Admin نباید اشتباهاً Tenant API را صدا بزند. + +--- + +# 2. Fix Admin Notification Bug + +در حال حاضر بررسی کن آیا: + +```text +WorkspaceShell +↓ +NotificationPopover +↓ +GET /notifications +``` + +برای Super Admin اجرا می‌شود. + +اگر `/notifications` نیازمند Tenant Scope است و Super Admin: + +```text +organization_id = null +``` + +دارد، این رفتار را اصلاح کن. + +راه‌حل مطلوب: + +اگر Platform Notifications هنوز وجود ندارد: + +* Notification Bell برای Super Admin نمایش داده نشود. + +اگر Platform Notifications نیاز است: + +API مستقل ایجاد کن: + +```text +/api/v1/platform/notifications +``` + +Tenant Notifications و Platform Notifications را قاطی نکن. + +--- + +# 3. Unknown Admin Route + +تمام `/admin/*` routeها را بررسی کن. + +URL نامعتبر مثل: + +```text +/admin/abc +``` + +نباید Runtime Error ایجاد کند. + +ایجاد کن: + +```text +AdminNotFound +``` + +یا redirect امن. + +--- + +# 4. Capability-aware Routing + +Hidden Navigation کافی نیست. + +اگر Deployment یا Feature Flag اجازه صفحه‌ای را نمی‌دهد، direct URL نیز باید صحیح مدیریت شود. + +به‌خصوص بررسی کن: + +```text +Organizations +Subscriptions +Platform settings +``` + +--- + +# 5. Settings Stale State Bug + +Platform Settings را بررسی کن. + +اگر Save هر Field بلافاصله object قدیمی را دوباره ارسال می‌کند و ممکن است Update قبلی overwrite شود، اصلاح کن. + +پیشنهاد مطلوب: + +```text +Edit locally +↓ +Dirty state +↓ +Save Changes +``` + +نمایش: + +```text +Unsaved changes +``` + +تا Save موفق. + +--- + +# 6. Permission Tests + +Test بنویس که: + +### Super Admin بتواند: + +```text +Platform APIs +Organization metadata +Platform settings +Audit +Platform usage +``` + +### Userهای Tenant نتوانند: + +```text +Platform APIs +Platform settings +Organization-wide platform data +``` + +و Super Admin هم Tenant-scoped endpoint نامناسب را بدون context صحیح استفاده نکند. + +--- + +## Acceptance Criteria Phase 0 + +* Notification 403 loop حذف شده. +* Unknown admin URL crash ندارد. +* Platform/Tenant scope مشخص است. +* capability-aware routing وجود دارد. +* Settings overwrite bug رفع شده. +* Authorization tests وجود دارد. +* unrelated feature تغییر نکرده. + +متوقف شو. + +--- + +# PHASE 1 — Organizations & Subscription Management + +## هدف + +Organization Management و Subscription Management از حالت نمایشی به ابزار مدیریتی واقعی تبدیل شوند. + +--- + +# Organizations Page + +صفحه Organizations باید این امکانات را داشته باشد: + +```text +Search +Status Filter +Plan Filter +Sorting +Pagination +``` + +Backend pagination metadata را حفظ کن. + +Frontend نباید فقط `data` را بردارد و `meta` را دور بیندازد. + +--- + +# Organization Table + +Columns پیشنهادی: + +```text +Organization +Status +Plan +Users +Storage +AI Usage +Subscription Expiry +Last Activity +``` + +Actionها: + +```text +View +Edit +Subscription +Usage +Suspend / Activate +``` + +فقط در صورت support backend. + +--- + +# Organization 360° Page + +برای هر Organization یک صفحه جزئیات ایجاد کن: + +```text +Organization +→ +Overview +Subscription +Users +Usage +Storage +AI +Activity +Audit +Settings +``` + +این صفحه باید Platform View باشد. + +Super Admin نباید برای دیدن اطلاعات سازمان وارد Tenant Workspace شود. + +--- + +# Organization Overview + +نمایش: + +```text +Organization Name +Status +Created +Plan +Users +Storage +AI consumption +Subscription +Recent activity +``` + +--- + +# Organization Lifecycle + +در صورت support backend: + +```text +Create +Edit +Activate +Suspend +``` + +Delete hard را فقط در صورت Business Requirement واقعی اضافه کن. + +Prefer: + +```text +Suspend +Archive +``` + +برای سیستم عملیاتی. + +--- + +# Subscription Management + +صفحه Subscriptions فعلی را از summary ساده خارج کن. + +Table: + +```text +Organization +Plan +User limit +Storage limit +AI limit +Start date +Expiry +Status +``` + +--- + +# Subscription Actions + +در صورت support business model: + +```text +Create subscription +Change plan +Extend +Change limits +Suspend +Reactivate +``` + +تمام تغییرات باید Audit شوند. + +--- + +# Limits + +Subscription باید در صورت وجود model مناسب این محدودیت‌ها را مدیریت کند: + +```text +Users / Seats +Storage +AI credits / usage +Features +Expiry +``` + +اگر backend مدل آنها را ندارد، بدون طراحی داده درست fake UI نساز. + +--- + +# Expiry Awareness + +Statusهایی مثل: + +```text +Active +Expiring Soon +Expired +Suspended +``` + +نمایش داده شوند. + +--- + +# Confirmation + +برای عملیات مهم مثل Suspend: + +Confirmation Dialog واضح: + +```text +Suspend organization? + +Users will lose access to the platform. + +[Cancel] +[Suspend] +``` + +--- + +## Acceptance Criteria Phase 1 + +* Pagination Organizations صحیح است. +* Search و Filters وجود دارند. +* Organization 360° وجود دارد. +* Edit Organization قابل دسترسی است. +* Subscription واقعی قابل مدیریت است. +* Subscription summary خام حذف شده. +* limits شفاف هستند. +* عملیات حساس Audit می‌شوند. + +متوقف شو. + +--- + +# PHASE 2 — Admin Dashboard & Platform Overview + +## هدف + +Dashboard از JSON Surface به **Platform Control Center** تبدیل شود. + +--- + +# حذف Generic JSON UI + +Dashboard نباید raw key/value یا JSON objectهای nested را مستقیماً نمایش دهد. + +`JsonSurface` را برای Dashboard اصلی استفاده نکن مگر برای Debug Tool داخلی. + +--- + +# Dashboard Structure + +Layout پیشنهادی: + +```text +Platform Overview +↓ +Critical KPIs +↓ +Needs Attention +↓ +Organizations Health +↓ +Subscription Health +↓ +Storage +↓ +AI +↓ +System Health +↓ +Recent Activity +``` + +--- + +# KPI Cards + +KPIهای اصلی: + +```text +Organizations +Active Organizations +Users +Storage Used +AI Jobs +Failed Jobs +Active Subscriptions +``` + +KPIها compact باشند. + +--- + +# Trend + +فقط وقتی داده واقعی historical وجود دارد: + +```text ++8% this month +-3% failed jobs +``` + +نمایش بده. + +Trend جعلی نساز. + +--- + +# Needs Attention + +یکی از مهم‌ترین قسمت‌ها. + +مثال: + +```text +3 AI jobs failed + +2 organizations are above 90% storage + +2 subscriptions expire within 7 days + +Queue worker unavailable + +Backup overdue +``` + +هر Item Action مستقیم داشته باشد: + +```text +Investigate +View organization +View jobs +View subscription +``` + +--- + +# Platform Activity + +نمایش eventهای مهم: + +```text +Organization created +Subscription changed +Platform setting changed +AI provider failed +Admin action +``` + +--- + +# Dashboard Refresh + +نمایش: + +```text +Last updated +Refresh +``` + +از polling بی‌دلیل سریع استفاده نکن. + +اگر polling وجود دارد adaptive باشد. + +--- + +# Loading/Error + +هر Widget: + +```text +Loading +Error +Empty +Success +``` + +را مستقل مدیریت کند. + +Failure یک Widget کل Dashboard را از کار نیندازد. + +--- + +# Responsive Dashboard + +در Mobile اولویت: + +```text +Needs Attention +↓ +KPIs +↓ +Critical Operations +↓ +Activity +``` + +باشد. + +--- + +## Acceptance Criteria Phase 2 + +* Dashboard خام نیست. +* JSON مستقیم نمایش داده نمی‌شود. +* Needs Attention وجود دارد. +* Platform Health واضح است. +* KPIها واقعی‌اند. +* dashboard actionable است. +* responsive صحیح است. +* unnecessary polling ندارد. + +متوقف شو. + +--- + +# PHASE 3 — Platform Operations + +## هدف + +Storage، AI، System Health، Jobs و Backup واقعاً Operational شوند. + +--- + +# 1. Storage + +صفحه Storage باید حداقل نمایش دهد: + +```text +Total Used +Configured Capacity +Usage % +Growth +``` + +Breakdown در صورت داده واقعی: + +```text +Images +Videos +Documents +Exports +Certificates +Other +``` + +--- + +# Storage by Organization + +Table: + +```text +Organization +Used +Quota +Percentage +Trend +Status +``` + +Status: + +```text +Healthy +Warning +Critical +``` + +--- + +# Storage Alerts + +Thresholdهای configurable یا منطقی: + +```text +80% +90% +95% +``` + +در صورت نیاز. + +--- + +# 2. AI Usage + +AI صفحه عملیاتی واقعی باشد. + +KPIها: + +```text +Requests +Successful +Failed +Queued +Avg latency +``` + +در صورت داده: + +```text +Tokens +Credits +Estimated cost +``` + +--- + +# Provider Breakdown + +مثلاً: + +```text +Ollama +OpenAI +LM Studio +vLLM +``` + +فقط providerهای واقعی. + +--- + +# Model Usage + +در صورت داده واقعی: + +```text +Model +Requests +Failures +Latency +``` + +--- + +# Organization AI Usage + +Table: + +```text +Organization +Requests +Success +Failure +Usage +``` + +--- + +# AI Failures + +لیست خطاهای اخیر: + +```text +Organization +Provider +Model +Error +Time +Retry status +``` + +Secretها را log/display نکن. + +--- + +# 3. System Health + +این صفحه نباید صرفاً System Info باشد. + +Health Check واقعی برای: + +```text +Application +Database +Cache +Queue +Scheduler +Storage +Email +AI Provider +``` + +در صورت وجود: + +```text +Redis +Search +Object Storage +``` + +--- + +# Health State + +نمایش: + +```text +Healthy +Warning +Critical +Unknown +``` + +با: + +```text +Last checked +Latency +Details +``` + +--- + +# 4. Jobs & Queues + +صفحه اختصاصی: + +```text +Jobs & Queues +``` + +نمایش: + +```text +Queued +Processing +Failed +Completed +``` + +اگر Laravel failed_jobs وجود دارد، لیست امن آن را نمایش بده. + +Actions فقط در صورت امن بودن: + +```text +Retry +Retry all selected +``` + +Delete failed jobs فقط با permission و confirmation. + +--- + +# 5. Backups + +اگر Backup system واقعاً وجود دارد: + +صفحه: + +```text +Backups +``` + +نمایش: + +```text +Last successful backup +Last failed backup +Backup size +Storage destination +Schedule +``` + +اگر Backup هنوز پیاده‌سازی نشده، fake feature نساز. + +فقط readiness یا setup requirement گزارش کن. + +--- + +# 6. Platform Usage + +یک Usage Overview ایجاد کن: + +```text +Organizations +Users +Courses +Assets +Storage +AI +Jobs +``` + +Historical chart فقط در صورت وجود data. + +--- + +## Acceptance Criteria Phase 3 + +* Storage operational است. +* AI Usage کاربردی است. +* Health واقعی است. +* Jobs قابل مانیتور هستند. +* Backup وضعیت واقعی دارد. +* raw JSON نمایش داده نمی‌شود. +* secrets در UI/log نمایش داده نمی‌شوند. +* performance مناسب است. + +متوقف شو. + +--- + +# PHASE 4 — Audit, Security, Administrators & Platform Settings + +## هدف + +پنل Admin برای Governance و Security قابل اتکا باشد. + +--- + +# 1. Audit Log + +Audit را از آخرین 20 event ساده خارج کن. + +امکانات: + +```text +Pagination +Search +Actor +Action +Organization +Entity +Date Range +IP +``` + +--- + +# Audit Details + +روی Event کلیک: + +```text +Actor +Action +Organization +Entity Type +Entity ID +Time +IP +Metadata +``` + +Sensitive data را mask کن. + +--- + +# Audit Coverage + +بررسی کن عملیات مهم Audit می‌شوند یا نه: + +```text +Organization Create / Update / Suspend + +Subscription changes + +Platform Settings + +Admin actions + +Role changes + +Security changes + +AI configuration changes +``` + +اگر نیستند، centralized audit approach اضافه کن بدون اینکه کل پروژه را rewrite کنی. + +--- + +# 2. Administrators + +اگر Business Requirement اجازه می‌دهد، صفحه: + +```text +Platform Administrators +``` + +ایجاد کن. + +نمایش: + +```text +Name +Email +Status +Last login +Created +``` + +اگر Super Admin Management فعلاً product requirement نیست، fake feature ایجاد نکن. + +--- + +# 3. Platform Settings + +Settings را گروه‌بندی کن. + +پیشنهاد: + +```text +General + +Deployment + +Organizations + +Subscriptions + +Storage + +AI + +Email + +Notifications + +Security + +Maintenance +``` + +فقط settingهای واقعی را نمایش بده. + +--- + +# Settings Save Pattern + +از Auto Save خطرناک جلوگیری کن. + +Pattern: + +```text +Edit +↓ +Unsaved changes +↓ +Save Changes +``` + +در صورت ترک صفحه: + +```text +You have unsaved changes. +``` + +در صورت نیاز. + +--- + +# Destructive Settings + +Settingهایی که impact مهم دارند Confirmation بخواهند. + +مثلاً: + +```text +Disable external AI +Enable maintenance mode +``` + +--- + +# 4. Maintenance Mode + +در صورت پشتیبانی واقعی: + +```text +Maintenance mode +Maintenance message +Expected end time +``` + +ولی کاربر Admin را accidentally lock out نکن. + +--- + +# 5. Security Settings + +در صورت موجود بودن زیرساخت: + +```text +Session policy +Authentication +Rate limits +Allowed domains +Security events +``` + +ولی architecture جدید سنگین بدون requirement ایجاد نکن. + +--- + +## Acceptance Criteria Phase 4 + +* Audit pagination دارد. +* Filters دارد. +* operations مهم Audit می‌شوند. +* Sensitive data mask می‌شود. +* Settings grouped است. +* stale overwrite وجود ندارد. +* Save state واضح است. +* Admin governance بهتر شده. + +متوقف شو. + +--- + +# PHASE 5 — Responsive, Accessibility, QA & Production Hardening + +## هدف + +Super Admin Panel برای Release آماده شود. + +--- + +# Responsive QA + +Viewportها: + +```text +375 +430 +768 +1024 +1280 +1440 +1920 +``` + +را بررسی کن. + +--- + +# Mobile Admin + +Super Admin Desktop-first است، اما Mobile نباید شکسته باشد. + +روی Mobile حداقل این کارها ممکن باشند: + +```text +View dashboard +View alerts +View organizations +View organization status +View health +View failed jobs +``` + +عملیات بسیار پیچیده می‌توانند desktop-preferred باشند. + +--- + +# Navigation + +Desktop: + +```text +Collapsible Sidebar +``` + +Mobile: + +```text +Drawer +``` + +--- + +# Proposed Navigation + +ساختار پیشنهادی: + +```text +OVERVIEW + +Dashboard + + +CUSTOMERS + +Organizations +Subscriptions + + +PLATFORM + +Usage +Storage +AI & Models + + +OPERATIONS + +System Health +Jobs & Queues +Backups + + +SECURITY + +Audit Logs +Administrators + + +CONFIGURATION + +Platform Settings +``` + +اگر Feature واقعاً موجود نیست نمایش نده. + +--- + +# Accessibility + +بررسی: + +```text +Keyboard +Focus +ARIA +Contrast +Tables +Dialog +Drawer +Form labels +Error messages +Status indicators +``` + +رنگ تنها Indicator نباشد. + +--- + +# RTL/LTR + +فارسی: + +```text +RTL +``` + +انگلیسی: + +```text +LTR +``` + +بررسی کن: + +```text +Tables +Pagination +Breadcrumb +Icons +Charts +Drawers +``` + +--- + +# Dark Mode + +تمام صفحات Admin را بررسی کن: + +```text +Dashboard +Organizations +Subscriptions +Storage +AI +Health +Jobs +Audit +Settings +``` + +--- + +# Security Testing + +دوباره بررسی کن: + +```text +Platform/Tenant boundary +IDOR +Privilege escalation +Admin route access +Settings authorization +Export leakage +Sensitive logs +Secrets +``` + +--- + +# Performance + +بررسی: + +```text +Dashboard request count +N+1 queries +Organization pagination +Over-fetching +Polling +Large audit payload +AI metrics queries +``` + +--- + +# Smoke Test Scenarios + +## Scenario 1 + +```text +Super Admin Login +→ +Dashboard +→ +Needs Attention +→ +Organization +``` + +## Scenario 2 + +```text +Organizations +→ +Search +→ +Pagination +→ +Organization 360 +→ +Edit +``` + +## Scenario 3 + +```text +Subscriptions +→ +Organization +→ +Modify subscription +→ +Save +→ +Audit +``` + +## Scenario 4 + +```text +AI +→ +View failures +→ +Inspect provider +``` + +## Scenario 5 + +```text +Health +→ +Detect unhealthy service +→ +View details +``` + +## Scenario 6 + +```text +Jobs +→ +Failed job +→ +Retry +``` + +اگر retry support دارد. + +## Scenario 7 + +```text +Settings +→ +Change value +→ +Unsaved state +→ +Save +→ +Reload +``` + +## Scenario 8 + +```text +Audit +→ +Filter +→ +Open event +``` + +--- + +# Unauthorized Tests + +Tenant user نباید بتواند: + +```text +Access /admin +Access platform APIs +Read other organizations +Modify subscriptions +Read platform audit +Change platform settings +``` + +--- + +# Quality Gate + +Backend: + +```bash +php artisan test +``` + +در صورت وجود: + +```bash +./vendor/bin/pint --test +``` + +Frontend: + +```bash +npm run lint +npm run typecheck +npm test +npm run build +``` + +همه باید سبز باشند. + +--- + +# Final Report + +در پایان Phase 5 گزارش کامل بده: + +## Bugs Fixed + +| Issue | Severity | Status | + +## Security Improvements + +## Organization Management Improvements + +## Subscription Improvements + +## Dashboard Improvements + +## Storage Improvements + +## AI Operations Improvements + +## Health Improvements + +## Jobs Improvements + +## Audit Improvements + +## Settings Improvements + +## Responsive Improvements + +## Accessibility Improvements + +--- + +# Final Test Results + +Backend: + +```text +Tests: +Assertions: +Failures: +``` + +Frontend: + +```text +Test Files: +Tests: +Lint: +Typecheck: +Build: +``` + +--- + +# Remaining Technical Debt + +فقط موارد واقعی باقی‌مانده. + +Severity: + +```text +High +Medium +Low +``` + +--- + +# Admin Panel Score + +از 10 امتیاز بده: + +```text +Platform Navigation + +Dashboard + +Organization Management + +Subscriptions + +Storage Operations + +AI Operations + +System Health + +Jobs & Queues + +Audit + +Settings + +Security + +Responsive + +Accessibility + +Performance +``` + +در نهایت: + +```text +Super Admin Experience Score: XX/100 +``` + +و یکی از این وضعیت‌ها: + +```text +NOT READY + +STAGING READY + +PRODUCTION READY WITH CONDITIONS + +PRODUCTION READY +``` + +--- + +# قوانین نهایی + +## 1 + +Super Admin را با Tenant Admin قاطی نکن. + +## 2 + +Platform API و Tenant API تفکیک شوند. + +## 3 + +Hide کردن UI Security Control نیست. + +## 4 + +Raw JSON نباید UI نهایی باشد. + +## 5 + +هر صفحه باید Actionable باشد. + +## 6 + +Metric جعلی نساز. + +## 7 + +Chart بدون historical data نساز. + +## 8 + +Feature بدون Backend واقعی نساز. + +## 9 + +Secret و Credential در Admin UI نمایش نده. + +## 10 + +Feature سالم unrelated را Refactor نکن. + +## 11 + +هر Bug Fix مهم Regression Test داشته باشد. + +## 12 + +هر Phase یک Checkpoint مستقل و قابل rollback باشد. + +--- + +# Final Phase Order + +```text +PHASE 0 +Security & Critical Bugs + ↓ +PHASE 1 +Organizations & Subscriptions + ↓ +PHASE 2 +Admin Dashboard + ↓ +PHASE 3 +Platform Operations + ↓ +PHASE 4 +Audit & Settings + ↓ +PHASE 5 +QA & Production Hardening +``` + +**هر Phase را کامل کن، تست کن، گزارش بده و سپس متوقف شو. بدون دستور صریح من وارد Phase بعد نشو.** diff --git a/Prompt/Learner.md b/Prompt/Learner.md new file mode 100644 index 0000000..ee28adb --- /dev/null +++ b/Prompt/Learner.md @@ -0,0 +1,3572 @@ +# Master Prompt — MicroLearning Learner PWA Stabilization, UX Enhancement & APK Readiness + +می‌خواهم بخش **Learner / یادگیرنده** پروژه MicroLearning را به‌صورت مرحله‌ای، کنترل‌شده و Production-Ready اصلاح و ارتقا دهی. + +این بخش در حال حاضر یک **React PWA** است و در آینده باید بتواند با کمترین تغییر به **Android APK از طریق Capacitor** تبدیل شود. + +هدف این پروژه: + +* رفع باگ‌های واقعی Learner +* اصلاح Progress Logic +* اصلاح Learning Path +* ایجاد Resume Learning واقعی +* بهبود Course Player +* تکمیل Offline/PWA +* تکمیل Notifications +* تکمیل Certificates +* بهبود Progress & Daily Learning +* تکمیل i18n و Accessibility +* آماده‌سازی معماری برای Android APK +* آماده‌سازی برای Firebase Cloud Messaging +* حفظ یک Source Code مشترک بین PWA و APK + +است. + +--- + +# قانون اصلی + +این پروژه باید دقیقاً در **۶ فاز** انجام شود: + +```text +PHASE 0 +Learner Stabilization & Critical Logic Fixes + +PHASE 1 +Learning Paths, Resume Learning & Assignment Logic + +PHASE 2 +Course Player & Assessment UX + +PHASE 3 +Offline Learning & PWA Reliability + +PHASE 4 +Progress, Certificates, Notifications & Learner Product UX + +PHASE 5 +APK Readiness, Responsive, Accessibility & Final QA +``` + +هر Phase را جداگانه انجام بده. + +بعد از پایان هر Phase: + +1. تغییرات همان Phase را کامل کن. +2. Regression Test اجرا کن. +3. Backend Tests مرتبط را اجرا کن. +4. Frontend Tests مرتبط را اجرا کن. +5. `npm run lint` را اجرا کن. +6. `npm run typecheck` را اجرا کن. +7. `npm test` را اجرا کن. +8. `npm run build` را اجرا کن. +9. `git diff` را بررسی کن. +10. فایل‌های تغییرکرده را اعلام کن. +11. Bugs Fixed را گزارش کن. +12. Remaining Issues را گزارش کن. +13. متوقف شو. + +بدون دستور صریح من: + +> برو فاز بعد + +وارد Phase بعد نشو. + +--- + +# CHANGE SAFETY PROTOCOL + +## Working Code Is Sacred + +اگر بخشی: + +* درست کار می‌کند، +* Test دارد، +* Security Issue ندارد، +* و مانع Phase فعلی نیست، + +فقط برای تمیزتر شدن Architecture آن را تغییر نده. + +--- + +## Minimum Necessary Change + +هر Issue را با کمترین تغییر لازم اصلاح کن. + +از: + +* Refactor گسترده +* Rename گسترده +* تغییر Folder Structure unrelated +* تغییر API Contract unrelated +* Dependency Upgrade غیرضروری +* Rebuild کردن Player از صفر + +خودداری کن. + +--- + +## Preserve Existing Learner Features + +این قابلیت‌های فعلی باید حفظ شوند: + +```text +Learner Home +My Learning +Daily Learning +Progress +More +Bottom Navigation + +Course Player + +Notes +Highlights +Bookmarks +Favorites +Discussions +Assessments +Offline +Progress Tracking +Light/Dark Mode +RTL/LTR +PWA +``` + +Feature موجود را بدون دلیل حذف نکن. + +--- + +## Scope Lock + +قبل از شروع هر Phase اعلام کن: + +```text +Expected files to change +Affected frontend modules +Affected backend modules +Affected APIs +Affected database tables +Behavior expected to change +Behavior that must remain unchanged +``` + +اگر Issue دیگری پیدا شد که برای Phase جاری ضروری نیست: + +```text +Deferred Technical Debt +``` + +ثبت کن و فعلاً دست نزن. + +--- + +## Regression Test Rule + +برای هر Bug مهم: + +```text +Reproduce +↓ +Regression Test +↓ +Confirm Failure +↓ +Minimum Fix +↓ +Confirm Pass +``` + +اگر Test ممکن نبود، دلیلش را گزارش کن. + +--- + +# PRODUCT PRINCIPLE + +Learner Experience باید: + +```text +Mobile First +Simple +Fast +Low Cognitive Load +Progress Oriented +Motivating +Offline Friendly +Touch Friendly +Accessible +``` + +باشد. + +Learner نباید UI پیچیده Admin یا Course Designer را ببیند. + +--- + +# PHASE 0 — Learner Stabilization & Critical Logic Fixes + +## هدف + +قبل از UX Enhancement، باگ‌های واقعی Learner و Data Quality اصلاح شوند. + +--- + +# 1. Learner Architecture Audit + +تمام فایل‌ها و APIهای مرتبط با Learner را شناسایی کن: + +```text +LearnerShell +LearnerHome +MyLearning +Daily +Progress +More +CoursePlayer +Assessment components + +Learner Controllers +Assignments +Progress +Learning Events +Certificates +Notifications +Offline Store +Service Worker +``` + +--- + +# 2. Assignment Status Audit + +تمام Statusهای Assignment را مشخص کن. + +حداقل تفکیک منطقی: + +```text +Not Started +In Progress +Completed +Overdue +Due Today +Due Soon +Upcoming +No Deadline +``` + +اگر Backend مدل دیگری دارد، همان مدل واقعی را اصلاح کن. + +--- + +# 3. Fix Due Soon Logic + +بررسی کن `dueSoon` واقعاً بر اساس نزدیک‌ترین Deadline مرتب شود. + +شرط باید چیزی شبیه: + +```text +deadline >= now +AND +deadline <= now + 7 days +``` + +باشد. + +Assignmentهای گذشته نباید Due Soon باشند. + +آنها باید: + +```text +Overdue +``` + +باشند. + +Test بنویس برای: + +```text +Deadline yesterday +Deadline today +Deadline tomorrow +Deadline in 7 days +Deadline in 8 days +No deadline +``` + +--- + +# 4. Analytics Event Deduplication + +بررسی کن این Eventها به دلیل React Query Refetch یا Component Render چندبار ثبت نشوند: + +```text +course.opened +lesson.started +block.viewed +lesson.completed +course.completed +``` + +Eventها باید Session-aware یا Idempotent باشند. + +Architecture مناسب ایجاد کن، مثلاً: + +```text +learningSessionId ++ +courseVersionId ++ +lessonId ++ +blockId ++ +eventType +``` + +ولی architecture را بیش از نیاز پیچیده نکن. + +--- + +# 5. Complete Lesson Idempotency + +دکمه Completion نباید چند Event Completion تولید کند. + +بعد از Complete شدن: + +```text +✓ Lesson Completed +``` + +نمایش داده شود. + +Mutation دوباره نباید Completion duplicate ایجاد کند. + +Backend نیز باید idempotency داشته باشد. + +--- + +# 6. Loading & Error Audit + +تمام Learner API Flowها باید: + +```text +Loading +Empty +Error +Offline +Retry +``` + +را صحیح مدیریت کنند. + +--- + +## Acceptance Criteria Phase 0 + +* Due Soon صحیح است. +* Overdue جدا شده. +* event duplication رفع شده. +* completion duplicate وجود ندارد. +* Learner regression ایجاد نشده. +* tests سبز هستند. + +سپس متوقف شو. + +--- + +# PHASE 1 — Learning Paths, Resume Learning & Assignment Logic + +## هدف + +منطق اصلی Learning Experience درست شود. + +این Phase بسیار مهم است. + +--- + +# 1. Learning Path Progress + +بررسی کن Assignment نوع: + +```text +learning_path +``` + +چگونه Progress محاسبه می‌کند. + +Learning Path نباید با تکمیل Course اول Completed شود. + +ساختار منطقی مقصد: + +```text +Learning Path + ↓ +Course + ↓ +Lesson + ↓ +Block +``` + +Progress Path باید بر اساس تمام Courseهای Path محاسبه شود. + +--- + +# Learning Path Completion + +Completion rule را از مدل واقعی سیستم بخوان. + +اگر Path نیازمند تمام Courseهاست: + +```text +Completed Courses +/ +Total Required Courses +``` + +محاسبه شود. + +اگر Rule دیگری وجود دارد از همان استفاده کن. + +Rule جعلی نساز. + +--- + +# Learning Path Tests + +حداقل: + +```text +3 courses +course 1 = 100% +course 2 = 0% +course 3 = 0% +``` + +Expected: + +```text +Path != Completed +``` + +و: + +```text +course 1 = 100 +course 2 = 100 +course 3 = 100 +``` + +Expected: + +```text +Path = Completed +``` + +--- + +# 2. True Resume Learning + +Continue Learning فعلی را بررسی کن. + +نباید صرفاً: + +```text +Highest progress course +→ first lesson +``` + +باشد. + +باید آخرین فعالیت واقعی Learner را ذخیره/استفاده کند. + +حداقل: + +```text +lastActivityAt +lastCourseVersionId +lastLessonId +``` + +در صورت امکان: + +```text +lastBlockId +``` + +--- + +# Resume Flow + +هدف: + +```text +User leaves app + ↓ +Returns later + ↓ +Continue Learning + ↓ +Same Course + ↓ +Same Lesson + ↓ +Closest possible Block +``` + +--- + +# Resume Data + +اگر داده لازم در Learning Events موجود است از همان استفاده کن. + +Schema جدید فقط اگر ضروری است ایجاد کن. + +Migration موجود را rewrite نکن. + +--- + +# 3. My Learning Status Architecture + +My Learning را براساس status واقعی قابل فیلتر کن: + +```text +All +In Progress +Not Started +Completed +Overdue +``` + +Downloaded بعداً در Phase Offline اضافه می‌شود. + +--- + +# 4. Mandatory Learning + +اگر Assignment یا Course قابلیت Mandatory دارد، آن را واضح نمایش بده. + +Badge: + +```text +Mandatory +``` + +و Deadline آن prominent باشد. + +--- + +# 5. Needs Attention + +در Home بخش: + +```text +Needs Attention +``` + +اضافه کن. + +Priority: + +```text +Overdue +Due Today +Due Soon +Mandatory Not Started +``` + +هر Item CTA مستقیم داشته باشد. + +--- + +## Acceptance Criteria Phase 1 + +* Learning Path Progress صحیح است. +* Learning Path Completion صحیح است. +* Resume Learning واقعی است. +* User به آخرین Lesson برمی‌گردد. +* My Learning status-based است. +* Mandatory/Overdue واضح است. +* tests کامل هستند. + +متوقف شو. + +--- + +# PHASE 2 — Course Player & Assessment UX + +## هدف + +Course Player ساده‌تر، سریع‌تر و مناسب Microlearning شود. + +Player را از صفر بازنویسی نکن. + +--- + +# Player Header + +Header موبایل فعلی را audit کن. + +از نمایش تعداد زیادی Icon همزمان جلوگیری کن. + +پیشنهاد: + +```text +← +Lesson Title +⋯ +``` + +Actions ثانویه داخل More: + +```text +Notes +Discussion +Favorite +Offline Download +``` + +Course Outline باید همچنان به‌سادگی قابل دسترسی باشد. + +--- + +# Lesson Progress + +Progress واضح‌تر شود. + +مثلاً: + +```text +Lesson 2 of 6 + +████████░░ 34% +``` + +Progress Course نیز در Context مناسب نمایش داده شود. + +--- + +# Player Footer + +Primary flow ساده شود. + +مثلاً: + +```text +Previous + +Complete & Continue → +``` + +بعد از Completion: + +```text +✓ Completed + +Next Lesson → +``` + +--- + +# Inline Progress Feedback + +وقتی Learner Activity ثبت شد feedback مزاحم ایجاد نکن. + +Toast فقط برای Actionهایی مثل: + +```text +Note saved +Bookmark added +Download ready +``` + +استفاده شود. + +--- + +# Notes / Discussions / Outline + +روی موبایل ترجیحاً Drawer/Sheet استاندارد باشند. + +Accessibility: + +```text +focus trap +Escape +focus return +aria labels +``` + +رعایت شود. + +--- + +# Assessment UX + +Backend scoring را Source of Truth نگه دار. + +Client نباید نتیجه رسمی را خودش تعیین کند. + +بعد از Submit: + +در صورت support مدل: + +```text +Correct / Incorrect +Feedback +Explanation +Score +Retry +Continue +``` + +نمایش بده. + +--- + +# Assessment Retry + +Retry فقط اگر Rule assessment اجازه می‌دهد. + +UI نباید خودش retry policy اختراع کند. + +--- + +# Hotspot Accessibility + +Hotspot Image باید در صورت وجود متن مناسب: + +```text +alt +``` + +داشته باشد. + +برای interactionهای تصویری keyboard alternative بررسی شود. + +--- + +# Rich Content + +بررسی کن: + +```text +Text +Image +Video +Audio +Quiz +Flashcard +Hotspot +Other interactive blocks +``` + +در Mobile overflow ایجاد نکنند. + +--- + +# Video / Audio + +بررسی: + +```text +responsive sizing +playback controls +captions if available +resume position if supported +``` + +--- + +# Fullscreen & Orientation + +از UX خراب در Landscape جلوگیری کن. + +Feature جدید native اضافه نکن؛ فقط Web/PWA behavior را اصلاح کن. + +--- + +## Acceptance Criteria Phase 2 + +* Header ساده‌تر است. +* Footer واضح‌تر است. +* progress قابل فهم است. +* assessment feedback بهتر است. +* accessibility Drawers بهتر شده. +* Player Feature حذف نشده. +* mobile UX بهتر شده. +* tests سبزند. + +متوقف شو. + +--- + +# PHASE 3 — Offline Learning & PWA Reliability + +## هدف + +Offline از حالت partial cache خارج شود و واقعاً قابل اعتماد شود. + +این Phase باید با احتیاط انجام شود. + +--- + +# 1. Offline Architecture Audit + +بررسی کن: + +```text +IndexedDB +Cache Storage +Service Worker +Offline queue +Course download +Progress synchronization +``` + +چگونه کار می‌کنند. + +قبل از تغییر architecture یک diagram کوتاه ارائه کن. + +--- + +# 2. Fix Lesson Cache Collision + +اگر Offline Data با: + +```text +assignmentId +``` + +Key می‌شود ولی فقط Lesson فعلی ذخیره می‌شود، اصلاح کن. + +Key یا schema باید Lesson-aware باشد. + +مثلاً: + +```text +organizationId +userId +assignmentId +courseVersionId +lessonId +``` + +یا کل Course Payload structured ذخیره شود. + +بهترین گزینه را با کمترین migration risk انتخاب کن. + +--- + +# 3. Full Course Download + +وقتی user: + +```text +Download Course +``` + +می‌زند، تمام محتوای لازم Course باید برای Offline آماده شود. + +حداقل: + +```text +Course metadata +Lessons +Blocks +Images +Documents required for lesson +Other essential assets +``` + +Videoهای بسیار بزرگ را براساس policy پروژه مدیریت کن. + +Download fake نساز. + +--- + +# 4. Service Worker Cache Fix + +Service Worker نباید Cacheهای Course Download را هنگام Activate اشتباهاً پاک کند. + +Cache namespaces را جدا کن: + +```text +microlearn-shell-* +microlearn-course-* +microlearn-assets-* +``` + +Cleanup فقط cacheهای obsolete مربوط به همان namespace را حذف کند. + +--- + +# 5. Offline Download Manager + +در Learner `More` یا محل مناسب اضافه کن: + +```text +Offline Downloads +``` + +هر Download: + +```text +Course name +Downloaded +Size +Last updated +Update available +Remove +``` + +--- + +# 6. Download Status + +Stateها: + +```text +Not downloaded +Downloading +Downloaded +Update available +Failed +``` + +Progress واقعی فقط اگر قابل اندازه‌گیری است. + +--- + +# 7. Offline Progress Queue + +اگر Learner Offline Course را تکمیل کرد: + +```text +Store progress locally + ↓ +Network available + ↓ +Sync +``` + +Queue باید: + +```text +idempotent +ordered where needed +retry-safe +``` + +باشد. + +--- + +# 8. Conflict Handling + +اگر Server و Device Progress اختلاف دارند، Rule مشخص داشته باش. + +برای Progress معمولاً از monotonic behavior استفاده کن: + +```text +Progress should not accidentally decrease +``` + +ولی business rules واقعی را بررسی کن. + +--- + +# 9. User Isolation + +Offline Data باید بین Userها leak نشود. + +اگر User A logout کند و User B وارد شود: + +User B نباید Cache خصوصی User A را ببیند. + +Cache/IndexedDB scope را بررسی کن. + +--- + +# 10. PWA Update Experience + +اگر Service Worker Version جدید آمد: + +Update نباید Downloadهای Offline را نابود کند. + +UX مناسب: + +```text +نسخه جدید آماده است. + +[به‌روزرسانی] +``` + +در صورت امکان. + +--- + +## Acceptance Criteria Phase 3 + +* Lesson offline collision رفع شده. +* Course Download واقعی است. +* Service Worker cache bug رفع شده. +* Offline Manager وجود دارد. +* Offline progress sync کار می‌کند. +* User isolation رعایت می‌شود. +* PWA update امن است. +* Offline tests وجود دارند. + +متوقف شو. + +--- + +# PHASE 4 — Progress, Certificates, Notifications & Learner Product UX + +## هدف + +Learner Experience از یک Player ساده به تجربه کامل یادگیری تبدیل شود. + +--- + +# 1. Home Redesign + +Home باید اولویت داشته باشد: + +```text +Continue Learning +↓ +Needs Attention +↓ +Today's Microlearning +↓ +Due Soon +↓ +Recent / Assigned Learning +``` + +--- + +# Continue Learning Card + +نمایش: + +```text +Course +Lesson +Progress +Last activity +``` + +CTA: + +```text +Continue +``` + +و باید به Resume واقعی Phase 1 متصل شود. + +--- + +# 2. Daily Learning + +Daily باید انتخاب منطقی داشته باشد. + +اگر algorithm فعلی Deadline-based است: + +واقعاً نزدیک‌ترین Learning مناسب را انتخاب کن. + +نمایش: + +```text +Today + +5 minutes + +Lesson title + +Part of: +Course title + +Why selected: +Due in 3 days +``` + +Reason فقط اگر واقعی است. + +--- + +# 3. Progress Page + +Progress را از summary بسیار ساده ارتقا بده. + +فقط Metricهای واقعی. + +پیشنهاد: + +```text +Overall completion +Completed courses +Active learning +Assessment average +Learning activity +Skills +Certificates +``` + +Learning Time فقط اگر داده معتبر داریم. + +--- + +# 4. Certificates + +Learner API مستقل ایجاد یا تکمیل کن: + +```text +GET /learner/certificates +GET /learner/certificates/:id +``` + +برای Download از معماری امن موجود استفاده کن. + +Learner فقط Certificate خودش را ببیند. + +--- + +# Certificates UI + +در More: + +```text +My Certificates +``` + +صفحه: + +```text +Course +Issue date +Certificate ID +Status + +View +Download +Share if supported +``` + +--- + +# 5. Notification Center + +Notificationهای داخل App را برای Learner کامل کن. + +Categoryها: + +```text +Course assigned +Deadline +Reminder +Completion +Certificate +System +``` + +--- + +# 6. Notification Preferences + +Preferences را از Keyهای فنی به UI واقعی تبدیل کن. + +مثلاً: + +```text +Notifications + +New learning assignments +Deadline reminders +Daily learning reminders +Certificates +``` + +--- + +# 7. Push Notification Readiness + +در این Phase **FCM را اجباری پیاده‌سازی نکن** مگر زیرساخت APK/Capacitor آماده باشد. + +اما Backend Notification architecture را آماده کن: + +```text +Notification Event + ↓ +Notification Service + ↓ +Channel +``` + +Channelهای آینده: + +```text +In App +Web Push +FCM +Email +``` + +--- + +# Notification Device Model + +برای آینده طراحی کن که User بتواند چند Device داشته باشد. + +مثلاً: + +```text +user_devices +``` + +با: + +```text +user_id +platform +push_token +last_seen_at +enabled +``` + +اما Migration فقط اگر در همین Phase واقعاً لازم است ایجاد شود. + +--- + +# 8. Preferences Cleanup + +Preferenceهایی که UI دارد ولی رفتار واقعی ندارند: + +یا: + +* پیاده‌سازی شوند، + +یا: + +* تا آماده‌شدن از UI حذف شوند. + +Fake settings ممنوع. + +--- + +# 9. More Page + +ساختار پیشنهادی: + +```text +Profile +Certificates +Offline Downloads +Notifications +Preferences +Language +Theme +Help +Logout +``` + +--- + +## Acceptance Criteria Phase 4 + +* Home actionable است. +* Daily Learning منطقی است. +* Progress کامل‌تر است. +* Certificates قابل دسترسی‌اند. +* Notification Preferences واقعی‌اند. +* Fake preferences حذف شده‌اند. +* Push architecture آماده است. +* Learner privacy رعایت شده. + +متوقف شو. + +--- + +# PHASE 5 — APK Readiness, Responsive, Accessibility & Final QA + +## هدف + +Learner PWA را به نقطه‌ای برسان که بدون بازنویسی بتوان آن را با Capacitor به APK تبدیل کرد. + +در این Phase لزوماً APK نساز مگر من صریحاً درخواست کنم. + +هدف اصلی: + +```text +APK Ready +``` + +است. + +--- + +# 1. Source Sharing Rule + +نباید Android Learner Frontend جداگانه ساخته شود. + +Architecture باید: + +```text +React Learner App + ↓ +Web / PWA + ↓ +Capacitor Android +``` + +باشد. + +یک Source Code اصلی. + +--- + +# 2. Platform Abstraction + +موارد platform-specific را abstraction بده: + +```text +Notifications +Network +Storage +Deep Links +Status Bar +Back Button +Sharing +Downloads +``` + +Web fallback داشته باشند. + +ولی dependency native را تا زمان نیاز بی‌دلیل اضافه نکن. + +--- + +# 3. Capacitor Readiness Audit + +بررسی کن: + +```text +SPA routing +API base URL +CORS +Authentication +Cookies / Sanctum strategy +Deep links +File downloads +External links +Back button behavior +Safe areas +Keyboard +Status bar +``` + +--- + +# 4. Authentication for APK + +بررسی کن مدل Authentication فعلی برای native shell امن و سازگار است. + +Secret یا session token در storage ناامن قرار نگیرد. + +اگر تغییر auth لازم است impact را گزارش کن و Minimum Change انجام بده. + +--- + +# 5. API Environment + +Hard-coded localhost نباید در production APK وجود داشته باشد. + +Config باید environment-based باشد. + +مثلاً: + +```text +Development +Staging +Production +On-Premise +``` + +--- + +# 6. Push Notification Integration Readiness + +برای Android architecture مقصد: + +```text +Laravel +↓ +Queue +↓ +FCM +↓ +Capacitor Android +↓ +Native Notification +``` + +باشد. + +در این Phase اگر implementation انجام می‌دهی: + +* permission Android 13+ +* token registration +* token refresh +* logout token cleanup +* multiple devices +* deep link +* notification channels + +را صحیح انجام بده. + +اگر من هنوز درخواست implementation نداده‌ام، فقط readiness ایجاد کن و integration را شروع نکن. + +--- + +# Notification Channels + +پیشنهاد: + +```text +Learning +Deadlines +Assignments +Certificates +System +``` + +--- + +# 7. Deep Linking + +آماده باش برای: + +```text +microlearning://course/{assignmentId}/lesson/{lessonId} +``` + +یا Universal/App Links. + +Notification باید بتواند user را مستقیم وارد context مربوط کند. + +--- + +# 8. Responsive QA + +حداقل: + +```text +320 +360 +375 +390 +430 +768 +1024 +``` + +را برای Learner بررسی کن. + +تمرکز اصلی Mobile است. + +--- + +# 9. Safe Areas + +برای گوشی‌های دارای: + +```text +notch +status bar +gesture navigation +``` + +Safe Area را رعایت کن. + +--- + +# 10. Touch Targets + +حداقل حدود: + +```text +44 × 44 +``` + +برای actionهای مهم. + +--- + +# 11. Accessibility + +Audit کن: + +```text +Keyboard +Focus +Screen reader +ARIA +Headings +Labels +Contrast +Alt text +Dialog +Drawer +Reduced motion +Assessment accessibility +``` + +حداقل WCAG AA را هدف قرار بده. + +--- + +# 12. Full i18n + +تمام Learner Player hard-coded textها را وارد i18n کن. + +فارسی: + +```text +RTL +``` + +انگلیسی: + +```text +LTR +``` + +Mixed UI نباید وجود داشته باشد مگر نام فنی/برند. + +--- + +# 13. Dark Mode + +تمام Learner Experience: + +```text +Home +My Learning +Daily +Progress +More +Player +Assessment +Notes +Discussion +Downloads +Certificates +``` + +در Dark Mode بررسی شود. + +--- + +# 14. Performance + +Audit: + +```text +initial bundle +course payload +image loading +video loading +React Query cache +unnecessary refetch +event network calls +offline storage +``` + +Optimization فقط بعد از اندازه‌گیری. + +--- + +# FINAL SMOKE TESTS + +## Scenario 1 — Resume + +```text +Login +→ +Start Course +→ +Lesson 3 +→ +Exit +→ +Reopen +→ +Continue Learning +→ +Lesson 3 +``` + +--- + +## Scenario 2 — Learning Path + +```text +Learning Path +→ +Complete Course 1 +→ +Path remains incomplete +→ +Complete all required Courses +→ +Path completed +``` + +--- + +## Scenario 3 — Deadline + +```text +Overdue assignment +→ +Needs Attention + +Tomorrow deadline +→ +Due Soon +``` + +--- + +## Scenario 4 — Assessment + +```text +Open quiz +→ +Answer +→ +Submit +→ +Server score +→ +Feedback +``` + +--- + +## Scenario 5 — Offline + +```text +Download Course +→ +Airplane Mode +→ +Open Lesson 1 +→ +Lesson 2 +→ +Lesson 3 +→ +Complete Learning +→ +Reconnect +→ +Progress Sync +``` + +--- + +## Scenario 6 — User Isolation + +```text +User A +→ +Download Course +→ +Logout +→ +User B Login +→ +Cannot access User A private offline data +``` + +--- + +## Scenario 7 — Certificate + +```text +Complete eligible Course +→ +Certificate issued +→ +My Certificates +→ +Open / Download +``` + +--- + +## Scenario 8 — Language + +```text +FA +→ +Full RTL + +EN +→ +Full LTR +``` + +--- + +## Scenario 9 — PWA Update + +```text +Download Course +→ +New Service Worker +→ +Update App +→ +Offline Course remains available +``` + +--- + +# SECURITY TESTS + +بررسی کن: + +```text +Learner cannot view other learner progress +Learner cannot access other certificates +Learner cannot access manager/admin APIs +Learner cannot modify another assignment +Offline cache does not leak user data +Downloaded assets obey authorization model +Progress APIs validate assignment ownership +``` + +--- + +# FINAL QUALITY GATE + +Backend: + +```bash +php artisan test +``` + +در صورت وجود: + +```bash +./vendor/bin/pint --test +``` + +Frontend: + +```bash +npm run lint +npm run typecheck +npm test +npm run build +``` + +همه باید سبز باشند. + +--- + +# FINAL REPORT + +در پایان Phase 5 گزارش بده: + +## Bugs Fixed + +| Issue | Severity | Status | + +## Learning Path Improvements + +## Resume Learning Improvements + +## Assignment & Deadline Improvements + +## Player Improvements + +## Assessment Improvements + +## Offline Improvements + +## PWA Improvements + +## Progress Improvements + +## Certificate Improvements + +## Notification Improvements + +## i18n Improvements + +## Accessibility Improvements + +## APK Readiness + +--- + +# TEST RESULTS + +Backend: + +```text +Tests: +Assertions: +Failures: +``` + +Frontend: + +```text +Test Files: +Tests: +Lint: +Typecheck: +Build: +``` + +--- + +# REMAINING TECHNICAL DEBT + +فقط موارد واقعی را گزارش کن. + +Severity: + +```text +High +Medium +Low +``` + +--- + +# LEARNER EXPERIENCE SCORE + +از 10 امتیاز بده: + +```text +Learner Home +My Learning +Daily Learning +Course Player +Assessments +Progress +Learning Paths +Offline +PWA +Certificates +Notifications +Mobile UX +Accessibility +i18n +Performance +APK Readiness +``` + +در نهایت: + +```text +Learner Experience Score: XX/100 +``` + +--- + +# RELEASE STATUS + +یکی را انتخاب کن: + +```text +NOT READY + +PWA STAGING READY + +PWA PRODUCTION READY + +APK READY + +APK READY WITH CONDITIONS +``` + +--- + +# قوانین نهایی + +## 1 + +Learner را از صفر بازنویسی نکن. + +## 2 + +Bottom Navigation فعلی را مگر با دلیل UX جدی تغییر نده. + +## 3 + +Offline را Fake نکن. + +## 4 + +Learning Path را بر اساس Course اول Completed نکن. + +## 5 + +Continue Learning باید واقعاً Resume باشد. + +## 6 + +Server Source of Truth برای Assessment باشد. + +## 7 + +Analytics Eventها duplicate نشوند. + +## 8 + +Cache بین Userها leak نکند. + +## 9 + +PWA و APK باید یک Source Code اصلی داشته باشند. + +## 10 + +Firebase/Capacitor را زودتر از نیاز وارد پروژه نکن. + +## 11 + +UI setting بدون رفتار واقعی نمایش نده. + +## 12 + +هر Bug Fix مهم Test داشته باشد. + +## 13 + +هر Phase یک Checkpoint مستقل و قابل rollback باشد. + +--- + +# FINAL PHASE ORDER + +```text +PHASE 0 +Critical Stabilization + ↓ +PHASE 1 +Learning Paths + Resume + ↓ +PHASE 2 +Course Player + Assessments + ↓ +PHASE 3 +Offline + PWA + ↓ +PHASE 4 +Progress + Certificates + Notifications + ↓ +PHASE 5 +APK Readiness + QA +``` + +**هر Phase را کامل کن، تست کن، `git diff` را بررسی کن، گزارش بده و سپس متوقف شو. بدون دستور صریح من وارد Phase بعد نشو.** + +# SUPPLEMENTARY PROMPT + +## MicroLearning — Learner Android APK + Native Push Notifications + Manager PWA Notifications + +این Prompt مکمل Promptهای قبلی **Learner** و **Manager** است. + +قوانین Safety، Scope Lock، Minimum Necessary Change، Regression Testing و Working Code Is Sacred که در Promptهای اصلی تعریف شده‌اند، همچنان لازم‌الاجرا هستند. + +هیچ بخش سالمی صرفاً برای اجرای این قابلیت‌ها بازنویسی نشود. + +--- + +# PART A — LEARNER PROMPT EXTENSION + +Prompt فعلی Learner دارای Phase 0 تا Phase 5 است. + +دو Phase جدید زیر را بعد از Phase 5 اضافه کن: + +```text +PHASE 6 +Android APK with Capacitor + +PHASE 7 +Native Push Notifications with Firebase Cloud Messaging +``` + +ترتیب نهایی Learner: + +```text +PHASE 0 +Critical Stabilization + ↓ +PHASE 1 +Learning Paths + Resume + ↓ +PHASE 2 +Course Player + Assessments + ↓ +PHASE 3 +Offline + PWA + ↓ +PHASE 4 +Progress + Certificates + Notifications + ↓ +PHASE 5 +APK Readiness + QA + ↓ +PHASE 6 +Android APK with Capacitor + ↓ +PHASE 7 +FCM Native Push Notifications +``` + +--- + +# PHASE 6 — Android Learner APK with Capacitor + +## هدف + +Learner PWA فعلی را بدون ایجاد Frontend جدید به Android Application واقعی تبدیل کن. + +قانون معماری: + +```text +ONE LEARNER SOURCE CODE + +React Learner + │ + ├── Web + ├── PWA + └── Capacitor Android +``` + +به هیچ عنوان یک React App، Flutter App یا Android UI مستقل برای Learner نساز. + +--- + +## 1. Capacitor Integration + +نسخه‌های dependencyهای فعلی را بررسی کن و نسخه سازگار Capacitor را انتخاب کن. + +Dependency upgrade unrelated انجام نده. + +Android Platform را به پروژه اضافه کن. + +ساختار باید به‌صورت استاندارد باشد: + +```text +frontend/ + src/ + dist/ + capacitor.config.* + android/ +``` + +از build output فعلی Vite استفاده کن. + +--- + +## 2. Learner-Only Android Experience + +APK باید تجربه Learner را ارائه کند. + +نباید به‌صورت پیش‌فرض navigation مربوط به: + +```text +Super Admin +Course Designer +Course Builder +AI Studio +Platform Administration +``` + +نمایش دهد. + +اگر همان User چند Role دارد، رفتار را از architecture واقعی Role Switching پروژه استخراج کن. + +امنیت را فقط با مخفی کردن menu پیاده‌سازی نکن. + +Backend authorization همچنان Source of Truth است. + +--- + +## 3. API Configuration + +هیچ URL مانند: + +```text +localhost +127.0.0.1 +``` + +در Production APK hard-code نشود. + +Environmentهای واقعی را پشتیبانی کن: + +```text +Development +Staging +Production +On-Premise +``` + +Configuration باید از روش استاندارد پروژه گرفته شود. + +--- + +## 4. On-Premise Support + +MicroLearning ممکن است روی Server داخلی سازمان نصب شود. + +Android App باید بتواند به Endpoint سازمان متصل شود. + +Architecture را طوری طراحی کن که Server/Base URL قابل configuration امن باشد اگر Business Model پروژه نیاز دارد. + +Validation انجام بده: + +```text +HTTPS preferred +Valid hostname +No malformed URL +Connection test +``` + +در Production اتصال insecure را بدون تصمیم صریح Business/Security مجاز نکن. + +--- + +## 5. Authentication + +Authentication فعلی را برای Capacitor بررسی کن. + +Token/Session نباید در storage ناامن نگهداری شود. + +بررسی کن: + +```text +Login +Logout +Session expiry +Token refresh if applicable +401 handling +Multiple accounts +Organization context +``` + +بعد از Logout داده Authentication و داده خصوصی local پاک یا isolate شود. + +--- + +## 6. Native Back Button + +Android Back Button باید رفتار طبیعی داشته باشد. + +مثلاً: + +```text +Course Player +→ Previous App Screen + +Drawer Open +→ Close Drawer + +Modal Open +→ Close Modal +``` + +نباید با اولین Back کل App بسته شود. + +در Root Screen در صورت لزوم رفتار native مناسب داشته باش. + +--- + +## 7. Deep Linking Foundation + +زیرساخت Deep Link ایجاد کن. + +هدف: + +```text +microlearning://learning/{assignmentId} +``` + +و ترجیحاً: + +```text +microlearning://learning/{assignmentId}/lesson/{lessonId} +``` + +در صورت استفاده از App Links: + +```text +https://learn.example.com/app/... +``` + +نیز architecture آماده باشد. + +Deep Link باید بعد از Authentication به destination صحیح منتقل شود. + +--- + +## 8. Native Safe Areas + +بررسی کن: + +```text +Status Bar +Navigation Bar +Display Cutout +Notch +Gesture Area +Keyboard +``` + +UI نباید زیر عناصر system قرار بگیرد. + +CSS safe-area را برای: + +```text +env(safe-area-inset-top) +env(safe-area-inset-bottom) +``` + +در صورت نیاز صحیح استفاده کن. + +--- + +## 9. Android Keyboard + +روی: + +```text +Login +Notes +Discussion +Assessment +Search +Forms +``` + +بررسی کن Keyboard باعث مخفی شدن input یا CTA نشود. + +--- + +## 10. External Links + +External URLها را audit کن. + +تصمیم واضح داشته باش: + +```text +Internal route +→ App + +External trusted URL +→ System browser +``` + +رفتار ناخواسته WebView ایجاد نکن. + +--- + +## 11. Downloads + +این موارد را بررسی کن: + +```text +Certificates +Documents +Course resources +``` + +اگر download در Web کار می‌کند، رفتار Android را نیز تست کن. + +برای Certificate باید حداقل امکان: + +```text +Open +Download +Share +``` + +در صورت پشتیبانی platform وجود داشته باشد. + +--- + +## 12. Sharing + +برای موارد مناسب abstraction ایجاد کن: + +```text +Certificate +Course link +Achievement +``` + +در Android از Native Share در صورت نیاز استفاده کن و Web fallback حفظ شود. + +--- + +## 13. Network Awareness + +App باید وضعیت شبکه را تشخیص دهد. + +Stateهای حداقل: + +```text +Online +Offline +Reconnecting +``` + +Offline Learning Phase 3 باید همچنان کار کند. + +Capacitor integration نباید Offline/PWA architecture را خراب کند. + +--- + +## 14. Splash Screen + +Splash Screen حرفه‌ای ولی کوتاه باشد. + +از نمایش Splash طولانی و مصنوعی جلوگیری کن. + +Branding موجود پروژه را reuse کن. + +--- + +## 15. App Icon + +Android launcher icon و adaptive icon استاندارد ایجاد کن. + +از asset برند فعلی MicroLearning استفاده کن. + +Icon جدید unrelated طراحی نکن مگر asset مناسب موجود نباشد. + +--- + +## 16. Status Bar + +Status Bar با: + +```text +Light Theme +Dark Theme +``` + +هماهنگ شود. + +--- + +## 17. PWA Must Continue Working + +بعد از اضافه شدن Capacitor این موارد نباید خراب شوند: + +```text +Web +PWA Install +Service Worker +Offline Downloads +Responsive UI +Desktop Learner +``` + +APK نباید باعث fork شدن codebase شود. + +--- + +## 18. Android Build + +Debug APK بساز. + +در صورت آماده بودن signing configuration، release build architecture را نیز آماده کن. + +Secret signing key را commit نکن. + +--- + +## 19. GitHub Actions Readiness + +در صورت وجود GitHub Actions، pipeline جدا برای Android ایجاد کن یا readiness آن را اضافه کن. + +هدف آینده: + +```text +Frontend Test +↓ +Frontend Build +↓ +Capacitor Sync +↓ +Android Build +↓ +APK Artifact +``` + +اما CI موجود را بدون ضرورت بازنویسی نکن. + +--- + +## PHASE 6 ACCEPTANCE CRITERIA + +Phase 6 فقط وقتی Complete است که: + +```text +React Web works +PWA works +Android project builds +APK installs +Login works +Learner Home works +Course Player works +Assessments work +Resume works +Offline works +Certificates work +Android Back works +Dark Mode works +RTL works +LTR works +``` + +و هیچ Frontend دوم ایجاد نشده باشد. + +--- + +# PHASE 7 — Native Push Notifications with FCM + +## هدف + +Learner Android App باید Notification واقعی Android دریافت کند. + +حتی وقتی App در foreground نیست. + +Architecture مقصد: + +```text +MicroLearning Backend + ↓ +Notification Domain Event + ↓ +Notification Service + ↓ +Laravel Queue + ↓ +Firebase Cloud Messaging + ↓ +Android App + ↓ +Native Android Notification +``` + +--- + +# 1. Notification Architecture + +Notification logic را داخل Controllerها پراکنده نکن. + +Architecture ترجیحی: + +```text +Domain Event +↓ +Notification Orchestrator +↓ +Channel +``` + +Channelها: + +```text +InAppChannel +PushChannel +EmailChannel +``` + +Web Push در آینده قابل اضافه شدن باشد. + +--- + +# 2. Device Registration + +Backend باید Deviceهای User را مدیریت کند. + +مدل مناسب طراحی کن. + +مثلاً: + +```text +user_devices +``` + +فیلدهای منطقی: + +```text +id +user_id +organization_id +platform +device_identifier +push_token +enabled +last_seen_at +created_at +updated_at +``` + +نام نهایی را با conventions پروژه هماهنگ کن. + +--- + +# 3. Multiple Devices + +یک User ممکن است: + +```text +Phone +Tablet +Second Phone +``` + +داشته باشد. + +Notification architecture باید Multiple Device را پشتیبانی کند. + +--- + +# 4. Token Registration + +بعد از دریافت FCM Token: + +```text +Android App +↓ +Authenticated API +↓ +Register Device Token +``` + +Backend ownership را verify کند. + +--- + +# 5. Token Refresh + +FCM Token ممکن است تغییر کند. + +Token refresh باید به Backend sync شود. + +Duplicate token ایجاد نکن. + +--- + +# 6. Logout + +هنگام Logout: + +Device registration مربوطه disable یا unregister شود. + +User قبلی نباید Notification User جدید را دریافت کند. + +--- + +# 7. Android 13+ Permission + +برای Androidهایی که Permission لازم دارند: + +```text +POST_NOTIFICATIONS +``` + +را صحیح مدیریت کن. + +Permission را بلافاصله و بدون context درخواست نکن. + +UX پیشنهادی: + +```text +اعلان‌های یادگیری را فعال کنید + +مهلت دوره‌ها، آموزش‌های جدید و +گواهی‌های صادرشده را از دست ندهید. + +[فعال کردن اعلان‌ها] +[بعداً] +``` + +از Dialog/Component استاندارد پروژه استفاده کن. + +--- + +# 8. Notification Channels + +Android Notification Channels ایجاد کن. + +حداقل: + +```text +Learning +Deadlines +Certificates +System +``` + +در صورت نیاز: + +```text +Assignments +Reminders +``` + +اما Channelهای بسیار زیاد نساز. + +--- + +# 9. Learner Notification Types + +حداقل Eventهای زیر را بررسی و در صورت support Backend پیاده‌سازی کن: + +```text +Course Assigned +Learning Path Assigned +Mandatory Learning Assigned + +Deadline Approaching +Due Today +Overdue + +Continue Learning Reminder + +Assessment Available + +Course Completed + +Certificate Issued + +Important Organization Announcement +``` + +--- + +# 10. Notification Preferences + +به Preference واقعی User احترام بگذار. + +مثلاً: + +```text +New assignments +Deadline reminders +Daily reminders +Certificates +Organization announcements +``` + +Notificationهای security/critical system در صورت Business Rule می‌توانند سیاست متفاوت داشته باشند. + +--- + +# 11. Scheduled Reminders + +Reminderها از queue/scheduler ارسال شوند. + +برای مثال: + +```text +3 days before deadline +1 day before deadline +Due today +Overdue +``` + +از Request synchronous برای ارسال bulk notification استفاده نکن. + +--- + +# 12. Duplicate Protection + +یک Notification نباید به دلیل Retry Queue چندبار برای یک Event ارسال شود. + +Idempotency مناسب ایجاد کن. + +مثلاً بر اساس: + +```text +user +event +assignment +notification type +scheduled window +``` + +--- + +# 13. Deep Link on Notification Tap + +هر Push باید در صورت نیاز مقصد داشته باشد. + +مثلاً: + +### Course Assigned + +```text +Notification +↓ +My Learning +↓ +Course +``` + +### Deadline + +```text +Notification +↓ +Assignment +↓ +Resume Lesson +``` + +### Certificate + +```text +Notification +↓ +My Certificates +↓ +Certificate +``` + +--- + +# 14. Foreground Behavior + +اگر App باز است، Notification نباید UX آزاردهنده ایجاد کند. + +در صورت مناسب بودن: + +```text +In-app banner / toast +``` + +و در background: + +```text +Native notification +``` + +--- + +# 15. Notification History + +Push Notification باید در صورت منطقی بودن با Notification Center داخل App هماهنگ باشد. + +یعنی Notification دریافت‌شده فقط ephemeral نباشد. + +User بتواند بعداً در: + +```text +Notifications +``` + +آن را مشاهده کند. + +--- + +# 16. Read / Unread + +Notification Center: + +```text +Unread +Read +Mark as read +Mark all as read +``` + +داشته باشد در صورت support فعلی. + +--- + +# 17. Security + +هر Notification Payload را حداقل‌گرا نگه دار. + +Sensitive data را مستقیماً داخل Push Payload قرار نده. + +مثلاً اطلاعات خصوصی ارزیابی یا اطلاعات حساس Employee ارسال نشود. + +Push فقط context identifier امن ارسال کند و App داده اصلی را از API مجاز دریافت کند. + +--- + +# 18. FCM Credentials + +Firebase credential: + +```text +.env +Secret Manager +Server Configuration +``` + +باشد. + +هیچ: + +```text +Private Key +Server Key +Service Account Secret +``` + +در repository commit نشود. + +--- + +# 19. Failure Handling + +FCM responseها را مدیریت کن. + +برای Tokenهای: + +```text +Invalid +Expired +Unregistered +``` + +Device token را disable/remove کن. + +--- + +# 20. Observability + +حداقل بتوانیم بفهمیم: + +```text +Queued +Sent to FCM +Failed +Invalid token +``` + +ولی Delivery قطعی را اگر FCM چنین اطلاعاتی نداده، جعلی نمایش نده. + +--- + +# 21. Push Tests + +حداقل تست: + +```text +User A notification +→ User A devices only +``` + +```text +User B +→ Must not receive User A notification +``` + +```text +Disabled preference +→ Optional notification not sent +``` + +```text +Invalid token +→ Safely disabled +``` + +```text +Logout +→ Device no longer receives private push +``` + +```text +Notification tap +→ Correct deep link +``` + +--- + +# PHASE 7 ACCEPTANCE CRITERIA + +Phase 7 زمانی Complete است که: + +```text +FCM connected +Device registration works +Token refresh works +Logout cleanup works +Android permission works +Native notification works +Background notification works +Notification channels work +Preferences work +Deep linking works +Duplicate push protection works +Notification center stays consistent +Security tests pass +``` + +--- + +# LEARNER FINAL QUALITY GATE — UPDATED + +پس از Phase 7 حتماً این Flow را تست کن: + +```text +Learner Login +↓ +Device Registered +↓ +Course Assigned +↓ +App Closed +↓ +Native Notification Received +↓ +Tap Notification +↓ +App Opens +↓ +Correct Course +↓ +Correct Resume Location +``` + +و: + +```text +Deadline Reminder +↓ +Native Notification +↓ +Tap +↓ +Assignment +↓ +Continue Learning +``` + +و: + +```text +Course Complete +↓ +Certificate Issued +↓ +Native Notification +↓ +My Certificates +``` + +--- + +# UPDATED LEARNER RELEASE STATUS + +در گزارش نهایی یکی از این وضعیت‌ها را بده: + +```text +NOT READY + +PWA PRODUCTION READY + +ANDROID DEBUG READY + +ANDROID RELEASE READY WITH CONDITIONS + +ANDROID PRODUCTION READY +``` + +--- + +# PART B — MANAGER PROMPT EXTENSION + +Prompt Manager فعلی Phase 0 تا Phase 5 دارد. + +یک Phase جدید اضافه کن: + +```text +PHASE 6 +Manager PWA & Push/Web Notification Experience +``` + +ترتیب نهایی: + +```text +PHASE 0 +Role & Permission Audit + ↓ +PHASE 1 +Manager Shell & Dashboard + ↓ +PHASE 2 +My Team & Learning Profiles + ↓ +PHASE 3 +Assignments, Deadlines & Notifications + ↓ +PHASE 4 +Skills, Insights, Reports & Approvals + ↓ +PHASE 5 +Responsive, Security & Final QA + ↓ +PHASE 6 +Manager PWA & Notifications +``` + +--- + +# PHASE 6 — Manager PWA & Notification Experience + +## هدف + +Manager Panel علاوه بر Desktop/Web، روی موبایل نیز به‌صورت PWA قابل استفاده باشد. + +برای Manager فعلاً Android APK مستقل نساز. + +Architecture: + +```text +Manager React Experience + ↓ +Web ++ +Installable PWA +``` + +--- + +# 1. PWA Availability + +Manager بتواند در browserهای پشتیبانی‌شده PWA را Install کند. + +PWA Shell باید Role-aware باشد. + +وقتی Manager وارد می‌شود: + +```text +Manager Dashboard +``` + +نمایش داده شود، نه Learner Home. + +--- + +# 2. Role Combination + +اگر Manager خودش Learner هم هست، Role Switching یا Experience Switching فعلی پروژه را بررسی کن. + +راه‌حل جدید duplicate نساز. + +در صورت وجود switching استاندارد: + +```text +Manager Mode +Learner Mode +``` + +را حفظ کن. + +--- + +# 3. Manager Mobile Priorities + +PWA Manager روی موبایل باید حداقل این Actions را عالی پشتیبانی کند: + +```text +View Needs Attention +View My Team +View Employee Learning +Send Reminder +Assign Learning +Review Deadline +Approve Request +View Team Progress +``` + +Course Builder و Admin Toolهای unrelated را وارد PWA Manager نکن. + +--- + +# 4. Manager In-App Notifications + +Notification Center برای Manager category-aware باشد. + +نمونه: + +```text +Team Learning +Deadlines +Approvals +Skill Alerts +Reports +System +``` + +--- + +# 5. Manager Notification Events + +در صورت وجود داده واقعی، Manager بتواند Notification دریافت کند برای: + +```text +Team member overdue + +Mandatory course not started + +Team deadline approaching + +Approval requested + +Learning request received + +Assignment completed + +Critical skill gap alert + +Weekly learning summary ready +``` + +Insight ساده را بی‌دلیل Push نکن. + +Notification fatigue ایجاد نکن. + +--- + +# 6. Immediate vs Digest + +همه Eventها Push فوری نباشند. + +تقسیم‌بندی منطقی: + +```text +Immediate: +Critical overdue +Approval needing action +Important assignment issue +``` + +```text +Digest: +Team learning summary +Weekly progress +General insights +``` + +--- + +# 7. Manager Notification Preferences + +Manager بتواند تنظیم کند: + +```text +Team deadline alerts +Overdue alerts +Approval requests +Learning activity +Weekly summary +``` + +--- + +# 8. Web Push + +اگر infrastructure Web Push پروژه آماده و قابل اتکا است، Manager PWA بتواند Web Push واقعی دریافت کند. + +اگر هنوز زیرساخت Web Push وجود ندارد: + +Fake implementation ایجاد نکن. + +Architecture Notification Channel را طوری نگه دار که Web Push بعداً اضافه شود. + +--- + +# 9. Shared Notification Backend + +Notification Business Rules را بین: + +```text +Learner APK +Manager PWA +In-App Notification Center +``` + +duplicate نکن. + +Architecture: + +```text +Domain Event + ↓ +Notification Service + ↓ +Recipient Resolution + ↓ +Preferences + ↓ +Channels +``` + +Channels: + +```text +In-App +FCM +Web Push +Email +``` + +باید قابل توسعه باشند. + +--- + +# 10. Manager Assignment Notification Integration + +وقتی Manager در Phase 3 دوره‌ای تخصیص می‌دهد: + +```text +Manager +↓ +Assign Course +↓ +Learners +↓ +Notification Event +↓ +In-App + FCM according to preference +``` + +در صورت APK Learner. + +Manager نباید خودش مستقیم FCM API را فراخوانی کند. + +--- + +# 11. Manager Reminder Action + +Action: + +```text +Send Reminder +``` + +باید از همان Notification Service استفاده کند. + +نه implementation جداگانه. + +قبل از ارسال bulk reminder خلاصه نشان بده: + +```text +12 learners +Course: Workplace Safety +Channel: +In-App + Push + +[Send Reminder] +``` + +--- + +# 12. Rate / Spam Protection + +Manager نباید بتواند ناخواسته پشت سر هم Pushهای مشابه برای یک Team ارسال کند. + +برای Reminder: + +```text +cooldown +duplicate detection +authorization +``` + +در نظر بگیر. + +--- + +# 13. Team Scope + +Notification recipientها حتماً Backend-scoped باشند. + +Manager با دستکاری Request نباید بتواند برای افراد خارج از Team مجاز خودش Push ارسال کند. + +--- + +# 14. Notification Audit + +عملیات مهم Manager مثل: + +```text +Bulk reminder sent +Assignment notification triggered +Deadline changed +``` + +در صورت وجود Audit architecture ثبت شود. + +--- + +# 15. PWA Responsive QA + +بررسی: + +```text +360 +375 +390 +430 +768 +``` + +Manager PWA روی mobile نباید desktop table shrink شده باشد. + +--- + +# MANAGER PHASE 6 ACCEPTANCE CRITERIA + +```text +Manager PWA installable +Manager route correct +Mobile dashboard works +Team actions work +Notifications permission-aware +Reminder action uses shared service +Team scope enforced +Duplicate reminder protection works +Learner FCM integration compatible +No separate Manager APK created +``` + +--- + +# PART C — SHARED NOTIFICATION ARCHITECTURE + +این بخش برای هر دو Prompt الزامی است. + +نباید برای Manager و Learner دو سیستم Notification جدا ساخته شود. + +Architecture مقصد: + +```text + MicroLearning + │ + Domain/Application Events + │ + Notification Service + │ + ┌───────────┼───────────┐ + │ │ │ + In-App Push Email + │ + ┌────────┴────────┐ + │ │ + FCM Web Push + │ │ + Learner APK Manager PWA +``` + +--- + +# Recipient Resolution + +Notification Service باید خودش مشخص کند Recipient چه کسی است. + +مثلاً: + +```text +Course Assigned +→ Learner +``` + +```text +Deadline approaching +→ Learner +``` + +```text +Employee overdue +→ Authorized Manager +``` + +```text +Approval requested +→ Manager +``` + +--- + +# Preferences + +Flow: + +```text +Event +↓ +Recipient +↓ +Permission / Scope +↓ +Notification Preference +↓ +Channel +↓ +Queue +↓ +Delivery +``` + +--- + +# Queue First + +ارسال Notificationهای خارجی را synchronous داخل user request انجام نده. + +از Queue استفاده کن. + +Failure FCM/Web Push نباید عملیات اصلی مثل: + +```text +Assign Course +Complete Course +Issue Certificate +``` + +را rollback کند مگر business rule صریحی وجود داشته باشد. + +--- + +# Notification Taxonomy + +یک taxonomy استاندارد داشته باش. + +مثلاً: + +```text +learning.assigned +learning.reminder +learning.deadline_soon +learning.overdue +learning.completed + +assessment.available + +certificate.issued + +manager.approval_requested +manager.team_overdue +manager.weekly_summary + +system.announcement +``` + +Naming را با conventions فعلی پروژه تطبیق بده. + +--- + +# Notification Payload + +Payload استاندارد داشته باش: + +```text +type +title +body +recipient +entityType +entityId +deepLink +createdAt +``` + +Sensitive information اضافه نکن. + +--- + +# Shared Deep Links + +Learner: + +```text +/course +/lesson +/certificate +/notification +``` + +Manager: + +```text +/team/member +/assignment +/approval +/report +``` + +هم Web و هم Native باید از destination منطقی مشترک استفاده کنند. + +--- + +# FINAL SAFETY RULE + +برای اضافه کردن Capacitor، FCM یا PWA Notification: + +**هیچ‌یک از فازهای قبلی را دوباره Refactor نکن مگر integration واقعاً به تغییر آن نیاز داشته باشد.** + +اگر integration نیازمند تغییر گسترده شد: + +```text +STOP +``` + +و قبل از ادامه گزارش بده: + +```text +Integration blocker: +Why: +Affected modules: +Risk: +Minimum viable fix: +Alternative: +``` + +--- + +# UPDATED EXECUTION ORDER + +ترتیب پیشنهادی نهایی برای این دو بخش: + +```text +LEARNER PHASE 0–5 + ↓ +LEARNER PHASE 6 +Android APK + ↓ +LEARNER PHASE 7 +FCM Push + ↓ +MANAGER PHASE 0–5 + ↓ +MANAGER PHASE 6 +PWA + Manager Notifications +``` + +اگر Manager Phaseهای 0–5 قبلاً انجام شده‌اند، مستقیماً Phase 6 را اجرا کن. + +**هیچ Phase را بدون دستور صریح من شروع نکن. پس از هر Phase تست کن، گزارش بده و متوقف شو.** diff --git a/Prompt/Manager.md b/Prompt/Manager.md new file mode 100644 index 0000000..48cdc44 --- /dev/null +++ b/Prompt/Manager.md @@ -0,0 +1,3246 @@ +# Master Prompt — MicroLearning Manager Experience Redesign + +می‌خواهم بخش **Manager / مدیران** در پروژه MicroLearning را به‌صورت کامل بازطراحی و اصلاح کنی. + +این کار فقط یک تغییر ظاهری نیست. + +هدف این است که Manager Experience از حالت «نسخه محدودشده Admin Panel» خارج شود و به یک فضای مستقل، ساده، actionable و مخصوص مدیریت یادگیری تیم تبدیل شود. + +--- + +# هدف اصلی Manager Experience + +پنل مدیر باید حول این سؤال طراحی شود: + +> وضعیت یادگیری تیم من چگونه است و الان باید چه اقدامی انجام دهم؟ + +Manager نباید برای انجام کارهای روزمره مجبور باشد با ساختار Course Designer، Admin یا Super Admin کار کند. + +--- + +# اصول اصلی + +Manager Experience باید: + +* Team-centric باشد. +* Action-oriented باشد. +* Data driven باشد. +* ساده‌تر از Admin Panel باشد. +* Role-aware باشد. +* Permission-aware باشد. +* فقط اطلاعات مجاز تیم را نمایش دهد. +* برای Desktop، Tablet و Mobile مناسب باشد. +* RTL و LTR را پشتیبانی کند. +* Dark Mode را خراب نکند. +* از Design System موجود استفاده کند. + +--- + +# قانون Safety + +## Working Code Is Sacred + +اگر بخشی: + +* درست کار می‌کند، +* تست دارد، +* Security Issue ندارد، +* و مانع Manager Experience نیست، + +فقط برای زیبایی Architecture آن را تغییر نده. + +--- + +# Minimum Necessary Change + +فقط تغییراتی را انجام بده که مستقیماً برای Manager Experience ضروری هستند. + +از Refactorهای unrelated خودداری کن. + +--- + +# Scope Lock + +در ابتدای هر Phase اعلام کن: + +```text +Files expected to change +Modules affected +API endpoints affected +Permissions affected +Expected behavior changes +Behavior that must remain unchanged +``` + +اگر Issue جدیدی پیدا کردی که مربوط به فاز جاری نیست، در: + +```text +Deferred Technical Debt +``` + +ثبت کن. + +--- + +# Backend Safety + +هیچ Permission یا Data Scope را فقط در Frontend پیاده‌سازی نکن. + +اگر Manager نباید داده‌ای را ببیند: + +**Backend نیز باید آن را محدود کند.** + +Hide کردن Menu امنیت محسوب نمی‌شود. + +--- + +# Manager Data Scope + +قاعده پیش‌فرض: + +```text +Manager + ↓ +Own permitted team + ↓ +Direct / authorized reports +``` + +نه: + +```text +Manager + ↓ +Entire Organization +``` + +مگر اینکه Permission صریح وجود داشته باشد. + +--- + +# PHASE 0 — Manager Role Audit & Permission Scope + +## هدف + +قبل از تغییر UI، نقش Manager را در کل پروژه بررسی کن. + +در این Phase redesign انجام نده. + +--- + +## 1. Manager Role Audit + +تمام موارد مربوط به Manager را پیدا کن: + +```text +roles +permissions +policies +middleware +API authorization +organization scope +team membership +reporting hierarchy +frontend route guards +navigation permissions +``` + +مشخص کن Manager در وضعیت فعلی چه چیزهایی می‌تواند: + +```text +View +Create +Update +Delete +Assign +Approve +Export +``` + +کند. + +--- + +## 2. Data Visibility Audit + +بررسی کن Manager اکنون به چه اطلاعاتی دسترسی دارد. + +به‌خصوص: + +```text +Users +Teams +Courses +Assignments +Assessments +Progress +Skills +Reports +Certificates +Monitoring +``` + +هر endpoint را بررسی کن. + +Manager نباید بتواند با تغییر دستی URL یا API parameter داده خارج از Scope خود را ببیند. + +IDOR را بررسی کن. + +--- + +## 3. Define Manager Permission Matrix + +یک Matrix ایجاد کن. + +مثال: + +| Capability | Manager | +| --------------------------- | ---------------- | +| View own team | Yes | +| View organization users | No | +| View team learning progress | Yes | +| Assign approved course | Yes | +| Create course | No by default | +| Edit course | No | +| Use Builder | No | +| Use AI Studio | No | +| View team skills | Yes | +| Export team report | Permission based | +| Extend assignment deadline | Permission based | +| Approve learning request | Permission based | + +Matrix نهایی را براساس قابلیت‌های واقعی پروژه تنظیم کن. + +--- + +## 4. Role Separation + +این چهار Experience را از هم تفکیک کن: + +```text +Super Admin +Course Designer +Manager +Learner +``` + +Manager نباید Sidebar مربوط به Course Designer را با چند گزینه hidden دریافت کند. + +Manager Experience باید Navigation خودش را داشته باشد. + +--- + +## 5. Backend Authorization Tests + +Test بنویس برای اینکه Manager: + +### بتواند: + +```text +Own team data +Own team assignments +Own team progress +Authorized reports +``` + +### نتواند: + +```text +Other team's private data +Organization-wide user data +Unauthorized course editing +Unauthorized Builder access +Unauthorized admin settings +``` + +--- + +## Acceptance Criteria Phase 0 + +* Manager role دقیقاً تعریف شده. +* Permission Matrix وجود دارد. +* Team Scope مشخص است. +* Data Leakage بررسی شده. +* Backend authorization تست دارد. +* Feature unrelated تغییر نکرده. +* UI redesign هنوز شروع نشده. + +بعد متوقف شو. + +--- + +# PHASE 1 — Manager Shell, Navigation & Dashboard + +## هدف + +Manager یک Experience مستقل و ساده داشته باشد. + +--- + +# Manager Navigation + +Navigation پیشنهادی: + +```text +داشبورد + +تیم من + +یادگیری تیم + +تخصیص آموزش + +مهارت‌ها + +گزارش‌ها + +تأییدها +``` + +پایین Navigation: + +```text +اعلان‌ها +تنظیمات شخصی +``` + +فقط آیتم‌هایی را نمایش بده که Manager واقعاً Permission آن‌ها را دارد. + +--- + +# Remove Manager Irrelevant Navigation + +موارد زیر به‌صورت پیش‌فرض نباید در پنل Manager نمایش داده شوند: + +```text +Course Builder +AI Studio +Question Bank +Template Management +Library Administration +Subscription +Organization Settings +System Settings +``` + +مگر Manager Permission صریح دیگری داشته باشد. + +--- + +# Manager App Shell + +Shell مدیر شامل: + +```text +Sidebar +Top Header +Context +Main Content +``` + +باشد. + +در Header: + +```text +Search if useful +Notifications +User menu +Team context if applicable +``` + +وجود داشته باشد. + +--- + +# Manager Dashboard + +Dashboard مدیر را از Dashboard عمومی جدا کن. + +Layout پیشنهادی: + +```text +Greeting +↓ +Team Health +↓ +Needs Attention +↓ +Team Learning Progress +↓ +Upcoming Deadlines +↓ +Skills Snapshot +↓ +Recent Activity +``` + +--- + +# KPI Cards + +KPIها compact باشند. + +پیشنهاد: + +```text +Team members + +Active learners + +Completion rate + +Behind schedule + +Due soon +``` + +اعداد باید از داده واقعی backend بیایند. + +Metric جعلی یا static ایجاد نکن. + +--- + +# Needs Attention + +مهم‌ترین بخش Dashboard باشد. + +نمونه: + +```text +4 learners are behind schedule + +3 mandatory courses have not been started + +2 assignments are due this week + +3 employees haven't been active for 7 days +``` + +هر Item باید مستقیم Action داشته باشد. + +مثلاً: + +```text +View learners + +Send reminder + +Extend deadline + +View course +``` + +--- + +# Continue Management Tasks + +وظایف نیمه‌تمام Manager را نمایش بده: + +```text +Pending approvals + +Recently assigned learning + +Saved reports + +Pending team actions +``` + +در صورت وجود داده واقعی. + +--- + +# Recent Activity + +Activity Feed ساده باشد: + +```text +Ali completed Leadership Basics + +Sara started Workplace Safety + +5 users were assigned Communication Skills + +Certificate issued to Reza +``` + +--- + +# Dashboard Empty State + +اگر Manager تیم ندارد یا داده‌ای موجود نیست، Dashboard نباید شکسته یا خالی باشد. + +State مناسب طراحی کن. + +--- + +## Acceptance Criteria Phase 1 + +* Manager Navigation مستقل است. +* Admin navigation reuse کورکورانه نشده. +* Dashboard کاملاً team-centric است. +* Needs Attention وجود دارد. +* KPIها compact و واقعی‌اند. +* Actions مستقیم‌اند. +* Permission-aware UI وجود دارد. +* responsive صحیح است. + +بعد متوقف شو. + +--- + +# PHASE 2 — My Team & Employee Learning Profiles + +## هدف + +Manager بتواند وضعیت اعضای تیم را سریع بفهمد. + +--- + +# My Team Page + +صفحه: + +```text +تیم من +``` + +ایجاد یا بازطراحی کن. + +Toolbar: + +```text +Search +Status Filter +Team/Subteam Filter if permitted +Learning Status +Skills Filter if applicable +``` + +--- + +# Team Table + +اطلاعات پیشنهادی: + +```text +Employee + +Role / Position + +Active courses + +Completion % + +Due soon + +Overdue + +Last activity + +Status +``` + +مثلاً: + +```text +Ali Rezaei +3 active +82% +0 overdue +Active today +Good +``` + +--- + +# Team Status + +از semantic state استفاده کن: + +```text +On Track + +Needs Attention + +Behind + +Inactive +``` + +رنگ تنها indicator نباشد. + +Text یا icon هم وجود داشته باشد. + +--- + +# Employee Learning Profile + +روی Employee کلیک شود و پروفایل یادگیری باز شود. + +حداقل: + +```text +Overview + +Active Learning + +Completed Learning + +Assessments + +Skills + +Learning Paths + +Certificates + +Recent Activity +``` + +اما فقط اطلاعاتی که Manager اجازه دیدن آن را دارد. + +--- + +# Employee Overview + +پیشنهاد: + +```text +Name +Position +Team + +Current progress +Active courses +Completed courses +Due assignments +Skill development +``` + +--- + +# Learning Timeline + +در صورت وجود داده: + +```text +Assigned +Started +Progressed +Completed +Assessment +Certificate +``` + +Timeline ساده ایجاد کن. + +--- + +# Manager Quick Actions + +از Employee Profile: + +```text +Assign Learning + +Send Reminder + +View Progress + +View Skills +``` + +و در صورت Permission: + +```text +Extend Deadline +``` + +--- + +# Privacy + +اطلاعات unrelated منابع انسانی را نمایش نده. + +Manager Learning Profile نباید به HR Master Profile تبدیل شود. + +--- + +## Acceptance Criteria Phase 2 + +* My Team قابل استفاده است. +* Search و Filter مناسب دارد. +* Employee Learning Profile ایجاد شده. +* مدیر فقط Scope خودش را می‌بیند. +* Quick Actions وجود دارد. +* mobile table مناسب است. +* N+1 query یا request explosion ایجاد نشده. + +بعد متوقف شو. + +--- + +# PHASE 3 — Learning Assignment, Deadlines & Notifications + +## هدف + +تخصیص آموزش برای Manager بسیار ساده شود. + +--- + +# Assignment Flow + +Manager نباید وارد workflow پیچیده Admin شود. + +Flow پیشنهادی: + +```text +Who? +↓ +What learning? +↓ +Deadline +↓ +Notification +↓ +Review +↓ +Assign +``` + +--- + +# STEP 1 — Audience + +Manager بتواند انتخاب کند: + +```text +Whole Team + +Specific employees + +Sub-team +``` + +فقط در Scope مجاز. + +--- + +# STEP 2 — Learning + +Manager فقط محتوایی را ببیند که برای assignment مجاز است. + +مثلاً: + +```text +Published Courses + +Learning Paths + +Mandatory Learning +``` + +Draft یا private course در صورت عدم Permission نمایش داده نشود. + +--- + +# STEP 3 — Deadline + +انتخاب: + +```text +No deadline + +Specific date + +Relative deadline +``` + +مثلاً: + +```text +Complete within 14 days +``` + +اگر backend support می‌کند. + +--- + +# STEP 4 — Notification + +گزینه‌های مناسب: + +```text +Notify learner now + +Reminder before deadline + +Reminder on deadline + +Reminder if not started +``` + +--- + +# Push Notification Readiness + +Architecture را طوری طراحی کن که Notification Service بعداً بتواند به: + +```text +Web +PWA +Android APK +FCM +``` + +وصل شود. + +ولی اگر FCM هنوز پیاده‌سازی نشده، mock یا fake production integration ایجاد نکن. + +Integration point تمیز ایجاد کن. + +--- + +# Notification Preferences + +به تنظیمات notification کاربر احترام بگذار. + +Notificationهای mandatory و optional را تفکیک کن. + +--- + +# Assignment Confirmation + +قبل از Assign خلاصه واضح بده: + +```text +Course: +Workplace Safety + +Audience: +12 employees + +Deadline: +September 10 + +Notifications: +Immediate + 3 days before deadline +``` + +--- + +# Success State + +بعد از Assignment: + +```text +Learning assigned successfully + +12 learners assigned +12 notifications scheduled +``` + +اگر بخشی fail شده، partial failure را واضح گزارش کن. + +--- + +# Deadline Management + +Manager در صورت Permission بتواند: + +```text +Extend deadline + +Remove deadline +``` + +کند. + +Bulk extension نیز در صورت نیاز واقعی. + +--- + +# Reminder Action + +در Team / Assignment view: + +```text +Send reminder +``` + +وجود داشته باشد. + +از duplicate notification جلوگیری کن. + +--- + +## Acceptance Criteria Phase 3 + +* Assignment workflow ساده است. +* Scope رعایت می‌شود. +* Deadline management صحیح است. +* Notification architecture آماده است. +* Error و partial failure مدیریت می‌شود. +* duplicate assignments کنترل شده. +* tests وجود دارند. + +بعد متوقف شو. + +--- + +# PHASE 4 — Skills, Insights, Reports & Approvals + +## هدف + +Manager فقط Progress نبیند؛ بتواند تصمیم مدیریتی بگیرد. + +--- + +# Skills Dashboard + +صفحه Skills برای Manager team-specific باشد. + +نمایش: + +```text +Team skill level + +Skill gaps + +Improving skills + +Critical gaps +``` + +--- + +# Skill Visualization + +مثلاً: + +```text +Communication 82% + +Leadership 68% + +Excel 54% + +Safety 91% +``` + +عددها فقط اگر مدل داده واقعاً آنها را پشتیبانی می‌کند. + +--- + +# Skill Gap + +Manager بتواند ببیند: + +```text +Skill + +Required level + +Current level + +Gap + +Affected employees +``` + +--- + +# Learning Recommendation + +اگر سیستم recommendation واقعی دارد: + +```text +Suggested learning +``` + +نمایش بده. + +اگر ندارد، recommendation جعلی نساز. + +در صورت امکان rule-based recommendation را جداگانه پیاده‌سازی کن. + +--- + +# Manager Insights + +یک بخش: + +```text +بینش‌های این هفته +``` + +ایجاد کن. + +Insights باید از داده واقعی derive شوند. + +مثال: + +```text +Completion rate increased 8% + +3 learners inactive for more than 7 days + +Workplace Safety has the lowest completion rate + +Communication is the largest team skill gap +``` + +--- + +# No Fake AI + +Insightهای ساده را به اسم AI نمایش نده. + +اگر AI واقعاً تحلیل می‌کند، مشخص باشد. + +اگر rule-based است، به‌عنوان Insight نمایش داده شود. + +--- + +# Reports + +Manager Reportها باید team-scoped باشند. + +پیشنهاد: + +```text +Team Learning Overview + +Course Completion + +Assignment Status + +Assessment Performance + +Skills Gap + +Engagement +``` + +--- + +# Filters + +گزارش‌ها حداقل: + +```text +Date + +Course + +Learning Path + +Employee + +Team/Subteam + +Status +``` + +در حد Permission مدیر. + +--- + +# Export + +اگر سیستم export دارد: + +```text +Excel +CSV +PDF +``` + +Manager فقط داده Scope خودش را export کند. + +--- + +# Approvals + +اگر Approval Workflow وجود دارد، صفحه: + +```text +تأییدها +``` + +فقط کارهای مربوط به Manager را نشان دهد. + +مثلاً: + +```text +Learning request + +Deadline extension + +Course enrollment + +Certificate approval +``` + +بسته به قابلیت واقعی محصول. + +--- + +# Approval UX + +هر Approval: + +```text +Requester + +Request + +Reason + +Date + +Relevant context + +Approve + +Reject +``` + +Reject باید در صورت نیاز Reason داشته باشد. + +--- + +## Acceptance Criteria Phase 4 + +* Skills team-scoped هستند. +* Skill gaps واضح‌اند. +* Insights actionable هستند. +* Reportها team-scoped هستند. +* Export امن است. +* Approvals واضح و permission-aware هستند. +* fake metric یا fake AI وجود ندارد. + +بعد متوقف شو. + +--- + +# PHASE 5 — Responsive, Security, QA & Final Manager Polish + +## هدف + +Manager Experience را برای Release آماده کن. + +--- + +# Responsive QA + +حداقل viewportهای زیر: + +```text +375 + +430 + +768 + +1024 + +1280 + +1440 +``` + +را بررسی کن. + +--- + +# Mobile Manager Experience + +Mobile نباید نسخه shrink شده Desktop باشد. + +Navigation: + +```text +Drawer +``` + +یا navigation مناسب role. + +Dashboard روی موبایل: + +```text +Needs Attention +↓ +Quick Actions +↓ +KPIs +↓ +Team +↓ +Deadlines +``` + +اولویت اطلاعات را تغییر بده. + +--- + +# Tables + +در Mobile صرفاً horizontal scroll ایجاد نکن. + +از: + +```text +Column priority + +Card representation + +Expandable row +``` + +استفاده کن. + +--- + +# Quick Actions on Mobile + +Manager باید بتواند از گوشی: + +```text +View team + +Send reminder + +Assign course + +Review approval + +Check progress +``` + +را انجام دهد. + +--- + +# Accessibility + +بررسی کن: + +```text +Keyboard + +Focus + +ARIA + +Labels + +Dialogs + +Drawers + +Tables + +Forms + +Color contrast + +RTL + +Screen reader semantics +``` + +--- + +# Security Recheck + +دوباره بررسی کن: + +```text +IDOR + +Role escalation + +Team scope bypass + +URL manipulation + +API parameter manipulation + +Export leakage +``` + +Frontend hiding را Security Control محسوب نکن. + +--- + +# Performance + +Manager Dashboard ممکن است چند Widget داشته باشد. + +بررسی کن: + +```text +duplicate API requests + +N+1 backend queries + +large payloads + +unnecessary polling + +over-fetching + +unnecessary renders +``` + +برای Dashboard در صورت نیاز endpoint تجمیعی بهینه طراحی کن، اما فقط اگر bottleneck واقعی وجود دارد. + +--- + +# Loading States + +از Skeleton استفاده کن. + +هر Widget باید: + +```text +Loading + +Empty + +Error + +Success +``` + +داشته باشد. + +خرابی یک Widget نباید کل Dashboard را از کار بیندازد مگر dependency واقعی وجود داشته باشد. + +--- + +# RTL / LTR + +فارسی: + +```text +RTL +``` + +انگلیسی: + +```text +LTR +``` + +Table alignment، icons، arrows و breadcrumbs را بررسی کن. + +--- + +# Dark Mode + +تمام Manager pages را بررسی کن. + +به‌خصوص: + +```text +Charts + +Tables + +Status badges + +Empty states + +Filters + +Dialogs +``` + +--- + +# Final Test Flow + +حداقل این Scenarioها را اجرا کن. + +## Scenario 1 + +```text +Manager Login +→ +Dashboard +→ +View Needs Attention +→ +Employee +→ +Send Reminder +``` + +## Scenario 2 + +```text +Manager +→ +My Team +→ +Employee +→ +Learning Profile +``` + +## Scenario 3 + +```text +Manager +→ +Assign Learning +→ +Whole Team +→ +Set Deadline +→ +Notify +→ +Confirm +``` + +## Scenario 4 + +```text +Manager +→ +Skills +→ +Skill Gap +→ +Affected Employees +``` + +## Scenario 5 + +```text +Manager +→ +Reports +→ +Filter +→ +Export +``` + +## Scenario 6 + +```text +Manager +→ +Approvals +→ +Review +→ +Approve / Reject +``` + +--- + +# Unauthorized Tests + +تست کن Manager نتواند: + +```text +open other team's employee + +read other team's progress + +assign learning to unauthorized user + +access admin settings + +access Builder + +access AI Studio unless explicitly permitted + +export organization-wide data +``` + +--- + +# Final Quality Gate + +Frontend: + +```bash +npm run lint +npm run typecheck +npm test +npm run build +``` + +Backend: + +```bash +php artisan test +``` + +و تست‌های authorization مربوط به Manager را حتماً اجرا کن. + +--- + +# Final Report + +در انتهای Phase 5 گزارش بده. + +## Manager Architecture + +چه چیزهایی تغییر کرده؟ + +## Permissions + +چه محدودیت‌هایی اضافه یا اصلاح شده؟ + +## Dashboard + +چه تغییراتی انجام شده؟ + +## My Team + +چه تغییراتی انجام شده؟ + +## Assignments + +چه تغییراتی انجام شده؟ + +## Notifications + +چه چیزی آماده یا پیاده‌سازی شده؟ + +## Skills + +چه تغییراتی انجام شده؟ + +## Reports + +چه تغییراتی انجام شده؟ + +## Approvals + +چه تغییراتی انجام شده؟ + +## Responsive + +چه تغییراتی انجام شده؟ + +## Security + +چه مواردی بررسی یا اصلاح شده؟ + +--- + +# Test Results + +Backend: + +```text +Tests: +Assertions: +Failures: +``` + +Frontend: + +```text +Test Files: +Tests: +Lint: +Typecheck: +Build: +``` + +--- + +# Remaining Manager Technical Debt + +فقط مشکلات واقعی باقی‌مانده را ثبت کن. + +Severity: + +```text +High + +Medium + +Low +``` + +--- + +# Manager Experience Score + +به موارد زیر از 10 امتیاز بده: + +```text +Navigation + +Dashboard + +Team Management + +Learning Assignment + +Notifications + +Skills + +Reports + +Approvals + +Mobile UX + +Accessibility + +Security + +Performance +``` + +و در نهایت: + +```text +Manager Experience Score: XX/100 +``` + +--- + +# اجرای دقیق فازها + +```text +PHASE 0 +Role & Permission Audit + ↓ +PHASE 1 +Manager Shell & Dashboard + ↓ +PHASE 2 +My Team & Learning Profiles + ↓ +PHASE 3 +Assignments, Deadlines & Notifications + ↓ +PHASE 4 +Skills, Insights, Reports & Approvals + ↓ +PHASE 5 +Responsive, Security & Final QA +``` + +## قانون آخر + +هر Phase را کامل کن. + +Test کن. + +`git diff` را بررسی کن. + +فایل‌های تغییرکرده را گزارش بده. + +Regressionها را رفع کن. + +سپس متوقف شو. + +**بدون دستور صریح من وارد Phase بعد نشو.** + +# SUPPLEMENTARY PROMPT + +## MicroLearning — Learner Android APK + Native Push Notifications + Manager PWA Notifications + +این Prompt مکمل Promptهای قبلی **Learner** و **Manager** است. + +قوانین Safety، Scope Lock، Minimum Necessary Change، Regression Testing و Working Code Is Sacred که در Promptهای اصلی تعریف شده‌اند، همچنان لازم‌الاجرا هستند. + +هیچ بخش سالمی صرفاً برای اجرای این قابلیت‌ها بازنویسی نشود. + +--- + +# PART A — LEARNER PROMPT EXTENSION + +Prompt فعلی Learner دارای Phase 0 تا Phase 5 است. + +دو Phase جدید زیر را بعد از Phase 5 اضافه کن: + +```text +PHASE 6 +Android APK with Capacitor + +PHASE 7 +Native Push Notifications with Firebase Cloud Messaging +``` + +ترتیب نهایی Learner: + +```text +PHASE 0 +Critical Stabilization + ↓ +PHASE 1 +Learning Paths + Resume + ↓ +PHASE 2 +Course Player + Assessments + ↓ +PHASE 3 +Offline + PWA + ↓ +PHASE 4 +Progress + Certificates + Notifications + ↓ +PHASE 5 +APK Readiness + QA + ↓ +PHASE 6 +Android APK with Capacitor + ↓ +PHASE 7 +FCM Native Push Notifications +``` + +--- + +# PHASE 6 — Android Learner APK with Capacitor + +## هدف + +Learner PWA فعلی را بدون ایجاد Frontend جدید به Android Application واقعی تبدیل کن. + +قانون معماری: + +```text +ONE LEARNER SOURCE CODE + +React Learner + │ + ├── Web + ├── PWA + └── Capacitor Android +``` + +به هیچ عنوان یک React App، Flutter App یا Android UI مستقل برای Learner نساز. + +--- + +## 1. Capacitor Integration + +نسخه‌های dependencyهای فعلی را بررسی کن و نسخه سازگار Capacitor را انتخاب کن. + +Dependency upgrade unrelated انجام نده. + +Android Platform را به پروژه اضافه کن. + +ساختار باید به‌صورت استاندارد باشد: + +```text +frontend/ + src/ + dist/ + capacitor.config.* + android/ +``` + +از build output فعلی Vite استفاده کن. + +--- + +## 2. Learner-Only Android Experience + +APK باید تجربه Learner را ارائه کند. + +نباید به‌صورت پیش‌فرض navigation مربوط به: + +```text +Super Admin +Course Designer +Course Builder +AI Studio +Platform Administration +``` + +نمایش دهد. + +اگر همان User چند Role دارد، رفتار را از architecture واقعی Role Switching پروژه استخراج کن. + +امنیت را فقط با مخفی کردن menu پیاده‌سازی نکن. + +Backend authorization همچنان Source of Truth است. + +--- + +## 3. API Configuration + +هیچ URL مانند: + +```text +localhost +127.0.0.1 +``` + +در Production APK hard-code نشود. + +Environmentهای واقعی را پشتیبانی کن: + +```text +Development +Staging +Production +On-Premise +``` + +Configuration باید از روش استاندارد پروژه گرفته شود. + +--- + +## 4. On-Premise Support + +MicroLearning ممکن است روی Server داخلی سازمان نصب شود. + +Android App باید بتواند به Endpoint سازمان متصل شود. + +Architecture را طوری طراحی کن که Server/Base URL قابل configuration امن باشد اگر Business Model پروژه نیاز دارد. + +Validation انجام بده: + +```text +HTTPS preferred +Valid hostname +No malformed URL +Connection test +``` + +در Production اتصال insecure را بدون تصمیم صریح Business/Security مجاز نکن. + +--- + +## 5. Authentication + +Authentication فعلی را برای Capacitor بررسی کن. + +Token/Session نباید در storage ناامن نگهداری شود. + +بررسی کن: + +```text +Login +Logout +Session expiry +Token refresh if applicable +401 handling +Multiple accounts +Organization context +``` + +بعد از Logout داده Authentication و داده خصوصی local پاک یا isolate شود. + +--- + +## 6. Native Back Button + +Android Back Button باید رفتار طبیعی داشته باشد. + +مثلاً: + +```text +Course Player +→ Previous App Screen + +Drawer Open +→ Close Drawer + +Modal Open +→ Close Modal +``` + +نباید با اولین Back کل App بسته شود. + +در Root Screen در صورت لزوم رفتار native مناسب داشته باش. + +--- + +## 7. Deep Linking Foundation + +زیرساخت Deep Link ایجاد کن. + +هدف: + +```text +microlearning://learning/{assignmentId} +``` + +و ترجیحاً: + +```text +microlearning://learning/{assignmentId}/lesson/{lessonId} +``` + +در صورت استفاده از App Links: + +```text +https://learn.example.com/app/... +``` + +نیز architecture آماده باشد. + +Deep Link باید بعد از Authentication به destination صحیح منتقل شود. + +--- + +## 8. Native Safe Areas + +بررسی کن: + +```text +Status Bar +Navigation Bar +Display Cutout +Notch +Gesture Area +Keyboard +``` + +UI نباید زیر عناصر system قرار بگیرد. + +CSS safe-area را برای: + +```text +env(safe-area-inset-top) +env(safe-area-inset-bottom) +``` + +در صورت نیاز صحیح استفاده کن. + +--- + +## 9. Android Keyboard + +روی: + +```text +Login +Notes +Discussion +Assessment +Search +Forms +``` + +بررسی کن Keyboard باعث مخفی شدن input یا CTA نشود. + +--- + +## 10. External Links + +External URLها را audit کن. + +تصمیم واضح داشته باش: + +```text +Internal route +→ App + +External trusted URL +→ System browser +``` + +رفتار ناخواسته WebView ایجاد نکن. + +--- + +## 11. Downloads + +این موارد را بررسی کن: + +```text +Certificates +Documents +Course resources +``` + +اگر download در Web کار می‌کند، رفتار Android را نیز تست کن. + +برای Certificate باید حداقل امکان: + +```text +Open +Download +Share +``` + +در صورت پشتیبانی platform وجود داشته باشد. + +--- + +## 12. Sharing + +برای موارد مناسب abstraction ایجاد کن: + +```text +Certificate +Course link +Achievement +``` + +در Android از Native Share در صورت نیاز استفاده کن و Web fallback حفظ شود. + +--- + +## 13. Network Awareness + +App باید وضعیت شبکه را تشخیص دهد. + +Stateهای حداقل: + +```text +Online +Offline +Reconnecting +``` + +Offline Learning Phase 3 باید همچنان کار کند. + +Capacitor integration نباید Offline/PWA architecture را خراب کند. + +--- + +## 14. Splash Screen + +Splash Screen حرفه‌ای ولی کوتاه باشد. + +از نمایش Splash طولانی و مصنوعی جلوگیری کن. + +Branding موجود پروژه را reuse کن. + +--- + +## 15. App Icon + +Android launcher icon و adaptive icon استاندارد ایجاد کن. + +از asset برند فعلی MicroLearning استفاده کن. + +Icon جدید unrelated طراحی نکن مگر asset مناسب موجود نباشد. + +--- + +## 16. Status Bar + +Status Bar با: + +```text +Light Theme +Dark Theme +``` + +هماهنگ شود. + +--- + +## 17. PWA Must Continue Working + +بعد از اضافه شدن Capacitor این موارد نباید خراب شوند: + +```text +Web +PWA Install +Service Worker +Offline Downloads +Responsive UI +Desktop Learner +``` + +APK نباید باعث fork شدن codebase شود. + +--- + +## 18. Android Build + +Debug APK بساز. + +در صورت آماده بودن signing configuration، release build architecture را نیز آماده کن. + +Secret signing key را commit نکن. + +--- + +## 19. GitHub Actions Readiness + +در صورت وجود GitHub Actions، pipeline جدا برای Android ایجاد کن یا readiness آن را اضافه کن. + +هدف آینده: + +```text +Frontend Test +↓ +Frontend Build +↓ +Capacitor Sync +↓ +Android Build +↓ +APK Artifact +``` + +اما CI موجود را بدون ضرورت بازنویسی نکن. + +--- + +## PHASE 6 ACCEPTANCE CRITERIA + +Phase 6 فقط وقتی Complete است که: + +```text +React Web works +PWA works +Android project builds +APK installs +Login works +Learner Home works +Course Player works +Assessments work +Resume works +Offline works +Certificates work +Android Back works +Dark Mode works +RTL works +LTR works +``` + +و هیچ Frontend دوم ایجاد نشده باشد. + +--- + +# PHASE 7 — Native Push Notifications with FCM + +## هدف + +Learner Android App باید Notification واقعی Android دریافت کند. + +حتی وقتی App در foreground نیست. + +Architecture مقصد: + +```text +MicroLearning Backend + ↓ +Notification Domain Event + ↓ +Notification Service + ↓ +Laravel Queue + ↓ +Firebase Cloud Messaging + ↓ +Android App + ↓ +Native Android Notification +``` + +--- + +# 1. Notification Architecture + +Notification logic را داخل Controllerها پراکنده نکن. + +Architecture ترجیحی: + +```text +Domain Event +↓ +Notification Orchestrator +↓ +Channel +``` + +Channelها: + +```text +InAppChannel +PushChannel +EmailChannel +``` + +Web Push در آینده قابل اضافه شدن باشد. + +--- + +# 2. Device Registration + +Backend باید Deviceهای User را مدیریت کند. + +مدل مناسب طراحی کن. + +مثلاً: + +```text +user_devices +``` + +فیلدهای منطقی: + +```text +id +user_id +organization_id +platform +device_identifier +push_token +enabled +last_seen_at +created_at +updated_at +``` + +نام نهایی را با conventions پروژه هماهنگ کن. + +--- + +# 3. Multiple Devices + +یک User ممکن است: + +```text +Phone +Tablet +Second Phone +``` + +داشته باشد. + +Notification architecture باید Multiple Device را پشتیبانی کند. + +--- + +# 4. Token Registration + +بعد از دریافت FCM Token: + +```text +Android App +↓ +Authenticated API +↓ +Register Device Token +``` + +Backend ownership را verify کند. + +--- + +# 5. Token Refresh + +FCM Token ممکن است تغییر کند. + +Token refresh باید به Backend sync شود. + +Duplicate token ایجاد نکن. + +--- + +# 6. Logout + +هنگام Logout: + +Device registration مربوطه disable یا unregister شود. + +User قبلی نباید Notification User جدید را دریافت کند. + +--- + +# 7. Android 13+ Permission + +برای Androidهایی که Permission لازم دارند: + +```text +POST_NOTIFICATIONS +``` + +را صحیح مدیریت کن. + +Permission را بلافاصله و بدون context درخواست نکن. + +UX پیشنهادی: + +```text +اعلان‌های یادگیری را فعال کنید + +مهلت دوره‌ها، آموزش‌های جدید و +گواهی‌های صادرشده را از دست ندهید. + +[فعال کردن اعلان‌ها] +[بعداً] +``` + +از Dialog/Component استاندارد پروژه استفاده کن. + +--- + +# 8. Notification Channels + +Android Notification Channels ایجاد کن. + +حداقل: + +```text +Learning +Deadlines +Certificates +System +``` + +در صورت نیاز: + +```text +Assignments +Reminders +``` + +اما Channelهای بسیار زیاد نساز. + +--- + +# 9. Learner Notification Types + +حداقل Eventهای زیر را بررسی و در صورت support Backend پیاده‌سازی کن: + +```text +Course Assigned +Learning Path Assigned +Mandatory Learning Assigned + +Deadline Approaching +Due Today +Overdue + +Continue Learning Reminder + +Assessment Available + +Course Completed + +Certificate Issued + +Important Organization Announcement +``` + +--- + +# 10. Notification Preferences + +به Preference واقعی User احترام بگذار. + +مثلاً: + +```text +New assignments +Deadline reminders +Daily reminders +Certificates +Organization announcements +``` + +Notificationهای security/critical system در صورت Business Rule می‌توانند سیاست متفاوت داشته باشند. + +--- + +# 11. Scheduled Reminders + +Reminderها از queue/scheduler ارسال شوند. + +برای مثال: + +```text +3 days before deadline +1 day before deadline +Due today +Overdue +``` + +از Request synchronous برای ارسال bulk notification استفاده نکن. + +--- + +# 12. Duplicate Protection + +یک Notification نباید به دلیل Retry Queue چندبار برای یک Event ارسال شود. + +Idempotency مناسب ایجاد کن. + +مثلاً بر اساس: + +```text +user +event +assignment +notification type +scheduled window +``` + +--- + +# 13. Deep Link on Notification Tap + +هر Push باید در صورت نیاز مقصد داشته باشد. + +مثلاً: + +### Course Assigned + +```text +Notification +↓ +My Learning +↓ +Course +``` + +### Deadline + +```text +Notification +↓ +Assignment +↓ +Resume Lesson +``` + +### Certificate + +```text +Notification +↓ +My Certificates +↓ +Certificate +``` + +--- + +# 14. Foreground Behavior + +اگر App باز است، Notification نباید UX آزاردهنده ایجاد کند. + +در صورت مناسب بودن: + +```text +In-app banner / toast +``` + +و در background: + +```text +Native notification +``` + +--- + +# 15. Notification History + +Push Notification باید در صورت منطقی بودن با Notification Center داخل App هماهنگ باشد. + +یعنی Notification دریافت‌شده فقط ephemeral نباشد. + +User بتواند بعداً در: + +```text +Notifications +``` + +آن را مشاهده کند. + +--- + +# 16. Read / Unread + +Notification Center: + +```text +Unread +Read +Mark as read +Mark all as read +``` + +داشته باشد در صورت support فعلی. + +--- + +# 17. Security + +هر Notification Payload را حداقل‌گرا نگه دار. + +Sensitive data را مستقیماً داخل Push Payload قرار نده. + +مثلاً اطلاعات خصوصی ارزیابی یا اطلاعات حساس Employee ارسال نشود. + +Push فقط context identifier امن ارسال کند و App داده اصلی را از API مجاز دریافت کند. + +--- + +# 18. FCM Credentials + +Firebase credential: + +```text +.env +Secret Manager +Server Configuration +``` + +باشد. + +هیچ: + +```text +Private Key +Server Key +Service Account Secret +``` + +در repository commit نشود. + +--- + +# 19. Failure Handling + +FCM responseها را مدیریت کن. + +برای Tokenهای: + +```text +Invalid +Expired +Unregistered +``` + +Device token را disable/remove کن. + +--- + +# 20. Observability + +حداقل بتوانیم بفهمیم: + +```text +Queued +Sent to FCM +Failed +Invalid token +``` + +ولی Delivery قطعی را اگر FCM چنین اطلاعاتی نداده، جعلی نمایش نده. + +--- + +# 21. Push Tests + +حداقل تست: + +```text +User A notification +→ User A devices only +``` + +```text +User B +→ Must not receive User A notification +``` + +```text +Disabled preference +→ Optional notification not sent +``` + +```text +Invalid token +→ Safely disabled +``` + +```text +Logout +→ Device no longer receives private push +``` + +```text +Notification tap +→ Correct deep link +``` + +--- + +# PHASE 7 ACCEPTANCE CRITERIA + +Phase 7 زمانی Complete است که: + +```text +FCM connected +Device registration works +Token refresh works +Logout cleanup works +Android permission works +Native notification works +Background notification works +Notification channels work +Preferences work +Deep linking works +Duplicate push protection works +Notification center stays consistent +Security tests pass +``` + +--- + +# LEARNER FINAL QUALITY GATE — UPDATED + +پس از Phase 7 حتماً این Flow را تست کن: + +```text +Learner Login +↓ +Device Registered +↓ +Course Assigned +↓ +App Closed +↓ +Native Notification Received +↓ +Tap Notification +↓ +App Opens +↓ +Correct Course +↓ +Correct Resume Location +``` + +و: + +```text +Deadline Reminder +↓ +Native Notification +↓ +Tap +↓ +Assignment +↓ +Continue Learning +``` + +و: + +```text +Course Complete +↓ +Certificate Issued +↓ +Native Notification +↓ +My Certificates +``` + +--- + +# UPDATED LEARNER RELEASE STATUS + +در گزارش نهایی یکی از این وضعیت‌ها را بده: + +```text +NOT READY + +PWA PRODUCTION READY + +ANDROID DEBUG READY + +ANDROID RELEASE READY WITH CONDITIONS + +ANDROID PRODUCTION READY +``` + +--- + +# PART B — MANAGER PROMPT EXTENSION + +Prompt Manager فعلی Phase 0 تا Phase 5 دارد. + +یک Phase جدید اضافه کن: + +```text +PHASE 6 +Manager PWA & Push/Web Notification Experience +``` + +ترتیب نهایی: + +```text +PHASE 0 +Role & Permission Audit + ↓ +PHASE 1 +Manager Shell & Dashboard + ↓ +PHASE 2 +My Team & Learning Profiles + ↓ +PHASE 3 +Assignments, Deadlines & Notifications + ↓ +PHASE 4 +Skills, Insights, Reports & Approvals + ↓ +PHASE 5 +Responsive, Security & Final QA + ↓ +PHASE 6 +Manager PWA & Notifications +``` + +--- + +# PHASE 6 — Manager PWA & Notification Experience + +## هدف + +Manager Panel علاوه بر Desktop/Web، روی موبایل نیز به‌صورت PWA قابل استفاده باشد. + +برای Manager فعلاً Android APK مستقل نساز. + +Architecture: + +```text +Manager React Experience + ↓ +Web ++ +Installable PWA +``` + +--- + +# 1. PWA Availability + +Manager بتواند در browserهای پشتیبانی‌شده PWA را Install کند. + +PWA Shell باید Role-aware باشد. + +وقتی Manager وارد می‌شود: + +```text +Manager Dashboard +``` + +نمایش داده شود، نه Learner Home. + +--- + +# 2. Role Combination + +اگر Manager خودش Learner هم هست، Role Switching یا Experience Switching فعلی پروژه را بررسی کن. + +راه‌حل جدید duplicate نساز. + +در صورت وجود switching استاندارد: + +```text +Manager Mode +Learner Mode +``` + +را حفظ کن. + +--- + +# 3. Manager Mobile Priorities + +PWA Manager روی موبایل باید حداقل این Actions را عالی پشتیبانی کند: + +```text +View Needs Attention +View My Team +View Employee Learning +Send Reminder +Assign Learning +Review Deadline +Approve Request +View Team Progress +``` + +Course Builder و Admin Toolهای unrelated را وارد PWA Manager نکن. + +--- + +# 4. Manager In-App Notifications + +Notification Center برای Manager category-aware باشد. + +نمونه: + +```text +Team Learning +Deadlines +Approvals +Skill Alerts +Reports +System +``` + +--- + +# 5. Manager Notification Events + +در صورت وجود داده واقعی، Manager بتواند Notification دریافت کند برای: + +```text +Team member overdue + +Mandatory course not started + +Team deadline approaching + +Approval requested + +Learning request received + +Assignment completed + +Critical skill gap alert + +Weekly learning summary ready +``` + +Insight ساده را بی‌دلیل Push نکن. + +Notification fatigue ایجاد نکن. + +--- + +# 6. Immediate vs Digest + +همه Eventها Push فوری نباشند. + +تقسیم‌بندی منطقی: + +```text +Immediate: +Critical overdue +Approval needing action +Important assignment issue +``` + +```text +Digest: +Team learning summary +Weekly progress +General insights +``` + +--- + +# 7. Manager Notification Preferences + +Manager بتواند تنظیم کند: + +```text +Team deadline alerts +Overdue alerts +Approval requests +Learning activity +Weekly summary +``` + +--- + +# 8. Web Push + +اگر infrastructure Web Push پروژه آماده و قابل اتکا است، Manager PWA بتواند Web Push واقعی دریافت کند. + +اگر هنوز زیرساخت Web Push وجود ندارد: + +Fake implementation ایجاد نکن. + +Architecture Notification Channel را طوری نگه دار که Web Push بعداً اضافه شود. + +--- + +# 9. Shared Notification Backend + +Notification Business Rules را بین: + +```text +Learner APK +Manager PWA +In-App Notification Center +``` + +duplicate نکن. + +Architecture: + +```text +Domain Event + ↓ +Notification Service + ↓ +Recipient Resolution + ↓ +Preferences + ↓ +Channels +``` + +Channels: + +```text +In-App +FCM +Web Push +Email +``` + +باید قابل توسعه باشند. + +--- + +# 10. Manager Assignment Notification Integration + +وقتی Manager در Phase 3 دوره‌ای تخصیص می‌دهد: + +```text +Manager +↓ +Assign Course +↓ +Learners +↓ +Notification Event +↓ +In-App + FCM according to preference +``` + +در صورت APK Learner. + +Manager نباید خودش مستقیم FCM API را فراخوانی کند. + +--- + +# 11. Manager Reminder Action + +Action: + +```text +Send Reminder +``` + +باید از همان Notification Service استفاده کند. + +نه implementation جداگانه. + +قبل از ارسال bulk reminder خلاصه نشان بده: + +```text +12 learners +Course: Workplace Safety +Channel: +In-App + Push + +[Send Reminder] +``` + +--- + +# 12. Rate / Spam Protection + +Manager نباید بتواند ناخواسته پشت سر هم Pushهای مشابه برای یک Team ارسال کند. + +برای Reminder: + +```text +cooldown +duplicate detection +authorization +``` + +در نظر بگیر. + +--- + +# 13. Team Scope + +Notification recipientها حتماً Backend-scoped باشند. + +Manager با دستکاری Request نباید بتواند برای افراد خارج از Team مجاز خودش Push ارسال کند. + +--- + +# 14. Notification Audit + +عملیات مهم Manager مثل: + +```text +Bulk reminder sent +Assignment notification triggered +Deadline changed +``` + +در صورت وجود Audit architecture ثبت شود. + +--- + +# 15. PWA Responsive QA + +بررسی: + +```text +360 +375 +390 +430 +768 +``` + +Manager PWA روی mobile نباید desktop table shrink شده باشد. + +--- + +# MANAGER PHASE 6 ACCEPTANCE CRITERIA + +```text +Manager PWA installable +Manager route correct +Mobile dashboard works +Team actions work +Notifications permission-aware +Reminder action uses shared service +Team scope enforced +Duplicate reminder protection works +Learner FCM integration compatible +No separate Manager APK created +``` + +--- + +# PART C — SHARED NOTIFICATION ARCHITECTURE + +این بخش برای هر دو Prompt الزامی است. + +نباید برای Manager و Learner دو سیستم Notification جدا ساخته شود. + +Architecture مقصد: + +```text + MicroLearning + │ + Domain/Application Events + │ + Notification Service + │ + ┌───────────┼───────────┐ + │ │ │ + In-App Push Email + │ + ┌────────┴────────┐ + │ │ + FCM Web Push + │ │ + Learner APK Manager PWA +``` + +--- + +# Recipient Resolution + +Notification Service باید خودش مشخص کند Recipient چه کسی است. + +مثلاً: + +```text +Course Assigned +→ Learner +``` + +```text +Deadline approaching +→ Learner +``` + +```text +Employee overdue +→ Authorized Manager +``` + +```text +Approval requested +→ Manager +``` + +--- + +# Preferences + +Flow: + +```text +Event +↓ +Recipient +↓ +Permission / Scope +↓ +Notification Preference +↓ +Channel +↓ +Queue +↓ +Delivery +``` + +--- + +# Queue First + +ارسال Notificationهای خارجی را synchronous داخل user request انجام نده. + +از Queue استفاده کن. + +Failure FCM/Web Push نباید عملیات اصلی مثل: + +```text +Assign Course +Complete Course +Issue Certificate +``` + +را rollback کند مگر business rule صریحی وجود داشته باشد. + +--- + +# Notification Taxonomy + +یک taxonomy استاندارد داشته باش. + +مثلاً: + +```text +learning.assigned +learning.reminder +learning.deadline_soon +learning.overdue +learning.completed + +assessment.available + +certificate.issued + +manager.approval_requested +manager.team_overdue +manager.weekly_summary + +system.announcement +``` + +Naming را با conventions فعلی پروژه تطبیق بده. + +--- + +# Notification Payload + +Payload استاندارد داشته باش: + +```text +type +title +body +recipient +entityType +entityId +deepLink +createdAt +``` + +Sensitive information اضافه نکن. + +--- + +# Shared Deep Links + +Learner: + +```text +/course +/lesson +/certificate +/notification +``` + +Manager: + +```text +/team/member +/assignment +/approval +/report +``` + +هم Web و هم Native باید از destination منطقی مشترک استفاده کنند. + +--- + +# FINAL SAFETY RULE + +برای اضافه کردن Capacitor، FCM یا PWA Notification: + +**هیچ‌یک از فازهای قبلی را دوباره Refactor نکن مگر integration واقعاً به تغییر آن نیاز داشته باشد.** + +اگر integration نیازمند تغییر گسترده شد: + +```text +STOP +``` + +و قبل از ادامه گزارش بده: + +```text +Integration blocker: +Why: +Affected modules: +Risk: +Minimum viable fix: +Alternative: +``` + +--- + +# UPDATED EXECUTION ORDER + +ترتیب پیشنهادی نهایی برای این دو بخش: + +```text +LEARNER PHASE 0–5 + ↓ +LEARNER PHASE 6 +Android APK + ↓ +LEARNER PHASE 7 +FCM Push + ↓ +MANAGER PHASE 0–5 + ↓ +MANAGER PHASE 6 +PWA + Manager Notifications +``` + +اگر Manager Phaseهای 0–5 قبلاً انجام شده‌اند، مستقیماً Phase 6 را اجرا کن. + +**هیچ Phase را بدون دستور صریح من شروع نکن. پس از هر Phase تست کن، گزارش بده و متوقف شو.** diff --git a/Prompt/Prompt.md b/Prompt/Prompt.md new file mode 100644 index 0000000..42472e9 --- /dev/null +++ b/Prompt/Prompt.md @@ -0,0 +1,2106 @@ +# CHANGE SAFETY PROTOCOL — MANDATORY + +این پروژه در حال حاضر دارای قابلیت‌های فعال و کد عملیاتی است. + +هدف این برنامه **Stabilization و Controlled Improvement** است، نه بازنویسی پروژه. + +## اصل اول — Working Code Is Sacred + +اگر یک بخش: + +* درست کار می‌کند، +* Test آن سبز است، +* Security issue ندارد، +* مانع مستقیم Phase فعلی نیست، + +فقط برای Clean Code، زیبایی Architecture یا Preference شخصی آن را تغییر نده. + +--- + +## Minimum Necessary Change + +برای حل هر Issue کمترین تغییر ممکن را انجام بده. + +مثلاً اگر یک Bug با تغییر: + +* یک Route، +* یک Service، +* یا یک Component + +قابل حل است، از Refactor گسترده بخش‌های دیگر خودداری کن. + +--- + +## Scope Lock + +در ابتدای هر Phase دقیقاً مشخص کن: + +```text +Files expected to change +Modules affected +Behavior expected to change +Behavior that must remain unchanged +``` + +در طول Phase از این Scope خارج نشو مگر اینکه یک Blocker واقعی پیدا شود. + +اگر مشکل دیگری پیدا کردی که برای Phase جاری ضروری نیست، آن را در: + +```text +Deferred Technical Debt +``` + +ثبت کن و تغییرش نده. + +--- + +## No Opportunistic Refactoring + +این موارد ممنوع هستند مگر اینکه مستقیماً برای حل مشکل Phase ضروری باشند: + +* Renameهای گسترده +* تغییر Folder Structure +* تغییر Architecture unrelated +* تغییر API Contract unrelated +* تغییر State Management unrelated +* تغییر Database Schema unrelated +* Replace کردن Libraryها +* Rewrite کردن Componentهای سالم +* تغییر Design System در Phase فنی +* Dependency Upgrade گسترده + +--- + +## Preserve Existing Behavior + +قبل از تغییر مشخص کن رفتار فعلی چیست. + +پس از تغییر همان رفتارهای سالم باید حفظ شده باشند. + +Feature موجود نباید به‌صورت تصادفی: + +* حذف شود، +* غیرفعال شود، +* تغییر UX پیدا کند، +* یا رفتار متفاوت پیدا کند. + +--- + +## Regression Test Rule + +برای هر Bug واقعی ترجیحاً: + +```text +Reproduce +↓ +Write regression test +↓ +Confirm test fails +↓ +Implement minimum fix +↓ +Confirm test passes +``` + +اگر نوشتن Test ممکن نبود، دلیل آن را گزارش کن. + +--- + +## Never Fix Tests by Weakening Them + +برای سبز کردن Pipeline حق نداری: + +* Test را حذف کنی. +* Test را skip کنی. +* Assertion را حذف کنی. +* شرط Test را ضعیف کنی. +* Error را suppress کنی. + +مگر اینکه Test واقعاً اشتباه باشد و دلیل فنی آن را مستند کنی. + +--- + +## Checkpoint Rule + +قبل از هر Phase: + +```bash +git status +``` + +را بررسی کن. + +Phase باید روی وضعیت مشخص شروع شود. + +بعد از Phase: + +```bash +git diff +git status +``` + +را بررسی کن. + +هر Phase باید یک تغییر مستقل و قابل rollback باشد. + +--- + +## One Phase = One Stable Checkpoint + +تا زمانی که موارد زیر سبز نشده‌اند Phase را Completed اعلام نکن: + +```text +Relevant Backend Tests +Relevant Frontend Tests +Lint +Typecheck +Build +Smoke Tests +``` + +در صورت Failure ابتدا همان Phase را Stabilize کن. + +به Phase بعد نرو. + +--- + +## Stop-Loss Rule + +اگر Fix یک Issue باعث شد: + +* بیش از حد انتظار فایل تغییر کند، +* Architecture جدید نیاز شود، +* APIهای زیادی تحت تأثیر قرار گیرند، +* migration گسترده لازم شود، +* Regression متعدد ایجاد شود، + +کار را متوقف کن. + +به‌جای ادامه دادن، گزارش بده: + +```text +Issue: +Why the fix became high-risk: +Files/modules affected: +Safer alternatives: +Recommended next step: +``` + +بدون اجازه User تغییر پرریسک را ادامه نده. + +--- + +## Refactor Budget + +برای Phaseهای Bug Fix: + +حداکثر Refactor باید فقط به اندازه‌ای باشد که Bug امن و قابل نگهداری حل شود. + +از Refactor پروژه‌محور خودداری کن. + +--- + +## Dependency Freeze + +در Phaseهای Stabilization هیچ Dependency عمده‌ای را Upgrade یا Replace نکن مگر اینکه: + +* vulnerability جدی وجود داشته باشد، +* dependency خراب باشد، +* یا Issue بدون آن قابل حل نباشد. + +هر Dependency Change را جداگانه گزارش کن. + +--- + +## Database Safety + +Migrationهای موجود را rewrite نکن. + +برای schema change از Migration جدید استفاده کن. + +عملیات destructive روی داده موجود ممنوع است مگر با دستور صریح User. + +--- + +## API Safety + +API Contractهای فعال را بدون ضرورت تغییر نده. + +اگر تغییر API ضروری است: + +1. مصرف‌کنندگان آن را پیدا کن. +2. backward compatibility را بررسی کن. +3. frontend و tests را همزمان اصلاح کن. +4. تغییر Contract را صریح گزارش کن. + +--- + +## UI Safety During Technical Phases + +در Phaseهای Technical: + +* layout را redesign نکن. +* spacing را redesign نکن. +* navigation را redesign نکن. +* typography را تغییر نده. +* visual styling unrelated را تغییر نده. + +فقط UI لازم برای رفع Bug مجاز است. + +--- + +## Technical Safety During UI Phases + +در Phaseهای UI: + +Business Logic، Database و Backend Architecture را فقط زمانی تغییر بده که UI بدون آن قابل اجرا نباشد. + +در غیر این صورت Technical Debt ثبت کن. + +--- + +## Final Principle + +هدف هر Phase: + +```text +Project after Phase += +Project before Phase ++ +specific improvement +- +specific bug +``` + +نه: + +```text +Project after Phase += +partially rewritten project ++ +new unknown regressions +``` + +# Master Prompt — MicroLearning Debug, Refactor & UI/UX Enhancement + +می‌خواهم پروژه **MicroLearning** را به‌صورت مرحله‌ای، کنترل‌شده و Production-Ready بررسی، Debug، Refactor و از نظر UI/UX ارتقا دهی. + +## قانون اصلی اجرای پروژه + +این کار باید دقیقاً در **۹ فاز از Phase 0 تا Phase 8** انجام شود. + +**هرگز چند فاز را همزمان انجام نده.** + +پس از پایان هر فاز: + +1. تمام تغییرات همان فاز را کامل کن. +2. تست‌های مرتبط را اجرا کن. +3. Regression احتمالی را بررسی کن. +4. فایل‌های تغییرکرده را اعلام کن. +5. باگ‌های پیدا شده را اعلام کن. +6. باگ‌های رفع‌شده را اعلام کن. +7. نتیجه Test / Lint / Build را اعلام کن. +8. وضعیت Git را بررسی کن. +9. یک گزارش کوتاه ارائه کن. +10. متوقف شو. + +تا زمانی که من صراحتاً نگفته‌ام: + +> برو فاز بعد + +نباید Phase بعدی را شروع کنی. + +--- + +# اصول غیرقابل مذاکره + +در تمام فازها این قوانین رعایت شوند. + +* Feature موجود بدون دلیل حذف نشود. +* رفتار فعلی سیستم بدون ضرورت تغییر نکند. +* داده کاربران یا migrationهای موجود خراب نشوند. +* API Contract بدون بررسی frontend تغییر نکند. +* هیچ Mock دائمی وارد production code نشود. +* هیچ hard-coded URL جدید ایجاد نشود. +* هیچ API Key یا Secret داخل source code قرار نگیرد. +* `.env` commit نشود. +* Architecture جدید باید component-based و maintainable باشد. +* TypeScript تا جای ممکن strict باقی بماند. +* `any` فقط با دلیل موجه استفاده شود. +* Errorها silent swallow نشوند. +* همه Async Operationها loading/error state داشته باشند. +* RTL و LTR هر دو حفظ شوند. +* Dark/Light Mode خراب نشود. +* Responsive Design حفظ شود. +* Accessibility در componentهای جدید رعایت شود. +* از ایجاد فایل‌های بسیار بزرگ جلوگیری شود. +* قبل از ساخت component جدید بررسی کن component مشابه وجود دارد یا خیر. +* Design Tokenهای فعلی را تا حد ممکن reuse کن. +* duplicate logic ایجاد نکن. +* Business Logic را از UI جدا نگه دار. + +اگر برای رفع یک مشکل مجبور به تغییر Architecture شدی، ابتدا impact آن را بررسی کن. + +--- + +# تکنولوژی پروژه + +Frontend: + +* React +* TypeScript +* Vite +* React Query +* CSS / Design System موجود + +Backend: + +* Laravel +* PHP +* REST API +* Laravel Sanctum +* Queue / Jobs +* Database-backed configuration + +AI Providers شامل مواردی مانند: + +* Ollama +* OpenAI-compatible providers +* LM Studio +* vLLM +* Custom providers + +--- + +# PHASE 0 — Full Baseline & Project Health Audit + +## هدف + +قبل از تغییر سورس باید وضعیت واقعی فعلی پروژه ثبت شود. + +هیچ Refactor یا Feature Development در این فاز انجام نده. + +## کارها + +ابتدا ساختار پروژه را بررسی کن. + +موارد زیر را شناسایی کن: + +* Frontend entry points +* Backend entry points +* Routes +* Middleware +* Authentication flow +* Permissions +* API Client +* React Query configuration +* Builder architecture +* AI architecture +* Queue architecture +* Database migrations +* Tests +* GitHub Actions +* Environment/config files + +--- + +## Backend baseline + +اجرا کن: + +```bash +php artisan about +php artisan route:list +php artisan route:list --path=ai +php artisan test +``` + +در صورت وجود ابزارها: + +```bash +./vendor/bin/pint --test +``` + +و هر static analysis موجود در پروژه. + +ثبت کن: + +* تعداد Tests +* تعداد Assertions +* Failed Tests +* Skipped Tests +* Warnings + +--- + +## Frontend baseline + +اجرا کن: + +```bash +npm ci +npm run lint +npm run typecheck +npm test +npm run build +``` + +اگر script متفاوت است ابتدا `package.json` را بررسی کن. + +ثبت کن: + +* تعداد Test files +* تعداد Tests +* TypeScript errors +* ESLint errors +* Build errors +* Build warnings + +--- + +## GitHub Actions + +Workflowهای موجود را بررسی کن. + +Quality Gate مطلوب: + +```text +install +↓ +lint +↓ +typecheck +↓ +test +↓ +build +``` + +Backend نیز باید test شود. + +--- + +## خروجی Phase 0 + +جدولی بساز: + +| Area | Status | Problem | Severity | +| ---- | ------ | ------- | -------- | + +Severity: + +```text +P0 Critical +P1 High +P2 Medium +P3 Low +``` + +همچنین Baseline رسمی پروژه را ثبت کن. + +در این فاز هیچ تغییر معماری انجام نده. + +پس از گزارش متوقف شو. + +--- + +# PHASE 1 — Critical Bugs & Security Stabilization + +این فاز فقط مربوط به مشکلات **P0 / Security / Critical API bugs** است. + +--- + +## 1. Fix duplicated API prefix + +فایل زیر را بررسی کن: + +```text +backend/routes/api.php +``` + +مشکل شناخته‌شده: + +Route group دوباره `v1` prefix گرفته و مسیرهایی مانند این ساخته شده‌اند: + +```text +/api/v1/v1/ai/chat +/api/v1/v1/health +/api/v1/v1/certificates/verify/{code} +``` + +معماری route را اصلاح کن. + +مسیر نهایی AI باید منطقی و یکتا باشد، مثلاً: + +```text +/api/v1/ai/chat +``` + +بعد از اصلاح: + +```bash +php artisan route:list +``` + +را اجرا و routeهای نهایی را بررسی کن. + +--- + +## 2. Secure AI endpoints + +تمام endpointهای AI را شناسایی کن. + +بررسی کن: + +* Authentication +* Authorization +* Throttling +* Organization scope +* Permission +* Input validation + +AI endpoint نباید برای anonymous user قابل استفاده باشد مگر endpoint مشخصاً public طراحی شده باشد. + +AI generation باید حداقل تحت: + +```text +auth:sanctum +``` + +و permission مناسب باشد. + +برای عملیات expensive rate limit تعریف کن. + +--- + +## 3. SSRF Protection + +تمام AI Connection URLها را بررسی کن. + +سیستم نباید اجازه دهد user به هر URL دلخواه backend request ارسال کند. + +Policy مناسب طراحی کن. + +برای providerهای شناخته‌شده: + +```text +Ollama +LM Studio +vLLM +OpenAI +``` + +قوانین مشخص داشته باش. + +Local providerها باید بتوانند localhost را استفاده کنند. + +Custom Providerها باید تحت policy / allowlist باشند. + +بررسی کن: + +* hostname +* protocol +* resolved IP +* redirects +* ports +* localhost rules +* private network rules + +DNS rebinding و redirect bypass را نیز در نظر بگیر. + +--- + +## 4. Secrets + +بررسی کن: + +```text +.env +API Keys +tokens +credentials +``` + +هیچ secret داخل repository قرار نگیرد. + +`.gitignore` را بررسی کن. + +--- + +## Acceptance Criteria — Phase 1 + +* duplicated `v1` وجود ندارد. +* anonymous AI generation امکان‌پذیر نیست. +* AI endpoint rate limited است. +* organization isolation رعایت شده. +* SSRF mitigation وجود دارد. +* تست security route نوشته شده. +* تست authentication نوشته شده. +* تست authorization نوشته شده. +* backend tests سبز هستند. +* frontend regression ایجاد نشده. + +پس از پایان Phase 1 متوقف شو. + +--- + +# PHASE 2 — AI Architecture Consolidation + +## هدف + +در حال حاضر AI نباید چند معماری موازی و ناسازگار داشته باشد. + +تمام مسیرهای AI را پیدا کن. + +به‌طور خاص بررسی کن: + +```text +AIController +App\Services\AI\OllamaService +AiProviderResolver +AiProviderConnection +OpenAiCompatibleProvider +``` + +و فایل‌های مشابه. + +--- + +## معماری مقصد + +تمام درخواست‌های AI باید تا جای ممکن از یک abstraction واحد عبور کنند: + +```text +Frontend +↓ +AI API +↓ +AI Application Service +↓ +AiProviderResolver +↓ +Provider Adapter +↓ +Provider +``` + +Providerها: + +```text +OllamaProvider +OpenAIProvider +OpenAICompatibleProvider +LMStudioProvider +VllmProvider +``` + +در صورت امکان adapterها را reuse کن. + +--- + +## Legacy Architecture + +بررسی کن آیا: + +```text +AIController +App\Services\AI\OllamaService +``` + +هنوز نیاز هستند یا خیر. + +اگر functionality آنها توسط معماری جدید پوشش داده شده: + +* dependencyها را migrate کن. +* tests را migrate کن. +* routeها را migrate کن. +* سپس legacy code را حذف کن. + +هیچ dead code باقی نگذار. + +--- + +## AI Config + +این فایل‌ها را بررسی کن: + +```text +.env.example +config/ai.php +``` + +هر config که application استفاده می‌کند باید واقعاً در config تعریف شده باشد. + +به‌خصوص: + +```text +AI_OPENAI_BASE_URL +AI_OPENAI_API_KEY +AI_OPENAI_MODEL_FAST +AI_OPENAI_MODEL_BALANCED +AI_OPENAI_MODEL_ADVANCED +AI_OPENAI_TIMEOUT +``` + +اگر دیگر لازم نیستند حذف شوند. + +اگر لازم هستند config صحیح ایجاد شود. + +--- + +# AI Connection Bug Fixes + +## enabled bug + +هنگام Create Connection بررسی کن که مقدار user: + +```text +enabled=false +``` + +با hard-coded: + +```text +enabled=true +``` + +overwrite نشود. + +--- + +## Default Connection invariant + +Backend باید invariant مشخص داشته باشد: + +```text +Per Organization: +Maximum one default enabled AI connection +``` + +Create / Update / Delete / Make Default باید transaction-safe باشند. + +حالت‌های زیر را تست کن: + +### Scenario A + +Default connection حذف می‌شود. + +Expected: + +یک **enabled** connection دیگر default شود. + +### Scenario B + +Default connection disable می‌شود. + +Expected: + +default به enabled connection دیگری منتقل شود یا default خالی شود. + +### Scenario C + +connection جدید default می‌شود. + +Expected: + +default قبلی atomically برداشته شود. + +### Scenario D + +همه connectionها disabled هستند. + +Expected: + +هیچ default active connection وجود نداشته باشد. + +--- + +# AI Provider Tests + +برای موارد زیر Test بنویس: + +* Ollama reachable +* Ollama unavailable +* invalid model +* invalid URL +* timeout +* OpenAI compatible provider +* disabled provider +* default provider +* fallback provider +* invalid credential +* provider deletion + +--- + +## Acceptance Criteria — Phase 2 + +* یک architecture اصلی AI وجود دارد. +* duplicate AI logic حذف شده. +* dead service حذف شده. +* configها consistent هستند. +* AI connections درست کار می‌کنند. +* enabled/default logic تست دارد. +* provider resolver deterministic است. +* تمام tests سبز هستند. + +سپس متوقف شو. + +--- + +# PHASE 3 — Frontend Reliability & Application State + +## هدف + +رفع باگ‌های state، routing و API handling. + +--- + +# 1. Authentication State + +فایل‌هایی مثل: + +```text +shared/api/client.ts +AuthProvider +AuthContext +``` + +را بررسی کن. + +مشکل: + +روی HTTP 401 فقط token پاک نشود. + +Authentication state نیز باید synchronize شود. + +Architecture مناسب طراحی کن. + +مثلاً: + +```text +API receives 401 +↓ +central unauthorized handler +↓ +clear token +↓ +clear user +↓ +clear protected cache +↓ +set guest state +↓ +navigate to login +``` + +از circular dependency جلوگیری کن. + +--- + +# 2. React Query Cache + +بعد از Logout یا Organization change: + +cache حساس باید invalidate یا clear شود. + +مطمئن شو اطلاعات organization قبلی برای organization جدید نمایش داده نمی‌شود. + +--- + +# 3. Routing + +AppRouter را بررسی و در صورت نیاز nested route architecture ایجاد کن. + +مقصد: + +```text +/app + /dashboard + /courses + /courses/:id + /library + /monitoring/* + /ai-studio + /settings + /* +``` + +صفحات مستقل داشته باش: + +```text +404 Not Found +403 Permission Denied +Session Expired +``` + +Unknown route نباید صفحه unrelated نشان دهد. + +--- + +# 4. Error Handling + +برای API stateها componentهای استاندارد تعریف کن: + +```text +Loading +Empty +Error +Permission denied +Offline +Retry +``` + +هر صفحه نباید implementation متفاوت داشته باشد. + +--- + +# 5. Network Resilience + +بررسی کن: + +* timeout +* cancellation +* retry +* duplicated request +* stale data +* race condition + +روی mutationهای حساس retry کورکورانه انجام نشود. + +--- + +## Acceptance Criteria Phase 3 + +* Session expiry صحیح است. +* logout کامل است. +* cache leakage وجود ندارد. +* 404 صحیح است. +* 403 صحیح است. +* routes deterministic هستند. +* error handling استاندارد شده. +* lint/typecheck/test/build موفق‌اند. + +سپس متوقف شو. + +--- + +# PHASE 4 — Builder Architecture & Performance Refactor + +## هدف + +Builder بدون تغییر UX فعلی از نظر architecture پایدار شود. + +--- + +# CourseBuilderPage + +فایل بزرگ: + +```text +CourseBuilderPage.tsx +``` + +را بررسی کن. + +اگر همچنان بسیار بزرگ است آن را component-based کن. + +Architecture پیشنهادی: + +```text +builder/ + components/ + shell/ + canvas/ + toolbar/ + block-library/ + inspector/ + structure/ + review/ + collaboration/ + + hooks/ + state/ + commands/ + services/ + utils/ + types/ +``` + +--- + +# State Separation + +Business logic از JSX جدا شود. + +مواردی مانند: + +```text +selection +drag/drop +block mutations +autosave +publish +collaboration +keyboard shortcuts +``` + +نباید همگی داخل یک page component باشند. + +--- + +# Rich Text + +Text block فعلی را بررسی کن. + +اگر HTML ذخیره می‌شود ولی render آن با regex strip می‌شود، این behavior را اصلاح کن. + +Rich Text واقعی باید حداقل پشتیبانی کند: + +* Paragraph +* Heading +* Bold +* Italic +* Lists +* Link + +از ذخیره unsafe HTML جلوگیری کن. + +Sanitization مناسب داشته باش. + +در صورت نیاز از editor استاندارد مانند TipTap/ProseMirror استفاده کن، ولی dependency جدید فقط در صورت توجیه اضافه شود. + +--- + +# Asset Resolution + +بررسی کن برای دریافت یک Asset کل library دریافت نشود. + +API مناسب: + +```text +GET /assets/:id +``` + +یا hydration مناسب. + +React Query cache keyهای asset را اصلاح کن. + +--- + +# Collaboration Polling + +این موارد را بررسی کن: + +```text +Presence +Collaboration +Notifications +Locks +AI Jobs +``` + +Polling frequency را audit کن. + +در صورت مناسب بودن: + +* Page Visibility API +* adaptive polling +* pause in background +* exponential backoff + +استفاده کن. + +برای realtime architecture آینده abstraction ایجاد کن تا بعداً WebSocket/SSE قابل اضافه شدن باشد. + +اما در این فاز بدون ضرورت infrastructure بزرگ جدید اضافه نکن. + +--- + +# Memory & Rendering + +بررسی کن: + +* unnecessary renders +* unstable callbacks +* large context providers +* large list rendering +* expensive calculations + +برای listهای بزرگ virtualization را فقط در صورت نیاز واقعی اضافه کن. + +--- + +## Acceptance Criteria Phase 4 + +* Builder functionality تغییر نکرده. +* فایل‌های monolithic شکسته شده‌اند. +* business logic جدا شده. +* duplicate logic کاهش یافته. +* rich text صحیح است. +* asset loading بهینه شده. +* polling کنترل شده. +* tests برای core builder behavior وجود دارد. +* lint/typecheck/test/build سبزند. + +سپس متوقف شو. + +--- + +# PHASE 5 — Navigation, Dashboard & Information Architecture UX + +این اولین فاز major UI/UX enhancement است. + +در این مرحله functionality جدید سنگین اضافه نکن. + +تمرکز روی usability باشد. + +--- + +# Sidebar + +Navigation فعلی را audit کن. + +پیشنهاد grouping: + +## Create + +* Dashboard +* Courses +* Learning Paths +* Library +* Templates +* Question Bank +* AI Studio + +## People + +* Users +* Teams +* Assignments + +## Insights + +* Monitoring +* Skills +* Reports + +## Manage + +* Reviews +* Certificates +* Subscription +* Settings + +اما grouping نهایی را بر اساس نقش‌ها و featureهای واقعی پروژه تعیین کن. + +--- + +# Role-aware Navigation + +Navigation باید بر اساس Role / Permission نمایش داده شود. + +مثلاً Learner نباید ابزار Course Designer را ببیند. + +--- + +# Dashboard Redesign + +Dashboard باید action-oriented باشد. + +بخش‌های پیشنهادی: + +```text +Needs Attention +Continue Working +Recent Courses +Pending Reviews +Publishing Issues +Learner Risk +AI Jobs +Recent Activity +Quick Actions +``` + +KPIها را compactتر کن. + +از empty whitespace غیرضروری جلوگیری کن. + +--- + +# Global Header + +Header باید شامل موارد مورد نیاز باشد: + +* context +* search +* notifications +* user menu + +اما clutter ایجاد نکند. + +--- + +# Empty States + +برای صفحات بدون داده Empty State حرفه‌ای ایجاد کن. + +مثلاً Courses: + +```text +هنوز دوره‌ای ایجاد نشده +اولین دوره خود را بسازید. + +[ایجاد دوره] +``` + +--- + +# UX Consistency + +بررسی کن: + +* button hierarchy +* destructive actions +* primary CTA +* page titles +* breadcrumbs +* tables +* filters +* dialogs +* drawers +* toasts + +Componentهای مشترک باید visual behavior یکسان داشته باشند. + +--- + +## Acceptance Criteria Phase 5 + +* Navigation ساده‌تر شده. +* Sidebar role-aware است. +* Dashboard action-oriented است. +* visual hierarchy بهبود یافته. +* UX consistency افزایش یافته. +* Feature حذف نشده. +* responsive regression وجود ندارد. + +سپس متوقف شو. + +--- + +# PHASE 6 — Builder & AI Studio UX Enhancement + +این مهم‌ترین UX Phase پروژه است. + +--- + +# Builder Layout + +Layout چهارپنله فعلی را audit کن. + +هدف این است که Canvas فضای اصلی باشد. + +پیشنهاد: + +```text +Block Library ++ +Canvas ++ +Right Panel +``` + +Right Panel می‌تواند tab داشته باشد: + +```text +Properties +Structure +Review +``` + +Block Library قابل collapse باشد. + +--- + +# Slash Command + +در Canvas امکان: + +```text +/ +``` + +برای افزودن سریع block بررسی و در صورت مناسب بودن اضافه شود. + +مثلاً: + +```text +/text +/heading +/image +/video +/quiz +/flashcard +/divider +/ai +``` + +Keyboard-first UX ایجاد کن. + +--- + +# Autosave Feedback + +کاربر همیشه باید وضعیت سند را بداند: + +```text +Saved +Saving... +Unsaved +Offline +Conflict +Save failed +``` + +این indicator در toolbar قرار گیرد. + +--- + +# Publish Readiness + +قبل از Publish Validation انجام شود. + +مثلاً: + +```text +3 issues before publishing +``` + +مواردی مانند: + +* Empty lesson +* Missing title +* Broken asset +* Invalid quiz +* Missing answer +* Missing required settings + +نمایش داده شوند. + +--- + +# Builder Keyboard Shortcuts + +در صورت سازگاری: + +```text +Ctrl/Cmd + S +Ctrl/Cmd + Z +Ctrl/Cmd + Shift + Z +Delete +Escape +Ctrl/Cmd + D +``` + +Shortcuts باید در input/editor مزاحم تایپ نباشند. + +--- + +# AI Studio Redesign + +AI Studio را به workflow واضح تبدیل کن. + +پیشنهاد: + +```text +1 Source +2 Learning Design +3 Generate +4 Review +``` + +--- + +# Upload UI + +از native file input خام استفاده نکن. + +Component حرفه‌ای: + +```text +Drag & Drop +Browse + +Supported: +PDF +DOCX +PPTX + +Selected file: +filename +size +progress +remove +``` + +--- + +# AI Configuration + +کاربر باید قبل از Generate موارد مهم را واضح ببیند: + +```text +Provider +Model +Language +Audience +Difficulty +Duration +Learning objective +Tone +Output type +``` + +تنظیمات advanced را collapse کن. + +--- + +# AI Generation Progress + +به جای spinner ساده: + +```text +Uploading source +Extracting content +Analyzing +Designing learning structure +Generating blocks +Validating +Preparing draft +``` + +نشان داده شود. + +--- + +# AI Review + +AI output نباید blind accept شود. + +در صورت سازگاری architecture، review block-level ایجاد کن: + +```text +AI Suggestion + +Accept +Edit +Reject +``` + +و در نهایت: + +```text +Accept all reviewed changes +``` + +--- + +## Acceptance Criteria Phase 6 + +* Builder سریع‌تر و ساده‌تر شده. +* Canvas فضای بیشتری دارد. +* Properties/Structure واضح‌ترند. +* Save state مشخص است. +* Publish validation وجود دارد. +* AI Studio workflow واضح دارد. +* File Upload UI حرفه‌ای است. +* AI review قابل کنترل است. +* mobile/desktop regression وجود ندارد. + +سپس متوقف شو. + +--- + +# PHASE 7 — Responsive, PWA, i18n & Accessibility + +## Mobile + +تمام صفحات اصلی را در حداقل viewportهای زیر بررسی کن: + +```text +375px +430px +768px +1024px +1440px +``` + +--- + +# Mobile Builder + +Builder نباید صرفاً در mobile مخفی شود. + +نسخه **Quick Edit** ایجاد کن. + +Workflow پیشنهادی: + +```text +Course +↓ +Lesson +↓ +Blocks +↓ +Tap block +↓ +Full screen editor +``` + +Mobile باید حداقل اجازه دهد: + +* edit text +* reorder block +* add common block +* delete block +* image selection +* quiz edit +* preview +* save + +Advanced desktop features می‌توانند desktop-only بمانند. + +--- + +# Learner Experience + +Learner UI را جداگانه audit کن. + +Focus: + +* bottom navigation +* course progress +* touch targets +* reading comfort +* assessments +* resume learning +* offline/PWA state + +--- + +# i18n + +Hard-coded Persian/English stringها را audit کن. + +تمام UI stringها باید از translation catalog بیایند. + +مثلاً: + +```text +t('builder.publish') +t('builder.saved') +t('ai.generate') +``` + +FA: + +```text +dir=rtl +``` + +EN: + +```text +dir=ltr +``` + +Mixed UI حذف شود مگر نام فنی باشد. + +--- + +# Accessibility + +Audit: + +* semantic HTML +* heading hierarchy +* labels +* ARIA +* focus state +* focus trap +* modal/dialog semantics +* drawer semantics +* keyboard navigation +* contrast +* images alt +* tabs +* skip link +* reduced motion + +Tabها باید keyboard accessible باشند. + +Dialog باید focus trap داشته باشد. + +Escape باید modal را ببندد. + +Focus بعد از close به trigger برگردد. + +--- + +# PWA + +بررسی کن: + +* manifest +* icons +* install +* offline fallback +* update handling +* cache policy + +API response حساس را بی‌دلیل cache نکن. + +--- + +## Acceptance Criteria Phase 7 + +* responsive QA کامل است. +* Builder mobile quick-edit دارد. +* FA/EN کامل‌تر شده. +* RTL/LTR صحیح است. +* accessibility issues اصلی رفع شده. +* PWA behavior پایدار است. +* Lighthouse regression جدی وجود ندارد. + +سپس متوقف شو. + +--- + +# PHASE 8 — Final QA, Production Hardening & Release Gate + +این فاز هیچ major feature جدیدی ندارد. + +فقط stabilization. + +--- + +# Full Backend QA + +اجرا کن: + +```bash +php artisan optimize:clear +php artisan route:list +php artisan test +``` + +در صورت وجود: + +```bash +./vendor/bin/pint --test +``` + +بررسی کن: + +* route duplicates +* migration status +* queue jobs +* scheduler +* permissions +* policies +* organization isolation + +--- + +# Full Frontend QA + +از clean install: + +```bash +rm -rf node_modules +npm ci +npm run lint +npm run typecheck +npm test +npm run build +``` + +--- + +# Manual Smoke Tests + +حداقل موارد زیر را دستی بررسی کن. + +## Authentication + +* Login +* Logout +* Expired Session +* Unauthorized URL +* Permission Denied + +## Users + +* Create +* Edit +* Search +* Team assignment + +## Courses + +* Create +* Edit +* Builder +* Add Blocks +* Reorder +* Save +* Preview +* Publish + +## AI + +* Ollama connection +* Connection test +* Generate +* Failure handling +* Timeout +* Invalid model +* AI Studio workflow + +## Learner + +* Open course +* Continue +* Complete lesson +* Quiz +* Progress + +## Monitoring + +* Dashboard +* Filters +* learner progress + +## Theme + +* Light +* Dark + +## Languages + +* Persian +* English + +## Responsive + +* Mobile +* Tablet +* Desktop + +--- + +# Security Audit + +حداقل بررسی کن: + +* XSS +* CSRF +* SSRF +* IDOR +* authorization +* organization isolation +* unsafe uploads +* mass assignment +* exposed secrets +* rate limit +* file access +* AI endpoint abuse + +--- + +# Database + +بررسی کن: + +* indexes +* foreign keys +* unique constraints +* N+1 queries +* transaction boundaries + +خصوصاً جداول پرترافیک. + +--- + +# Performance + +بررسی کن: + +* frontend bundle size +* lazy loading +* unnecessary requests +* duplicate requests +* query count +* image loading +* large lists + +قبل و بعد را در صورت امکان مقایسه کن. + +--- + +# GitHub Actions Final Quality Gate + +Pipeline نهایی باید حداقل چنین منطق داشته باشد: + +```text +Backend install +Backend static checks +Backend tests + +Frontend install +Frontend lint +Frontend typecheck +Frontend tests +Frontend build + +↓ +SUCCESS +``` + +اگر هر مرحله Fail شد Release نباید موفق تلقی شود. + +--- + +# Release Hygiene + +Archive یا deployment package نباید شامل موارد غیرضروری باشد: + +```text +.git +.env +node_modules +temporary logs +IDE files +runtime cache +test artifacts +``` + +Dependencyها باید lock شده باشند. + +در PHP dependencyهای wildcard مانند: + +```text +"*" +``` + +را بررسی و در صورت منطقی بودن version constraint مناسب تعریف کن. + +`minimum-stability` را نیز بررسی کن. + +--- + +# Final Report + +در انتهای Phase 8 یک گزارش نهایی بده. + +فرمت: + +## 1. Bugs Fixed + +جدول: + +| Issue | Severity | Status | + +## 2. Security Improvements + +## 3. Architecture Improvements + +## 4. Performance Improvements + +## 5. UI/UX Improvements + +## 6. Accessibility Improvements + +## 7. Responsive Improvements + +## 8. AI Improvements + +## 9. Test Results + +Backend: + +```text +Tests: +Assertions: +Failures: +``` + +Frontend: + +```text +Test Files: +Tests: +Lint: +Typecheck: +Build: +``` + +## 10. Remaining Technical Debt + +فقط موارد واقعی باقی‌مانده را بنویس. + +Severity بده: + +```text +P0 +P1 +P2 +P3 +``` + +## 11. Production Readiness Score + +از 100 امتیاز بده: + +```text +Architecture +Security +Reliability +Performance +Testing +UI/UX +Accessibility +Maintainability +``` + +و در نهایت یکی از این وضعیت‌ها را اعلام کن: + +```text +NOT READY +STAGING READY +PRODUCTION READY WITH CONDITIONS +PRODUCTION READY +``` + +--- + +# قوانین Git + +در هر Phase: + +قبل از تغییر: + +```bash +git status +``` + +بعد از تغییر: + +```bash +git diff +``` + +فایل‌های unrelated را تغییر نده. + +در پایان هر Phase فایل‌های تغییرکرده را اعلام کن. + +اگر Git repository دارای تغییرات قبلی user است، آنها را overwrite یا revert نکن. + +--- + +# قانون تست + +هیچ Phase را فقط به دلیل اینکه کد compile شد موفق اعلام نکن. + +هر Bug Fix مهم باید تا حد امکان Regression Test داشته باشد. + +اولویت: + +```text +Bug reproduction +↓ +Test +↓ +Fix +↓ +Test passes +``` + +--- + +# قانون Refactor + +Refactor و Feature Change را مخلوط نکن. + +اگر قرار است رفتار تغییر کند، دقیقاً اعلام کن. + +اگر فقط Refactor است، رفتار observable باید ثابت بماند. + +--- + +# قانون UI + +در UI/UX Enhancement: + +* از redesign بی‌هدف خودداری کن. +* Design System موجود را حفظ و تقویت کن. +* visual consistency مهم‌تر از decoration است. +* از gradient، glass، animation و shadow بی‌دلیل استفاده نکن. +* Density برای نرم‌افزار سازمانی متعادل باشد. +* Primary Action در هر صفحه واضح باشد. +* Information hierarchy حفظ شود. +* RTL first-class citizen باشد. + +--- + +# قانون Performance + +قبل از optimization حدس نزن. + +ابتدا bottleneck را شناسایی کن. + +سپس fix کن. + +سپس نتیجه را دوباره اندازه بگیر. + +--- + +# قانون نهایی اجرای مراحل + +ترتیب دقیق: + +```text +PHASE 0 +Baseline & Audit + ↓ +PHASE 1 +Critical Bugs & Security + ↓ +PHASE 2 +AI Architecture + ↓ +PHASE 3 +Frontend Reliability + ↓ +PHASE 4 +Builder Architecture & Performance + ↓ +PHASE 5 +Navigation & Dashboard UX + ↓ +PHASE 6 +Builder & AI Studio UX + ↓ +PHASE 7 +Responsive + i18n + Accessibility + PWA + ↓ +PHASE 8 +Final QA & Production Release Gate +``` + +**هیچ Phase را بدون اجازه من رد نکن.** + +بعد از پایان هر Phase فقط گزارش بده و منتظر دستور من برای Phase بعد بمان. diff --git a/Prompt/UI Prompt.md b/Prompt/UI Prompt.md new file mode 100644 index 0000000..a322cd0 --- /dev/null +++ b/Prompt/UI Prompt.md @@ -0,0 +1,2032 @@ +# Master UI/UX Redesign Prompt — MicroLearning + +می‌خواهم UI/UX پروژه **MicroLearning** را به‌صورت کامل، حرفه‌ای و مرحله‌ای بازطراحی و ارتقا دهی. + +این پروژه یک پلتفرم سازمانی Microlearning است و UI آن باید حس یک **Enterprise SaaS / Learning Platform مدرن، مینیمال، سریع و حرفه‌ای** داشته باشد. + +هدف فقط زیباتر کردن صفحات نیست. + +هدف اصلی: + +* افزایش usability +* کاهش cognitive load +* بهبود information hierarchy +* افزایش سرعت انجام کارها +* یکپارچه‌سازی Design System +* بهبود Builder Experience +* بهبود AI Experience +* بهبود Responsive Design +* افزایش Accessibility +* حفظ کامل RTL/LTR +* ایجاد تجربه منسجم در کل محصول + +--- + +# قانون اجرای پروژه + +این پروژه UI باید دقیقاً در **۸ فاز** انجام شود. + +فازها: + +```text +PHASE 1 +UI Audit + Design System + +PHASE 2 +App Shell + Navigation + +PHASE 3 +Dashboard + Home Experience + +PHASE 4 +Course Builder UX + +PHASE 5 +AI Studio UX + +PHASE 6 +Management Screens + +PHASE 7 +Learner + Responsive Experience + +PHASE 8 +Dark Mode + Accessibility + Final Polish +``` + +هر Phase را جداگانه انجام بده. + +بعد از پایان هر Phase: + +1. تغییرات را کامل کن. +2. صفحات مرتبط را بررسی کن. +3. Responsive را بررسی کن. +4. RTL/LTR را بررسی کن. +5. Dark Mode را تا حد امکان regression test کن. +6. lint را اجرا کن. +7. typecheck را اجرا کن. +8. تست‌های مرتبط را اجرا کن. +9. build را اجرا کن. +10. فایل‌های تغییرکرده را گزارش کن. +11. Before/After UX Summary ارائه کن. +12. متوقف شو. + +تا زمانی که من نگفته‌ام: + +> برو فاز بعد + +نباید Phase بعدی را شروع کنی. + +--- + +# Product Design Direction + +زبان بصری اصلی پروژه باید: + +**Modern Enterprise + Flat UI + Soft Depth** + +باشد. + +نباید ظاهر پروژه تبدیل به Landing Page یا UI نمایشی شود. + +محصول یک ابزار روزمره سازمانی است. + +بنابراین: + +* خوانایی +* سرعت +* hierarchy +* density +* consistency +* discoverability + +مهم‌تر از decoration هستند. + +--- + +# Visual Principles + +از موارد زیر استفاده کن: + +* Flat surfaces +* Subtle borders +* Minimal shadows +* Clear hierarchy +* Moderate border radius +* Neutral backgrounds +* Limited accent colors +* High quality typography +* Lucide Icons +* Clear interactive states + +از موارد زیر بیش از حد استفاده نکن: + +* Gradient +* Glassmorphism +* Glow +* Huge shadows +* Decorative illustrations +* Oversized cards +* excessive rounded corners +* excessive animation + +--- + +# Typography + +برای فارسی: + +**Dana** + +را حفظ کن. + +Hierarchy پیشنهادی: + +```text +Page Title 24–28px +Section Title 18–20px +Card Title 15–16px +Body 14px +Secondary 12–13px +Caption 11–12px +``` + +Weightها را محدود نگه دار. + +مثلاً: + +```text +Regular +Medium +SemiBold +Bold +``` + +از Font Weightهای زیاد بدون ضرورت استفاده نکن. + +--- + +# Spacing System + +Spacing باید بر اساس scale ثابت باشد. + +مثلاً: + +```text +4 +8 +12 +16 +20 +24 +32 +40 +48 +``` + +Spacing تصادفی در Componentها ایجاد نکن. + +--- + +# Radius System + +حداکثر چند radius مشخص داشته باش: + +```text +Small 8px +Medium 12px +Large 16px +XL 20px +``` + +Radius خیلی زیاد ایجاد نکن. + +--- + +# Surface System + +حداقل این سطح‌ها مشخص باشند: + +```text +App Background +Surface +Elevated Surface +Interactive Surface +Overlay +``` + +Dark Mode نیز همین hierarchy را داشته باشد. + +--- + +# Color System + +Colorها را Semantic کن. + +حداقل: + +```text +Primary +Neutral +Success +Warning +Danger +Info +``` + +رنگ UI فقط برای decoration استفاده نشود. + +رنگ باید meaning داشته باشد. + +--- + +# Button System + +Button Variantها را استاندارد کن: + +```text +Primary +Secondary +Outline +Ghost +Danger +Icon Button +``` + +Button sizeها: + +```text +Small +Medium +Large +``` + +هر صفحه حداکثر یک Primary CTA اصلی داشته باشد، مگر اینکه UX واقعاً نیاز داشته باشد. + +--- + +# Shared State Components + +Component استاندارد برای موارد زیر ایجاد یا تثبیت کن: + +```text +Loading +Skeleton +Empty State +Error State +Offline State +Permission Denied +Not Found +Retry +Success State +``` + +هر صفحه نباید طراحی متفاوتی برای این stateها داشته باشد. + +--- + +# PHASE 1 — UI Audit + Design System Foundation + +## هدف + +قبل از تغییر صفحات اصلی، کل UI فعلی را Audit و Design System را تثبیت کن. + +--- + +## UI Audit + +تمام UI را بررسی کن. + +حداقل صفحات: + +* Login +* Dashboard +* Courses +* Course Workspace +* Builder +* Library +* Templates +* Question Bank +* Users +* Teams +* Assignments +* Monitoring +* Reports +* Skills +* Reviews +* Certificates +* Subscription +* Settings +* AI Studio +* Learner pages +* Player +* Assessments + +مشکلات را دسته‌بندی کن: + +```text +Consistency +Hierarchy +Density +Spacing +Typography +Navigation +Forms +Tables +Cards +Dialogs +Mobile +Accessibility +RTL/LTR +Dark Mode +``` + +--- + +## Design Tokens + +بررسی کن Design Tokenهای فعلی چگونه تعریف شده‌اند. + +اگر Design Token وجود دارد، از همان ساختار استفاده کن. + +Tokenهای لازم: + +```text +colors +spacing +radius +shadow +typography +z-index +motion +breakpoints +``` + +از hard-coded style جدید جلوگیری کن. + +--- + +## Core Components + +این Componentها را audit و استاندارد کن: + +```text +Button +IconButton +Input +Textarea +Select +Checkbox +Radio +Switch +Tabs +Badge +Chip +Tooltip +Card +Modal +Dialog +Drawer +Popover +Dropdown +Toast +Table +Pagination +SearchInput +Filter +Breadcrumb +PageHeader +Skeleton +EmptyState +``` + +--- + +## Forms + +برای فرم‌ها استاندارد مشخص کن: + +```text +Label +Required marker +Helper text +Validation +Error +Disabled +Loading +Success +``` + +Error باید دقیقاً نزدیک field مربوطه نمایش داده شود. + +--- + +## Interaction States + +هر Component interactive حداقل این حالت‌ها را داشته باشد: + +```text +default +hover +active +focus +disabled +loading +error +``` + +Focus Ring را حذف نکن. + +--- + +## Acceptance Criteria Phase 1 + +* UI Audit کامل شده باشد. +* Design Tokens یکپارچه باشند. +* Core Components مشخص باشند. +* duplicate styles کاهش یافته باشد. +* Typography hierarchy مشخص باشد. +* Button hierarchy مشخص باشد. +* Form pattern استاندارد باشد. +* RTL/LTR خراب نشده باشد. +* build موفق باشد. + +بعد متوقف شو. + +--- + +# PHASE 2 — App Shell + Navigation Redesign + +## هدف + +ساختار اصلی کل محصول را ساده و حرفه‌ای کن. + +--- + +# App Shell + +ساختار مطلوب: + +```text +Sidebar ++ +Top Header ++ +Page Context ++ +Main Content +``` + +Main Content باید width و spacing مناسب داشته باشد. + +--- + +# Sidebar + +Sidebar فعلی را audit کن. + +Navigation نباید لیست بسیار طولانی flat باشد. + +Grouping پیشنهادی: + +## Create + +* Dashboard +* Courses +* Learning Paths +* Library +* Templates +* Question Bank +* AI Studio + +## People + +* Users +* Teams +* Assignments + +## Insights + +* Monitoring +* Skills +* Reports + +## Manage + +* Reviews +* Certificates +* Subscription +* Settings + +اما ساختار نهایی را بر اساس featureهای واقعی پروژه تعیین کن. + +--- + +# Sidebar Interaction + +Sidebar باید: + +* Collapsible باشد. +* حالت icon-only داشته باشد. +* Tooltip در collapsed mode داشته باشد. +* Active State واضح داشته باشد. +* groupها قابل collapse باشند در صورت نیاز. +* scrolling صحیح داشته باشد. + +Active item را بیش از حد پررنگ نکن. + +پیشنهاد: + +```text +soft background ++ +accent indicator ++ +stronger text +``` + +--- + +# Role Based Navigation + +منو بر اساس: + +```text +Role +Permission +Organization capabilities +Subscription +``` + +نمایش داده شود. + +کاربر نباید گزینه‌ای را ببیند که هیچ دسترسی به آن ندارد. + +--- + +# Header + +Global Header باید فقط عناصر موردنیاز داشته باشد. + +پیشنهاد: + +```text +Page context +Global search +Notifications +AI shortcut if needed +User menu +``` + +Header را شلوغ نکن. + +--- + +# Breadcrumb + +برای صفحات عمیق Breadcrumb اضافه کن. + +مثلاً: + +```text +Courses +/ +Leadership Essentials +/ +Builder +``` + +--- + +# Page Header + +همه صفحات اصلی pattern ثابتی داشته باشند: + +```text +Title +Description / Context +Actions +``` + +مثلاً: + +```text +دوره‌ها + +دوره‌های سازمان را ایجاد، مدیریت و منتشر کنید. + +[ایجاد دوره] +``` + +--- + +# Mobile Navigation + +Learner و Admin/Designer navigation یکسان نباشد. + +### Learner + +Bottom Navigation مناسب است. + +### Designer / Admin + +Drawer یا adaptive sidebar مناسب است. + +--- + +## Acceptance Criteria Phase 2 + +* Navigation ساده‌تر است. +* Sidebar grouped است. +* Collapsed mode دارد. +* Role-aware است. +* Header استاندارد است. +* Page Header pattern ایجاد شده. +* Breadcrumb consistent است. +* responsive navigation صحیح است. + +متوقف شو. + +--- + +# PHASE 3 — Dashboard + Home Experience + +## هدف + +Dashboard از یک صفحه آماری به **Action Center** تبدیل شود. + +--- + +# Dashboard Hierarchy + +پیشنهاد layout: + +```text +Greeting / Context +↓ +Quick Actions +↓ +Compact KPIs +↓ +Needs Attention +↓ +Continue Working +↓ +Recent Courses +↓ +Pending Reviews / AI Jobs +↓ +Recent Activity +``` + +--- + +# Quick Actions + +مثلاً: + +```text +ایجاد دوره +ساخت دوره با AI +آپلود محتوا +ایجاد مسیر یادگیری +دعوت کاربران +``` + +همه را Primary Button نکن. + +یک Primary و بقیه Secondary/Ghost باشند. + +--- + +# KPI Cards + +KPI Cardها: + +* compact باشند. +* ارتفاع زیاد نداشته باشند. +* فقط اطلاعات actionable داشته باشند. + +مثلاً: + +```text +Active courses +Learners +Completion rate +Pending reviews +``` + +اگر Trend واقعی وجود دارد نمایش بده. + +Trend ساختگی ایجاد نکن. + +--- + +# Needs Attention + +یکی از مهم‌ترین بخش‌های Dashboard باشد. + +مثلاً: + +```text +3 courses waiting for review +14 learners behind schedule +2 failed AI jobs +5 assignments overdue +``` + +هر مورد CTA مستقیم داشته باشد. + +--- + +# Continue Working + +Draftها و کارهای نیمه‌تمام را نمایش بده. + +مثلاً: + +```text +Leadership Essentials +Last edited 2h ago +65% + +[Continue] +``` + +--- + +# Activity Feed + +Activity را بیش از حد شلوغ نکن. + +مثلاً: + +```text +Course published +User enrolled +Review requested +AI generation completed +``` + +--- + +# Empty Dashboard + +برای سازمان جدید Dashboard نباید خالی باشد. + +Onboarding state طراحی کن. + +مثلاً: + +```text +Welcome to MicroLearning + +1. Create your first course +2. Invite users +3. Publish learning +``` + +--- + +## Acceptance Criteria Phase 3 + +* Dashboard action-oriented شده. +* whitespace غیرضروری کاهش یافته. +* KPIها compact هستند. +* Needs Attention وجود دارد. +* Continue Working وجود دارد. +* onboarding state وجود دارد. +* mobile layout صحیح است. + +متوقف شو. + +--- + +# PHASE 4 — Course Builder UX Redesign + +## هدف + +Builder باید به بهترین و مهم‌ترین تجربه UI محصول تبدیل شود. + +--- + +# Builder Layout + +چهارپنل همزمان را audit کن. + +Architecture پیشنهادی: + +```text +Left: +Block Library + +Center: +Canvas + +Right: +Tabbed Panel +``` + +Right Panel: + +```text +Properties +Structure +Review +``` + +--- + +# Canvas Priority + +Canvas باید dominant area باشد. + +در Desktop معمولی بهتر است تقریباً: + +```text +55–65% +``` + +فضا را بگیرد. + +--- + +# Block Library + +Block Library: + +* Search داشته باشد. +* Category داشته باشد. +* collapse شود. +* Recent Blocks داشته باشد. +* Favorites در صورت منطقی بودن. +* Drag & Drop واضح باشد. + +Categoryهای احتمالی: + +```text +Text +Media +Interactive +Assessment +Layout +AI +``` + +--- + +# Slash Command + +در Editor امکان: + +```text +/ +``` + +اضافه کن. + +مثلاً: + +```text +/text +/image +/video +/quiz +/flashcard +/divider +/ai +``` + +Command Palette باید searchable باشد. + +--- + +# Block Selection + +Selected block کاملاً مشخص باشد. + +اما border خیلی سنگین ایجاد نکن. + +پیشنهاد: + +```text +subtle outline ++ +floating mini toolbar +``` + +--- + +# Floating Block Toolbar + +برای block انتخاب‌شده: + +```text +Move +Duplicate +Delete +Settings +AI Assist +``` + +بسته به Block Type. + +--- + +# Right Inspector + +Inspector باید context-sensitive باشد. + +مثلاً برای Image: + +```text +Image +Alt text +Caption +Alignment +Size +Link +``` + +برای Quiz: + +```text +Question +Answers +Correct answer +Feedback +Score +``` + +--- + +# Structure Panel + +Structure باید hierarchy واقعی دوره را نشان دهد. + +مثلاً: + +```text +Course + ├─ Module + │ ├─ Lesson + │ │ ├─ Block +``` + +Drag & Drop structure در صورت وجود support شود. + +--- + +# Save Status + +همیشه نمایش داده شود: + +```text +Saved +Saving... +Unsaved +Offline +Conflict +Save failed +``` + +--- + +# Publish + +Publish باید primary action باشد، اما قبل آن Readiness check وجود داشته باشد. + +مثلاً: + +```text +Ready to publish +``` + +یا: + +```text +3 issues before publishing +``` + +Problems قابل click باشند. + +--- + +# Builder Top Toolbar + +Toolbar را سبک نگه دار. + +مثلاً: + +```text +Back +Course title +Device preview +Undo +Redo +Save status +Preview +Publish +``` + +گزینه‌های فرعی داخل More Menu. + +--- + +# Keyboard Shortcuts + +در صورت سازگاری: + +```text +Ctrl/Cmd + S +Ctrl/Cmd + Z +Ctrl/Cmd + Shift + Z +Ctrl/Cmd + D +Delete +Escape +/ +``` + +--- + +# Inline Editing + +Text Block تا حد امکان inline edit داشته باشد. + +کاربر نباید برای هر تغییر کوچک Inspector را باز کند. + +--- + +# Empty Canvas + +Empty Canvas مفید باشد: + +```text +شروع ساخت درس + +بلوک را از پنل کناری بکشید +یا / را فشار دهید + +[افزودن اولین بلوک] +``` + +--- + +## Acceptance Criteria Phase 4 + +* Canvas فضای بیشتری دارد. +* Builder cognitive load کمتری دارد. +* Inspector و Structure بهتر مدیریت شده‌اند. +* Block insertion سریع‌تر شده. +* save status واضح است. +* publish readiness واضح است. +* keyboard UX بهتر شده. +* responsive regression ندارد. + +متوقف شو. + +--- + +# PHASE 5 — AI Studio UX Redesign + +## هدف + +AI Studio باید ساده، قابل اعتماد و قابل کنترل باشد. + +AI نباید حس Black Box داشته باشد. + +--- + +# Workflow + +AI Studio را به Wizard تبدیل کن: + +```text +1. Source +2. Learning Design +3. Generate +4. Review +``` + +Step indicator واضح باشد. + +--- + +# STEP 1 — Source + +Sourceهای ممکن را براساس امکانات واقعی نمایش بده: + +```text +Upload File +Paste Text +URL +Existing Content +``` + +--- + +# File Upload + +Native file input خام نمایش نده. + +Component اختصاصی: + +```text +Drag & Drop your file + +or + +[Browse files] + +PDF • DOCX • PPTX +``` + +بعد از انتخاب: + +```text +Safety Training.pdf +4.2 MB + +Uploaded +[Remove] +``` + +--- + +# STEP 2 — Learning Design + +تنظیمات اصلی را مرتب نمایش بده: + +```text +Language +Audience +Difficulty +Duration +Tone +Learning Objective +Course Type +``` + +Advanced settings را داخل Accordion قرار بده. + +--- + +# AI Provider + +Provider selection نباید برای کاربران غیرتکنیکال بیش از حد برجسته باشد. + +اگر سیستم Default Provider دارد، آن را استفاده کن. + +Advanced users بتوانند Provider را تغییر دهند. + +--- + +# STEP 3 — Generate + +Progress واقعی نمایش بده. + +مثلاً: + +```text +✓ Uploading +✓ Extracting content +● Analyzing material +○ Designing learning flow +○ Generating activities +○ Validating +``` + +Percentage فقط اگر واقعی است نمایش بده. + +--- + +# Generation Error + +به‌جای generic error: + +```text +Ollama is unavailable. + +Check: +• Ollama is running +• Selected model exists + +[Retry] +[Connection settings] +``` + +Error باید actionable باشد. + +--- + +# STEP 4 — Review + +Output را یکباره و بدون review وارد Builder نکن. + +Review قابل کنترل باشد. + +مثلاً: + +```text +Lesson 1 +Introduction to Safety + +AI created: +5 blocks +1 quiz +2 flashcards + +[Preview] +``` + +--- + +# Block Level Review + +در صورت سازگاری architecture: + +```text +AI Suggestion + +[Accept] +[Edit] +[Reject] +``` + +--- + +# AI Confidence / Warning + +در موارد مناسب warning بده: + +```text +This section may require human review. +``` + +اما confidence جعلی تولید نکن. + +--- + +# AI History + +Generationهای اخیر قابل مشاهده باشند: + +```text +Course +Status +Model +Created +Action +``` + +--- + +## Acceptance Criteria Phase 5 + +* AI Studio wizard-based است. +* Upload UI حرفه‌ای است. +* settings ساده‌تر شده. +* advanced settings جدا شده. +* progress واضح است. +* errors actionable هستند. +* review قبل از قبول خروجی وجود دارد. +* mobile UX مناسب است. + +متوقف شو. + +--- + +# PHASE 6 — Courses, Users, Teams & Management Screens + +## هدف + +تمام صفحات مدیریتی یک الگوی ثابت داشته باشند. + +--- + +# Standard Management Page + +Pattern پیشنهادی: + +```text +Page Header +↓ +Search + Filters + View +↓ +Table / Cards +↓ +Pagination +``` + +--- + +# Tables + +Table Design استاندارد کن. + +نیازها: + +* Sticky header +* Row hover +* Sorting +* Filters +* Search +* Pagination +* Bulk selection +* Bulk actions +* Empty State +* Loading Skeleton +* Error State + +--- + +# Row Actions + +Actionهای پرتکرار مستقیم. + +Actionهای کم‌استفاده داخل: + +```text +... +``` + +مثلاً: + +```text +Edit +Duplicate +Archive +Delete +``` + +--- + +# Courses + +Course Card/Table باید اطلاعات اصلی را سریع نشان دهد: + +```text +Title +Status +Author +Learners +Progress +Last updated +``` + +Statusها semantic باشند: + +```text +Draft +Review +Scheduled +Published +Archived +``` + +--- + +# Filtering + +Filterهای active قابل مشاهده باشند. + +مثلاً: + +```text +Status: Published × +Owner: Me × +``` + +و: + +```text +Clear all +``` + +--- + +# Users + +User management باید سریع باشد. + +Columns مهم: + +```text +Name +Role +Team +Status +Last activity +``` + +--- + +# Bulk Actions + +مثلاً: + +```text +Assign to team +Change role +Activate +Deactivate +Enroll +``` + +فقط اگر backend support دارد. + +--- + +# Drawers vs Modals + +Editهای سبک: + +Drawer + +Confirmationها: + +Dialog + +Workflowهای پیچیده: + +Dedicated Page + +--- + +# Settings + +Settings را به sectionهای منطقی تقسیم کن. + +مثلاً: + +```text +General +Branding +Learning +AI +Notifications +Security +Integrations +``` + +از یک صفحه بسیار طولانی جلوگیری کن. + +--- + +## Acceptance Criteria Phase 6 + +* Management pages consistent هستند. +* Table system استاندارد است. +* Filter UX بهتر شده. +* Bulk actions واضح‌اند. +* settings architecture بهتر شده. +* duplicate UI patterns کاهش یافته. + +متوقف شو. + +--- + +# PHASE 7 — Learner Experience + Responsive UI + +## هدف + +Learner Experience باید از Admin Experience جدا و ساده‌تر باشد. + +--- + +# Learner Home + +تمرکز: + +```text +Continue Learning +Assigned Courses +Progress +Due Soon +Completed +Achievements if applicable +``` + +--- + +# Course Player + +Player باید distraction-free باشد. + +Layout پیشنهادی: + +```text +Lesson Content ++ +Minimal Navigation ++ +Progress +``` + +--- + +# Progress + +Progress همیشه قابل فهم باشد. + +مثلاً: + +```text +Lesson 3 of 8 +42% complete +``` + +--- + +# Continue Learning + +سیستم باید واضحاً نشان دهد کاربر آخرین بار کجا بوده است. + +```text +Continue from: +Handling Workplace Conflict +``` + +--- + +# Mobile Navigation + +برای Learner: + +```text +Home +Learn +Progress +Profile +``` + +Bottom Navigation مناسب است. + +--- + +# Mobile Builder + +Builder را کاملاً disable نکن. + +یک **Quick Edit Mode** ایجاد کن. + +Workflow: + +```text +Course +↓ +Lesson +↓ +Blocks +↓ +Block Editor +``` + +حداقل قابلیت‌ها: + +* Edit text +* Add common block +* Reorder +* Delete +* Edit quiz +* Image selection +* Preview +* Save + +--- + +# Responsive Breakpoints + +حداقل بررسی کن: + +```text +375 +430 +768 +1024 +1280 +1440 +1920 +``` + +--- + +# Tables on Mobile + +فقط horizontal scroll استفاده نکن. + +Column priority یا Card View در نظر بگیر. + +--- + +# Touch UX + +Touch target حداقل تقریباً: + +```text +44×44px +``` + +باشد. + +--- + +# Mobile Drawers + +Drawer: + +* full or near-full height +* clear close action +* swipe behavior فقط اگر implementation مطمئن است +* focus management صحیح + +--- + +## Acceptance Criteria Phase 7 + +* Learner Experience ساده‌تر شده. +* Player reading-friendly است. +* mobile navigation مناسب است. +* Builder Quick Edit دارد. +* tables mobile-friendly هستند. +* touch targets مناسب‌اند. +* تمام viewportها بررسی شده‌اند. + +متوقف شو. + +--- + +# PHASE 8 — Dark Mode + Accessibility + Final UI Polish + +## هدف + +تکمیل UI قبل از Release. + +--- + +# Dark Mode + +Dark Mode نباید فقط inversion باشد. + +تعریف کن: + +```text +Background +Surface +Elevated surface +Border +Text primary +Text secondary +Interactive +``` + +Contrast را بررسی کن. + +--- + +# Accessibility + +حداقل WCAG AA را هدف قرار بده. + +Audit کن: + +```text +Keyboard +Focus +Contrast +Labels +ARIA +Semantic HTML +Headings +Alt text +Tabs +Dialogs +Drawers +Tooltips +Forms +Error messages +``` + +--- + +# Keyboard Navigation + +UI اصلی باید بدون mouse قابل استفاده باشد. + +--- + +# Focus + +Focus visible باشد. + +Modal/Drawer: + +* focus trap +* Escape close +* focus return + +--- + +# Tabs + +Tabs باید: + +```text +ArrowLeft +ArrowRight +Home +End +``` + +را در صورت implementation استاندارد پشتیبانی کنند. + +--- + +# Reduced Motion + +اگر user: + +```text +prefers-reduced-motion +``` + +فعال دارد animationها کاهش یابند. + +--- + +# Final Micro Interactions + +Animationها کوتاه و subtle باشند. + +مثلاً: + +```text +120–200ms +``` + +برای: + +* Hover +* Drawer +* Dropdown +* Toast +* Accordion + +Animation نمایشی و کند ایجاد نکن. + +--- + +# Loading + +از Skeleton برای layoutهای content-heavy استفاده کن. + +Spinner برای عملیات کوچک مناسب است. + +--- + +# Toast + +Toastها: + +```text +Success +Error +Warning +Info +``` + +زمان نمایش مناسب داشته باشند. + +Error مهم سریع ناپدید نشود. + +--- + +# Final UX Review + +تمام Product Flowهای اصلی را بررسی کن. + +## Designer + +```text +Login +→ +Dashboard +→ +Create Course +→ +Builder +→ +Preview +→ +Publish +``` + +## AI + +```text +AI Studio +→ +Upload +→ +Configure +→ +Generate +→ +Review +→ +Builder +``` + +## Admin + +```text +Users +→ +Create User +→ +Assign +→ +Monitor +``` + +## Learner + +```text +Login +→ +Assigned Course +→ +Learn +→ +Quiz +→ +Complete +``` + +--- + +# Final UI Quality Gate + +قبل از پایان: + +```bash +npm run lint +npm run typecheck +npm test +npm run build +``` + +باید موفق باشند. + +--- + +# Final Report + +در انتهای Phase 8 گزارش بده: + +## UI Improvements + +## UX Improvements + +## Builder Improvements + +## AI Studio Improvements + +## Responsive Improvements + +## Learner Improvements + +## Accessibility Improvements + +## Dark Mode Improvements + +## Remaining UX Debt + +برای موارد باقی‌مانده Severity بده: + +```text +High +Medium +Low +``` + +--- + +# UI Score + +به این موارد از 10 امتیاز بده: + +```text +Visual Consistency +Navigation +Information Hierarchy +Dashboard +Builder UX +AI UX +Management UX +Learner UX +Responsive +Accessibility +Dark Mode +``` + +و امتیاز کلی: + +```text +UI/UX Score: XX/100 +``` + +--- + +# مهم‌ترین قوانین اجرا + +در تمام ۸ فاز: + +### 1 + +UI جدید نباید Feature موجود را حذف کند. + +### 2 + +Backend Contract را بدون ضرورت تغییر نده. + +### 3 + +قبل از ساخت Component جدید، Componentهای موجود را بررسی کن. + +### 4 + +Duplicate Component ایجاد نکن. + +### 5 + +Design Token استفاده کن. + +### 6 + +RTL و LTR را همزمان در نظر بگیر. + +### 7 + +Mobile را به آخر کار موکول نکن. + +### 8 + +Accessibility را جزئی از Component Design بدان. + +### 9 + +از Decoration غیرضروری جلوگیری کن. + +### 10 + +هر صفحه باید Primary Action واضح داشته باشد. + +### 11 + +هر Async Flow باید: + +```text +Loading +Success +Error +Empty +``` + +را مدیریت کند. + +### 12 + +هر تغییر UI باید usability را بهتر کند، نه صرفاً ظاهر را. + +--- + +# Final Phase Order + +```text +PHASE 1 +Design System + ↓ +PHASE 2 +App Shell & Navigation + ↓ +PHASE 3 +Dashboard + ↓ +PHASE 4 +Course Builder + ↓ +PHASE 5 +AI Studio + ↓ +PHASE 6 +Management Screens + ↓ +PHASE 7 +Learner + Responsive + ↓ +PHASE 8 +Dark Mode + Accessibility + Final Polish +``` + +**هر فاز را کامل کن، تست کن، گزارش بده و سپس منتظر دستور من بمان.** diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6dce7e --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# MicroLearning Intelligence Platform + +Enterprise microlearning authoring, delivery, and learning-intelligence platform. The product is API-first, multi-tenant, bilingual (FA/EN), RTL/LTR-native, and deployable as SaaS or dedicated on-premise software. + +## Applications + +- `backend`: Laravel 12 API and queue workers (PHP 8.2 compatible) +- `frontend`: React 19, TypeScript, and Vite +- `docs`: architecture and product engineering source of truth +- `infrastructure`: deployment assets added per environment + +## Local verification + +```powershell +php backend\artisan test +npm run lint --prefix frontend +npm run build --prefix frontend +``` + +## Run locally on Windows + +Double-click `start-dev.bat` to check dependencies, apply pending non-destructive migrations, start Laravel, Vite, the queue worker, and scheduler in separate windows, and open the application. Run `start-dev.bat --check` to validate prerequisites without starting services. + +For local review data, run `php backend\artisan db:seed`. The seeded review accounts all use the password `password`: + +- Super Admin: `admin@microlearn.test` +- Designer: `designer@microlearn.test` +- Manager: `manager@microlearn.test` +- Learner: `maryam@microlearn.test` + +The local launcher starts PHP with a 2 GB upload limit for large video assets. Production deployments must apply an equivalent request-body limit at both PHP and the reverse proxy/load balancer. + +After signing in, the interactive Builder sample is available at `http://127.0.0.1:5173/app/builder-preview`; its content is explicitly labelled and changes remain local. The real tenant-scoped Builder route is `/app/courses/:courseId/versions/:versionId/lessons/:lessonId/builder`. + +Phase 6 adds the private organization Content Library at `/app/library`, reusable media selection inside the Builder, Navigator, accessibility-aware Inspector controls, and direct learning mappings. Phase 7 adds the Question Bank and Assessment Studio at `/app/question-bank`, the complete assessment/interaction Block catalog, reusable questions, assessment settings, competency mappings, and validated visual Branching Scenario authoring. + +Phase 8 adds readiness-gated and scheduled Course publishing, immutable version history with taxonomy snapshots, dynamic Assignments at `/app/assignments`, Course-level assignment management, and ordered versioned Learning Paths at `/app/learning-paths`. + +Phase 9 adds the assignment-only learner experience at `/learn`, the canonical Flow/Card Player, verified server-side interactions and completion, private notes/highlights/bookmarks/favorites, lesson discussions, and manifest-driven PWA/offline synchronization. + +Phase 10 adds the read-only, team-scoped Manager Workspace at `/manager`, explainable real learning-health metrics and attention drill-downs, visible session Logout across roles, and a bilingual iOS-inspired learner experience with floating safe-area-aware navigation. + +The completed product also includes the Monitoring Engine, collaboration/review workflows, AI Studio and governed ingestion, Export Center at `/app/exports`, and verifiable certificates at `/app/certificates` with public verification at `/certificate/verify/:code`. + +Production and dedicated On-Prem deployment use `docker-compose.production.yml`. Copy the appropriate environment template, supply real secrets, terminate TLS at the ingress/reverse proxy, then follow [docs/deployment.md](docs/deployment.md) and [docs/runbooks/operations.md](docs/runbooks/operations.md). Docker image validation is also enforced by the CI workflow. + +All environments use MySQL 8.4. Existing local SQLite data can be transferred with the guarded procedure in [docs/mysql-migration.md](docs/mysql-migration.md). + +Implementation follows the phased roadmap in [docs/architecture.md](docs/architecture.md). diff --git a/Screen Shots/Dashboard.jpg b/Screen Shots/Dashboard.jpg new file mode 100644 index 0000000..3a8ebe9 Binary files /dev/null and b/Screen Shots/Dashboard.jpg differ diff --git a/Screen Shots/login page.jpg b/Screen Shots/login page.jpg new file mode 100644 index 0000000..34b4630 Binary files /dev/null and b/Screen Shots/login page.jpg differ diff --git a/Screen Shots/ارزیابی.jpg b/Screen Shots/ارزیابی.jpg new file mode 100644 index 0000000..5ffe8d9 Binary files /dev/null and b/Screen Shots/ارزیابی.jpg differ diff --git a/Screen Shots/استدیو هوش مصنوعی.jpg b/Screen Shots/استدیو هوش مصنوعی.jpg new file mode 100644 index 0000000..a3010b8 Binary files /dev/null and b/Screen Shots/استدیو هوش مصنوعی.jpg differ diff --git a/Screen Shots/بانک سوال.jpg b/Screen Shots/بانک سوال.jpg new file mode 100644 index 0000000..505f1c6 Binary files /dev/null and b/Screen Shots/بانک سوال.jpg differ diff --git a/Screen Shots/تخصیص ها.jpg b/Screen Shots/تخصیص ها.jpg new file mode 100644 index 0000000..69d810d Binary files /dev/null and b/Screen Shots/تخصیص ها.jpg differ diff --git a/Screen Shots/دوره ها - header.jpg b/Screen Shots/دوره ها - header.jpg new file mode 100644 index 0000000..85d8ce7 Binary files /dev/null and b/Screen Shots/دوره ها - header.jpg differ diff --git a/Screen Shots/دوره ها.jpg b/Screen Shots/دوره ها.jpg new file mode 100644 index 0000000..7162d00 Binary files /dev/null and b/Screen Shots/دوره ها.jpg differ diff --git a/Screen Shots/ساخت قالب.jpg b/Screen Shots/ساخت قالب.jpg new file mode 100644 index 0000000..880ffe3 Binary files /dev/null and b/Screen Shots/ساخت قالب.jpg differ diff --git a/Screen Shots/سناریو تصمیم.jpg b/Screen Shots/سناریو تصمیم.jpg new file mode 100644 index 0000000..bea7b94 Binary files /dev/null and b/Screen Shots/سناریو تصمیم.jpg differ diff --git a/Screen Shots/سناریو شاخه ای.jpg b/Screen Shots/سناریو شاخه ای.jpg new file mode 100644 index 0000000..d18d73a Binary files /dev/null and b/Screen Shots/سناریو شاخه ای.jpg differ diff --git a/Screen Shots/سناریو.jpg b/Screen Shots/سناریو.jpg new file mode 100644 index 0000000..7064452 Binary files /dev/null and b/Screen Shots/سناریو.jpg differ diff --git a/Screen Shots/صفحه اصلی.jpg b/Screen Shots/صفحه اصلی.jpg new file mode 100644 index 0000000..3a10267 Binary files /dev/null and b/Screen Shots/صفحه اصلی.jpg differ diff --git a/Screen Shots/صفحه طراحی دوره.jpg b/Screen Shots/صفحه طراحی دوره.jpg new file mode 100644 index 0000000..226225d Binary files /dev/null and b/Screen Shots/صفحه طراحی دوره.jpg differ diff --git a/Screen Shots/فضای دوره.jpg b/Screen Shots/فضای دوره.jpg new file mode 100644 index 0000000..ccf9c88 Binary files /dev/null and b/Screen Shots/فضای دوره.jpg differ diff --git a/Screen Shots/قالب ها.jpg b/Screen Shots/قالب ها.jpg new file mode 100644 index 0000000..7571d1f Binary files /dev/null and b/Screen Shots/قالب ها.jpg differ diff --git a/Screen Shots/محتوا.jpg b/Screen Shots/محتوا.jpg new file mode 100644 index 0000000..fd1739a Binary files /dev/null and b/Screen Shots/محتوا.jpg differ diff --git a/Screen Shots/کارت قالب دوره.jpg b/Screen Shots/کارت قالب دوره.jpg new file mode 100644 index 0000000..8f608b5 Binary files /dev/null and b/Screen Shots/کارت قالب دوره.jpg differ diff --git a/Screen Shots/یادگیرندگان.jpg b/Screen Shots/یادگیرندگان.jpg new file mode 100644 index 0000000..9fab066 Binary files /dev/null and b/Screen Shots/یادگیرندگان.jpg differ diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 0000000..8f0de65 --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[docker-compose.yml] +indent_size = 4 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..1b583bc --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,81 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost +FRONTEND_URL=http://127.0.0.1:5173 +DEPLOYMENT_MODE=saas + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=microlearn +DB_USERNAME=root +DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database +EXPORT_DISK=local +EXPORT_RETENTION_DAYS=30 +FFMPEG_BINARY=ffmpeg +FFMPEG_FONT= +FFMPEG_TIMEOUT=600 + +# OpenAI-compatible provider (optional; local AI remains the default) +AI_OPENAI_BASE_URL=https://api.openai.com/v1 +AI_OPENAI_API_KEY= +AI_OPENAI_MODEL_FAST=gpt-4.1-mini +AI_OPENAI_MODEL_BALANCED=gpt-4.1-mini +AI_OPENAI_MODEL_ADVANCED=gpt-4.1-mini +AI_OPENAI_TIMEOUT=90 +AI_CUSTOM_PROVIDER_ALLOWED_HOSTS= + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/backend/.gitattributes b/backend/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/backend/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..c7cf1fa --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,23 @@ +/.phpunit.cache +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +Homestead.json +Homestead.yaml +npm-debug.log +yarn-error.log +/auth.json +/.fleet +/.idea +/.nova +/.vscode +/.zed diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..1a4c26b --- /dev/null +++ b/backend/README.md @@ -0,0 +1,66 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. + +You may also try the [Laravel Bootcamp](https://bootcamp.laravel.com), where you will be guided through building a modern Laravel application from scratch. + +If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +## Laravel Sponsors + +We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com). + +### Premium Partners + +- **[Vehikl](https://vehikl.com/)** +- **[Tighten Co.](https://tighten.co)** +- **[WebReinvent](https://webreinvent.com/)** +- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)** +- **[64 Robots](https://64robots.com)** +- **[Curotec](https://www.curotec.com/services/technologies/laravel/)** +- **[Cyber-Duck](https://cyber-duck.co.uk)** +- **[DevSquad](https://devsquad.com/hire-laravel-developers)** +- **[Jump24](https://jump24.co.uk)** +- **[Redberry](https://redberry.international/laravel/)** +- **[Active Logic](https://activelogic.com)** +- **[byte5](https://byte5.de)** +- **[OP.GG](https://op.gg)** + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/backend/app/Console/Commands/ImportSqliteDatabase.php b/backend/app/Console/Commands/ImportSqliteDatabase.php new file mode 100644 index 0000000..6b751c9 --- /dev/null +++ b/backend/app/Console/Commands/ImportSqliteDatabase.php @@ -0,0 +1,132 @@ +error('The default DB_CONNECTION must be mysql.'); + + return self::FAILURE; + } + + $path = $this->absolutePath((string) $this->argument('path')); + + if (! is_file($path)) { + $this->error("SQLite database not found: {$path}"); + + return self::FAILURE; + } + + $chunkSize = max(1, (int) $this->option('chunk')); + config(['database.connections.sqlite_import.database' => $path]); + DB::purge('sqlite_import'); + + $migrationExitCode = Artisan::call('migrate', ['--force' => true]); + $this->output->write(Artisan::output()); + + if ($migrationExitCode !== self::SUCCESS) { + $this->error('MySQL migrations failed; no SQLite data was copied.'); + + return self::FAILURE; + } + + $sourceTables = array_map( + static fn (object $table): string => $table->name, + DB::connection('sqlite_import')->select( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'" + ) + ); + $targetTables = array_map( + static fn (object $table): string => $table->name, + DB::connection('mysql')->select( + "SELECT table_name AS name FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'" + ) + ); + $tables = array_values(array_intersect($sourceTables, $targetTables)); + $tables = array_values(array_diff($tables, ['migrations', 'sqlite_sequence'])); + + $this->assertTargetIsEmpty($tables); + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=0'); + + try { + foreach ($tables as $table) { + $copied = $this->copyTable($table, $chunkSize); + $this->line("{$table}: {$copied} rows"); + } + } finally { + DB::connection('mysql')->statement('SET FOREIGN_KEY_CHECKS=1'); + } + + $this->newLine(); + $this->info('SQLite data imported into MySQL successfully. The SQLite file was left unchanged.'); + + return self::SUCCESS; + } + + private function assertTargetIsEmpty(array $tables): void + { + foreach ($tables as $table) { + if (DB::connection('mysql')->table($table)->exists()) { + throw new RuntimeException( + "MySQL table [{$table}] is not empty. Import into a new database to prevent duplicate or overwritten data." + ); + } + } + } + + private function copyTable(string $table, int $chunkSize): int + { + $sourceColumns = Schema::connection('sqlite_import')->getColumnListing($table); + $targetColumns = Schema::connection('mysql')->getColumnListing($table); + $columns = array_values(array_intersect($sourceColumns, $targetColumns)); + + if ($columns === []) { + $this->warn("{$table}: skipped because it has no compatible columns"); + + return 0; + } + + $copied = 0; + DB::connection('sqlite_import')->table($table)->select($columns)->orderBy($columns[0])->chunk( + $chunkSize, + function ($rows) use ($table, $columns, &$copied): void { + $payload = $rows->map(static function ($row) use ($columns): array { + $values = (array) $row; + + return array_intersect_key($values, array_flip($columns)); + })->all(); + + if ($payload !== []) { + DB::connection('mysql')->table($table)->insert($payload); + $copied += count($payload); + } + } + ); + + return $copied; + } + + private function absolutePath(string $path): string + { + if (preg_match('/^(?:[A-Za-z]:[\\\\\/]|\/)/', $path) === 1) { + return $path; + } + + return base_path($path); + } +} diff --git a/backend/app/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/backend/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ + */ + use HasApiTokens, HasFactory, HasUlids, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'organization_id', + 'name', + 'first_name', + 'last_name', + 'department', + 'job_level', + 'direct_manager_id', + 'email', + 'password', + 'role', + 'status', + 'locale', + 'timezone', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + 'role' => UserRole::class, + 'status' => AccountStatus::class, + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function teams(): BelongsToMany + { + return $this->belongsToMany(Team::class, 'team_memberships')->withTimestamps(); + } + + public function managedTeams(): BelongsToMany + { + return $this->belongsToMany(Team::class, 'team_managers')->withTimestamps(); + } + + public function directManager(): BelongsTo + { + return $this->belongsTo(self::class, 'direct_manager_id'); + } + + public function directReports(): HasMany + { + return $this->hasMany(self::class, 'direct_manager_id'); + } +} diff --git a/backend/app/Modules/AI/Application/AiProviderResolver.php b/backend/app/Modules/AI/Application/AiProviderResolver.php new file mode 100644 index 0000000..576b979 --- /dev/null +++ b/backend/app/Modules/AI/Application/AiProviderResolver.php @@ -0,0 +1,106 @@ +where('organization_id', $organizationId) + ->where('enabled', true) + ->orderByDesc('is_default') + ->orderBy('created_at') + ->orderBy('id') + ->first(); + if ($connection) { + return $this->fromConnection($connection); + } + $row = DB::table('organization_profiles')->where('organization_id', $organizationId)->first(); + $settings = json_decode($row?->settings ?: '{}', true); + + return ($settings['ai']['provider'] ?? 'local') === 'openai' ? $this->openAi : $this->local; + } + + public function byId(string $id, string $organizationId): AiProvider + { + if (str_starts_with($id, 'connection:')) { + $connection = DB::table('ai_provider_connections') + ->where('organization_id', $organizationId) + ->where('id', substr($id, 11)) + ->where('enabled', true) + ->first(); + if ($connection) { + return $this->fromConnection($connection); + } + + throw new RuntimeException('The selected AI provider connection is unavailable.'); + } + + return match ($id) { + 'openai' => $this->openAi, + 'local', 'local-structuring-v1' => $this->local, + default => throw new RuntimeException('The selected AI provider is unknown.'), + }; + } + + public function fallbackForOrganization(string $organizationId, string $failedProviderId): ?AiProvider + { + $settings = $this->settings($organizationId); + $enabled = (bool) ($settings['reliability']['fallbackEnabled'] ?? true); + $fallback = $settings['fallbackProvider'] ?? 'local'; + + $fallbackConnectionId = $settings['fallbackConnectionId'] ?? null; + if ($enabled && $fallbackConnectionId && ! str_contains($failedProviderId, $fallbackConnectionId)) { + $connection = DB::table('ai_provider_connections')->where('organization_id', $organizationId)->where('id', $fallbackConnectionId)->where('enabled', true)->first(); + if ($connection) { + return $this->fromConnection($connection); + } + } + + return $enabled && $failedProviderId !== 'local' && $fallback === 'local' ? $this->local : null; + } + + /** @return array */ + public function status(string $organizationId): array + { + $provider = $this->forOrganization($organizationId); + if ($provider instanceof OpenAiCompatibleProvider) { + return ['id' => $provider->id(), 'external' => $provider->external(), 'configured' => $provider->configured(), ...$provider->health()]; + } + + return ['id' => $provider->id(), 'external' => false, 'configured' => true, 'connected' => true, 'latencyMs' => 0, 'message' => 'پردازش محلی آماده است.']; + } + + /** @return array */ + private function settings(string $organizationId): array + { + $row = DB::table('organization_profiles')->where('organization_id', $organizationId)->first(); + $settings = json_decode($row?->settings ?: '{}', true); + + return $settings['ai'] ?? []; + } + + private function fromConnection(object $row): OpenAiCompatibleProvider + { + return new OpenAiCompatibleProvider($this->endpointPolicy, [ + 'id' => $row->id, 'provider' => $row->provider, 'mode' => $row->mode, 'base_url' => $row->base_url, + 'api_key' => filled($row->encrypted_api_key) ? Crypt::decryptString($row->encrypted_api_key) : '', + 'default_model' => $row->default_model, 'timeout_seconds' => $row->timeout_seconds, + ]); + } +} diff --git a/backend/app/Modules/AI/Application/DocumentExtractor.php b/backend/app/Modules/AI/Application/DocumentExtractor.php new file mode 100644 index 0000000..e1baf83 --- /dev/null +++ b/backend/app/Modules/AI/Application/DocumentExtractor.php @@ -0,0 +1,95 @@ + */ + public function extract(string $path, string $kind): array + { + return match ($kind) { + 'pdf' => $this->pdf($path), + 'docx' => $this->docx($path), + 'pptx' => $this->pptx($path), + 'scorm' => [], + default => throw new RuntimeException('Unsupported source document type.'), + }; + } + + private function normalize(string $value): string + { + $value = html_entity_decode($value, ENT_QUOTES | ENT_XML1, 'UTF-8'); + $value = preg_replace('/[\t ]+/u', ' ', $value) ?? $value; + $value = preg_replace('/\R{3,}/u', "\n\n", $value) ?? $value; + + return trim($value); + } + + /** @return list */ + private function pdf(string $path): array + { + $pages = (new Parser)->parseFile($path)->getPages(); + + return collect($pages)->map(fn ($page, int $index) => ['locator' => 'page:'.($index + 1), 'heading' => 'Page '.($index + 1), 'content' => $this->normalize($page->getText())])->filter(fn ($item) => $item['content'] !== '')->values()->all(); + } + + /** @return list */ + private function docx(string $path): array + { + $xml = $this->zipEntry($path, 'word/document.xml'); + $paragraphs = preg_split('/]*>/u', $xml) ?: []; + $items = []; + foreach ($paragraphs as $paragraph) { + preg_match_all('/]*>(.*?)<\/w:t>/us', $paragraph, $matches); + $content = $this->normalize(implode('', $matches[1] ?? [])); + if ($content !== '') { + $items[] = ['locator' => 'paragraph:'.(count($items) + 1), 'heading' => mb_strlen($content) <= 120 ? $content : null, 'content' => $content]; + } + } + + return $items; + } + + /** @return list */ + private function pptx(string $path): array + { + $zip = new ZipArchive; + throw_unless($zip->open($path) === true, RuntimeException::class, 'Unable to open PPTX archive.'); + $names = []; + for ($index = 0; $index < $zip->numFiles; $index++) { + $name = $zip->getNameIndex($index); + if ($name && preg_match('#^ppt/slides/slide\d+\.xml$#', $name)) { + $names[] = $name; + } + } + natsort($names); + $items = []; + foreach (array_values($names) as $index => $name) { + $xml = (string) $zip->getFromName($name); + preg_match_all('/(.*?)<\/a:t>/us', $xml, $matches); + $texts = array_map(fn ($text) => $this->normalize($text), $matches[1] ?? []); + $content = $this->normalize(implode("\n", array_filter($texts))); + if ($content !== '') { + $items[] = ['locator' => 'slide:'.($index + 1), 'heading' => $texts[0] ?? null, 'content' => $content]; + } + } + $zip->close(); + + return $items; + } + + private function zipEntry(string $path, string $entry): string + { + $zip = new ZipArchive; + throw_unless($zip->open($path) === true, RuntimeException::class, 'Unable to open document archive.'); + $content = $zip->getFromName($entry); + $zip->close(); + throw_if($content === false, RuntimeException::class, 'Required document content is missing.'); + + return $content; + } +} diff --git a/backend/app/Modules/AI/Application/ProcessAiJob.php b/backend/app/Modules/AI/Application/ProcessAiJob.php new file mode 100644 index 0000000..f9767e0 --- /dev/null +++ b/backend/app/Modules/AI/Application/ProcessAiJob.php @@ -0,0 +1,64 @@ +find($this->jobId); + if (! $job || $job->cancelled_at) { + return; + } + DB::table('ai_jobs')->where('id', $this->jobId)->update(['status' => 'processing', 'progress' => 10, 'started_at' => now(), 'updated_at' => now()]); + try { + $input = json_decode($job->input ?: '{}', true); + $fragments = []; + foreach (DB::table('ai_source_documents')->where('ai_job_id', $this->jobId)->get() as $document) { + foreach ($extractor->extract(Storage::disk($document->disk)->path($document->path), $document->kind) as $position => $fragment) { + $id = (string) str()->ulid(); + DB::table('ai_source_fragments')->insert(['id' => $id, 'organization_id' => $job->organization_id, 'source_document_id' => $document->id, 'position' => $position + 1, 'locator' => $fragment['locator'], 'heading' => $fragment['heading'], 'content' => $fragment['content'], 'content_hash' => hash('sha256', $fragment['content']), 'metadata' => json_encode(['schemaVersion' => 1]), 'created_at' => now(), 'updated_at' => now()]); + $fragments[] = ['id' => $id, ...$fragment]; + } + } + if ($fragments === []) { + $fragments[] = ['id' => 'prompt', 'locator' => null, 'heading' => $input['topic'] ?? null, 'content' => $input['objective'] ?? $input['topic'] ?? '']; + } + DB::table('ai_jobs')->where('id', $this->jobId)->update(['progress' => 65, 'updated_at' => now()]); + try { + $proposal = $providers->byId($job->provider, $job->organization_id)->structure($fragments, $input); + } catch (Throwable $providerException) { + $fallback = $providers->fallbackForOrganization($job->organization_id, $job->provider); + if (! $fallback) { + throw $providerException; + } + $proposal = $fallback->structure($fragments, $input); + $proposal['fallbackDisclosure'] = 'The configured external provider failed; the organization fallback provider generated this draft.'; + } + $suggestionId = (string) str()->ulid(); + DB::table('ai_suggestions')->insert(['id' => $suggestionId, 'organization_id' => $job->organization_id, 'ai_job_id' => $this->jobId, 'suggestion_type' => 'course_draft', 'payload' => json_encode($proposal), 'confidence' => 70, 'rationale' => 'Source-grounded structure requires Designer review before acceptance.', 'status' => 'draft', 'created_at' => now(), 'updated_at' => now()]); + DB::table('ai_jobs')->where('id', $this->jobId)->update(['status' => 'completed', 'progress' => 100, 'output' => json_encode(['suggestionId' => $suggestionId]), 'input_units' => array_sum(array_map(fn ($item) => mb_strlen($item['content']), $fragments)), 'output_units' => mb_strlen(json_encode($proposal)), 'completed_at' => now(), 'updated_at' => now()]); + DB::table('subscriptions')->where('organization_id', $job->organization_id)->where('status', 'active')->whereNotNull('ai_credit_quota')->increment('ai_credits_used'); + } catch (Throwable $exception) { + DB::table('ai_jobs')->where('id', $this->jobId)->update(['status' => 'failed', 'error' => mb_substr($exception->getMessage(), 0, 4000), 'updated_at' => now()]); + throw $exception; + } + } +} diff --git a/backend/app/Modules/AI/Domain/AiProvider.php b/backend/app/Modules/AI/Domain/AiProvider.php new file mode 100644 index 0000000..0e9617a --- /dev/null +++ b/backend/app/Modules/AI/Domain/AiProvider.php @@ -0,0 +1,23 @@ + $fragments + * @param array $options + * @return array + */ + public function structure(array $fragments, array $options): array; + + /** @param array $context + * @return array + */ + public function assist(string $operation, string $content, array $context): array; +} diff --git a/backend/app/Modules/AI/Http/AiChatController.php b/backend/app/Modules/AI/Http/AiChatController.php new file mode 100644 index 0000000..ac418f9 --- /dev/null +++ b/backend/app/Modules/AI/Http/AiChatController.php @@ -0,0 +1,48 @@ +permissions->allows($request->user(), Permission::CoursesAuthor), 403); + $validated = $request->validate(['prompt' => ['required', 'string', 'max:10000']]); + $answer = $this->providers->forOrganization($this->tenant->id())->chat( + $validated['prompt'], + <<<'PROMPT' +You are an expert instructional designer. + +Your specialty is: +- Microlearning +- Adult learning +- Corporate learning +- Instructional design +- Learning objectives +- Assessments +- Scenario-based learning + +Answer in Persian unless the user explicitly requests another language. +PROMPT, + ); + + return response()->json([ + 'success' => true, + 'data' => ['answer' => $answer], + ]); + } +} diff --git a/backend/app/Modules/AI/Http/AiConnectionController.php b/backend/app/Modules/AI/Http/AiConnectionController.php new file mode 100644 index 0000000..472f06b --- /dev/null +++ b/backend/app/Modules/AI/Http/AiConnectionController.php @@ -0,0 +1,257 @@ +author($request); + $items = DB::table('ai_provider_connections')->where('organization_id', $this->tenant->id())->orderByDesc('is_default')->orderBy('name')->get()->map(fn ($row) => $this->payload($row)); + + return response()->json(['data' => ['items' => $items, 'providers' => $this->providers()]]); + } + + public function store(Request $request): JsonResponse + { + $this->author($request); + $data = $this->validated($request); + $id = (string) Str::ulid(); + DB::transaction(function () use ($data, $id, $request): void { + $this->lockOrganization(); + DB::table('ai_provider_connections')->insert([ + 'id' => $id, 'organization_id' => $this->tenant->id(), 'created_by' => $request->user()->getKey(), + ...$this->columns($data), 'encrypted_api_key' => filled($data['apiKey'] ?? null) ? Crypt::encryptString($data['apiKey']) : null, + 'models' => json_encode([]), 'is_default' => false, 'created_at' => now(), 'updated_at' => now(), + ]); + $this->normalizeDefault(); + }, 3); + + return response()->json(['data' => $this->payload($this->connection($id))], 201); + } + + public function update(Request $request, string $connection): JsonResponse + { + $this->author($request); + $data = $this->validated($request); + $columns = [...$this->columns($data), 'updated_at' => now()]; + if (filled($data['apiKey'] ?? null)) { + $columns['encrypted_api_key'] = Crypt::encryptString($data['apiKey']); + } + if (($data['clearApiKey'] ?? false) === true) { + $columns['encrypted_api_key'] = null; + } + DB::transaction(function () use ($connection, $columns): void { + $this->lockOrganization(); + $row = $this->lockedConnection($connection); + DB::table('ai_provider_connections')->where('id', $row->id)->update($columns); + $this->normalizeDefault(); + }, 3); + + return response()->json(['data' => $this->payload($this->connection($connection))]); + } + + public function destroy(Request $request, string $connection): JsonResponse + { + $this->author($request); + DB::transaction(function () use ($connection): void { + $this->lockOrganization(); + $row = $this->lockedConnection($connection); + DB::table('ai_provider_connections')->where('id', $row->id)->delete(); + $this->normalizeDefault(); + }, 3); + + return response()->json(status: 204); + } + + public function makeDefault(Request $request, string $connection): JsonResponse + { + $this->author($request); + DB::transaction(function () use ($connection): void { + $this->lockOrganization(); + $row = $this->lockedConnection($connection); + abort_unless($row->enabled, 422, 'اتصال غیرفعال نمی‌تواند پیش‌فرض باشد.'); + $this->normalizeDefault($row->id); + }, 3); + + return response()->json(['data' => $this->payload($this->connection($connection))]); + } + + public function test(Request $request, string $connection): JsonResponse + { + $this->author($request); + $row = $this->connection($connection); + $result = $this->probe($row); + DB::table('ai_provider_connections')->where('id', $row->id)->update(['last_status' => $result['connected'] ? 'connected' : 'failed', 'last_latency_ms' => $result['latencyMs'], 'last_error' => $result['connected'] ? null : $result['message'], 'last_tested_at' => now(), 'updated_at' => now()]); + + return response()->json(['data' => $result]); + } + + public function discover(Request $request, string $connection): JsonResponse + { + $this->author($request); + $row = $this->connection($connection); + try { + $response = $this->client($row)->get(rtrim($row->base_url, '/').'/models'); + abort_unless($response->successful(), 422, 'فهرست مدل‌ها از ارائه‌دهنده دریافت نشد.'); + $models = collect($response->json('data', []))->map(fn ($model) => is_array($model) ? ($model['id'] ?? null) : null)->filter()->unique()->sort()->values()->all(); + if ($models === []) { + throw ValidationException::withMessages(['connection' => ['ارائه‌دهنده هیچ مدل قابل استفاده‌ای برنگرداند.']]); + } + DB::table('ai_provider_connections')->where('id', $row->id)->update(['models' => json_encode($models), 'default_model' => in_array($row->default_model, $models, true) ? $row->default_model : $models[0], 'last_status' => 'connected', 'last_error' => null, 'last_tested_at' => now(), 'updated_at' => now()]); + + return response()->json(['data' => ['models' => $models]]); + } catch (ValidationException $exception) { + throw $exception; + } catch (\Throwable) { + throw ValidationException::withMessages(['connection' => ['کشف مدل‌ها ناموفق بود؛ Endpoint و کلید دسترسی را بررسی کنید.']]); + } + } + + private function validated(Request $request): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:160'], + 'provider' => ['required', Rule::in(array_keys($this->providers()))], + 'mode' => ['required', Rule::in(['local', 'online'])], + 'baseUrl' => ['required', 'url:http,https', 'max:1000'], + 'apiKey' => ['nullable', 'string', 'max:4000'], 'clearApiKey' => ['nullable', 'boolean'], + 'defaultModel' => ['nullable', 'string', 'max:255'], 'timeoutSeconds' => ['required', 'integer', 'between:10,600'], 'enabled' => ['required', 'boolean'], + ]); + if ($data['mode'] === 'online' && ! str_starts_with($data['baseUrl'], 'https://')) { + throw ValidationException::withMessages(['baseUrl' => ['اتصال آنلاین باید از HTTPS استفاده کند.']]); + } + try { + $this->endpointPolicy->assertAllowed($data['provider'], $data['mode'], $data['baseUrl']); + } catch (InvalidArgumentException $exception) { + throw ValidationException::withMessages(['baseUrl' => [$exception->getMessage()]]); + } + + return $data; + } + + private function columns(array $data): array + { + return ['name' => trim($data['name']), 'provider' => $data['provider'], 'mode' => $data['mode'], 'base_url' => rtrim($data['baseUrl'], '/'), 'default_model' => $data['defaultModel'] ?: null, 'timeout_seconds' => $data['timeoutSeconds'], 'enabled' => $data['enabled']]; + } + + private function connection(string $id): object + { + $row = DB::table('ai_provider_connections')->where('organization_id', $this->tenant->id())->where('id', $id)->first(); + abort_unless($row, 404); + + return $row; + } + + private function lockedConnection(string $id): object + { + $row = DB::table('ai_provider_connections') + ->where('organization_id', $this->tenant->id()) + ->where('id', $id) + ->lockForUpdate() + ->first(); + abort_unless($row, 404); + + return $row; + } + + private function lockOrganization(): void + { + DB::table('organizations')->where('id', $this->tenant->id())->lockForUpdate()->first(); + } + + private function normalizeDefault(?string $preferredId = null): void + { + $enabled = DB::table('ai_provider_connections') + ->where('organization_id', $this->tenant->id()) + ->where('enabled', true) + ->orderByDesc('is_default') + ->orderBy('created_at') + ->orderBy('id') + ->get(['id', 'is_default']); + $selectedId = $preferredId && $enabled->contains('id', $preferredId) + ? $preferredId + : $enabled->firstWhere('is_default', true)?->id ?? $enabled->first()?->id; + + DB::table('ai_provider_connections') + ->where('organization_id', $this->tenant->id()) + ->where('is_default', true) + ->update(['is_default' => false, 'updated_at' => now()]); + if ($selectedId) { + DB::table('ai_provider_connections') + ->where('organization_id', $this->tenant->id()) + ->where('id', $selectedId) + ->where('enabled', true) + ->update(['is_default' => true, 'updated_at' => now()]); + } + } + + private function payload(object $row): array + { + return ['id' => $row->id, 'name' => $row->name, 'provider' => $row->provider, 'mode' => $row->mode, 'baseUrl' => $row->base_url, 'hasApiKey' => filled($row->encrypted_api_key), 'models' => json_decode($row->models ?: '[]', true), 'defaultModel' => $row->default_model, 'timeoutSeconds' => $row->timeout_seconds, 'enabled' => (bool) $row->enabled, 'isDefault' => (bool) $row->is_default, 'lastStatus' => $row->last_status, 'lastLatencyMs' => $row->last_latency_ms, 'lastError' => $row->last_error, 'lastTestedAt' => $row->last_tested_at]; + } + + private function probe(object $row): array + { + $started = microtime(true); + try { + $response = $this->client($row)->get(rtrim($row->base_url, '/').'/models'); + $connected = $response->successful(); + + return ['connected' => $connected, 'latencyMs' => (int) round((microtime(true) - $started) * 1000), 'message' => $connected ? 'اتصال با موفقیت برقرار شد.' : 'ارائه‌دهنده پاسخ معتبر نداد.']; + } catch (\Throwable) { + return ['connected' => false, 'latencyMs' => (int) round((microtime(true) - $started) * 1000), 'message' => 'اتصال با ارائه‌دهنده برقرار نشد.']; + } + } + + private function client(object $row): PendingRequest + { + $client = Http::acceptJson() + ->timeout((int) $row->timeout_seconds) + ->withOptions($this->endpointPolicy->requestOptions($row->provider, $row->mode, $row->base_url)); + if (filled($row->encrypted_api_key)) { + $client = $client->withToken(Crypt::decryptString($row->encrypted_api_key)); + } + + return $client; + } + + private function providers(): array + { + return [ + 'openai' => ['label' => 'OpenAI', 'mode' => 'online', 'defaultBaseUrl' => 'https://api.openai.com/v1', 'requiresApiKey' => true], + 'openai_compatible' => ['label' => 'سرویس آنلاین OpenAI Compatible', 'mode' => 'online', 'defaultBaseUrl' => 'https://', 'requiresApiKey' => true], + 'ollama' => ['label' => 'Ollama', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:11434/v1', 'requiresApiKey' => false], + 'lm_studio' => ['label' => 'LM Studio', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:1234/v1', 'requiresApiKey' => false], + 'vllm' => ['label' => 'vLLM', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:8000/v1', 'requiresApiKey' => false], + 'localai' => ['label' => 'LocalAI', 'mode' => 'local', 'defaultBaseUrl' => 'http://127.0.0.1:8080/v1', 'requiresApiKey' => false], + ]; + } + + private function author(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } +} diff --git a/backend/app/Modules/AI/Http/AiStudioController.php b/backend/app/Modules/AI/Http/AiStudioController.php new file mode 100644 index 0000000..54ed8d0 --- /dev/null +++ b/backend/app/Modules/AI/Http/AiStudioController.php @@ -0,0 +1,283 @@ +author($request); + $items = DB::table('ai_jobs')->where('organization_id', $this->tenant->id())->where('requested_by', $request->user()->getKey())->latest()->limit(50)->get()->map(fn ($row) => $this->payload($row)); + + $subscription = $this->subscription(); + + $provider = $this->providers->forOrganization($this->tenant->id()); + + return response()->json(['data' => ['provider' => [...$this->providers->status($this->tenant->id()), 'enabled' => ! ($provider->external() && $this->deployment->mode() === DeploymentMode::OnPremise)], 'quota' => ['limit' => $subscription?->ai_credit_quota, 'used' => $subscription?->ai_credits_used ?? 0], 'items' => $items]]); + } + + public function settings(Request $request): JsonResponse + { + $this->author($request); + $row = DB::table('organization_profiles')->where('organization_id', $this->tenant->id())->first(); + $stored = json_decode($row?->settings ?: '{}', true); + + return response()->json(['data' => ['settings' => array_replace_recursive($this->aiDefaults(), $stored['ai'] ?? []), 'providerStatus' => $this->providers->status($this->tenant->id())]]); + } + + public function updateSettings(Request $request): JsonResponse + { + $this->author($request); + $data = $request->validate([ + 'settings' => ['required', 'array'], + 'settings.provider' => ['required', 'string', 'max:100'], + 'settings.courseModel' => ['required', 'string', 'max:255'], + 'settings.assistModel' => ['required', 'string', 'max:255'], + 'settings.fallbackProvider' => ['required', Rule::in(['local', 'none'])], + 'settings.fallbackConnectionId' => ['nullable', 'string', 'max:26'], + 'settings.capabilities' => ['required', 'array'], 'settings.capabilities.*' => ['boolean'], + 'settings.quality' => ['required', 'array'], 'settings.quality.*' => [], + 'settings.privacy' => ['required', 'array'], 'settings.privacy.*' => [], + 'settings.limits' => ['required', 'array'], 'settings.limits.*' => [], + 'settings.reliability' => ['required', 'array'], 'settings.reliability.*' => [], + ]); + if (filled($data['settings']['fallbackConnectionId'] ?? null)) { + abort_unless(DB::table('ai_provider_connections')->where('organization_id', $this->tenant->id())->where('id', $data['settings']['fallbackConnectionId'])->exists(), 422, 'اتصال جایگزین معتبر نیست.'); + } + $row = DB::table('organization_profiles')->where('organization_id', $this->tenant->id())->first(); + $stored = json_decode($row?->settings ?: '{}', true); + $stored['ai'] = array_replace_recursive($this->aiDefaults(), $data['settings']); + DB::table('organization_profiles')->updateOrInsert(['organization_id' => $this->tenant->id()], ['id' => $row?->id ?? (string) str()->ulid(), 'settings' => json_encode($stored), 'created_at' => $row?->created_at ?? now(), 'updated_at' => now()]); + + return $this->settings($request); + } + + public function health(Request $request): JsonResponse + { + $this->author($request); + + return response()->json(['data' => $this->providers->status($this->tenant->id())]); + } + + public function store(Request $request): JsonResponse + { + $this->author($request); + $this->assertAiAvailable(); + $data = $request->validate(['source' => ['nullable', 'file', 'max:204800', 'mimes:pdf,docx,pptx,zip'], 'sourceMode' => ['nullable', Rule::in(['topic', 'document', 'existing'])], 'topic' => ['nullable', 'string', 'max:240'], 'objective' => ['nullable', 'string', 'max:2000'], 'audience' => ['nullable', 'string', 'max:500'], 'duration' => ['nullable', 'integer', 'between:1,600'], 'difficulty' => ['nullable', Rule::in(['beginner', 'intermediate', 'advanced'])], 'tone' => ['nullable', Rule::in(['professional', 'friendly', 'formal'])], 'lessonCount' => ['nullable', 'integer', 'between:1,12'], 'assessmentLevel' => ['nullable', Rule::in(['none', 'knowledge', 'application', 'scenario'])], 'interactionDensity' => ['nullable', Rule::in(['low', 'medium', 'high'])], 'instructionalPattern' => ['nullable', Rule::in(['micro', 'scenario', 'story', 'practice'])], 'presentationMode' => ['nullable', Rule::in(['flow', 'slides'])], 'detail' => ['nullable', Rule::in(['concise', 'balanced', 'detailed'])], 'taxonomyNodeIds' => ['nullable', 'array', 'max:20'], 'taxonomyNodeIds.*' => ['string', 'distinct'], 'masteryLevel' => ['nullable', Rule::in(['awareness', 'foundation', 'applied', 'advanced', 'expert'])], 'language' => ['nullable', Rule::in(['fa', 'en'])], 'idempotencyKey' => ['nullable', 'uuid']]); + $nodeIds = $data['taxonomyNodeIds'] ?? []; + abort_if(count($nodeIds) !== DB::table('taxonomy_nodes')->where('organization_id', $this->tenant->id())->where('status', 'active')->whereIn('id', $nodeIds)->count(), 422, 'یک یا چند مهارت انتخاب‌شده معتبر نیست.'); + $aiSettings = $this->currentAiSettings(); + abort_unless((bool) ($aiSettings['capabilities']['courseGeneration'] ?? true), 403, 'ساخت دوره با هوش مصنوعی در تنظیمات غیرفعال است.'); + $provider = $this->providers->forOrganization($this->tenant->id()); + abort_if($request->hasFile('source') && $provider->external() && ! ($aiSettings['privacy']['allowExternalDocuments'] ?? false), 422, 'ارسال سند به ارائه‌دهنده خارجی در تنظیمات حریم خصوصی غیرفعال است.'); + $activeJobs = DB::table('ai_jobs')->where('organization_id', $this->tenant->id())->whereIn('status', ['queued', 'processing'])->count(); + abort_if($activeJobs >= (int) ($aiSettings['limits']['concurrentJobs'] ?? 2), 429, 'حداکثر پردازش هم‌زمان هوش مصنوعی در حال اجرا است.'); + $monthlyJobs = DB::table('ai_jobs')->where('organization_id', $this->tenant->id())->where('requested_by', $request->user()->getKey())->where('created_at', '>=', now()->startOfMonth())->count(); + abort_if($monthlyJobs >= (int) ($aiSettings['limits']['perUserMonthly'] ?? 50), 429, 'سقف ماهانه این کاربر برای هوش مصنوعی تکمیل شده است.'); + $data = [...['language' => $aiSettings['quality']['language'] ?? 'fa', 'tone' => $aiSettings['quality']['tone'] ?? 'professional', 'lessonCount' => $aiSettings['quality']['lessonCount'] ?? 5, 'modelPolicy' => $aiSettings['courseModel'] ?? 'balanced'], ...$data]; + abort_if(! $request->hasFile('source') && empty($data['topic']), 422, 'A source file or topic is required.'); + $key = $data['idempotencyKey'] ?? (string) Str::uuid(); + if ($existing = DB::table('ai_jobs')->where('organization_id', $this->tenant->id())->where('idempotency_key', $key)->first()) { + return response()->json(['data' => $this->payload($existing)]); + } + $id = (string) str()->ulid(); + DB::table('ai_jobs')->insert(['id' => $id, 'organization_id' => $this->tenant->id(), 'requested_by' => $request->user()->getKey(), 'provider' => $provider->id(), 'operation' => 'course_draft', 'status' => 'queued', 'idempotency_key' => $key, 'input' => json_encode(collect($data)->except(['source'])->all()), 'progress' => 0, 'created_at' => now(), 'updated_at' => now()]); + if ($file = $request->file('source')) { + $subscription = $this->subscription(); + $stored = (int) DB::table('assets')->where('organization_id', $this->tenant->id())->sum('size') + (int) DB::table('ai_source_documents')->where('organization_id', $this->tenant->id())->sum('size'); + abort_if($subscription?->storage_quota_bytes !== null && $stored + $file->getSize() > $subscription->storage_quota_bytes, 422, 'Organization storage quota is exceeded.'); + $extension = strtolower($file->getClientOriginalExtension()); + $path = $file->storeAs('ai-sources/'.$this->tenant->id().'/'.$id, Str::uuid().'.'.$extension, 'local'); + DB::table('ai_source_documents')->insert(['id' => (string) str()->ulid(), 'organization_id' => $this->tenant->id(), 'ai_job_id' => $id, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType() ?: 'application/octet-stream', 'size' => $file->getSize(), 'sha256' => hash_file('sha256', $file->getRealPath()), 'disk' => 'local', 'path' => $path, 'kind' => $extension === 'zip' ? 'scorm' : $extension, 'metadata' => json_encode(['schemaVersion' => 1]), 'created_at' => now(), 'updated_at' => now()]); + if ($extension === 'zip') { + $asset = new Asset(['organization_id' => $this->tenant->id(), 'uploaded_by' => $request->user()->getKey(), 'kind' => 'document', 'original_name' => $file->getClientOriginalName(), 'disk' => 'local', 'mime_type' => $file->getMimeType() ?: 'application/zip', 'size' => $file->getSize(), 'sha256' => hash_file('sha256', $file->getRealPath()), 'metadata' => ['contentType' => 'scorm', 'editable' => false, 'launchMode' => 'external_package']]); + $asset->id = (string) Str::ulid(); + $asset->path = $file->storeAs('assets/'.$this->tenant->id().'/'.$asset->id, Str::uuid().'.zip', 'local'); + $asset->save(); + } + } + ProcessAiJob::dispatch($id); + + return response()->json(['data' => $this->payload(DB::table('ai_jobs')->find($id))], 202); + } + + public function show(Request $request, string $job): JsonResponse + { + $row = $this->job($request, $job); + $suggestion = DB::table('ai_suggestions')->where('ai_job_id', $row->id)->latest()->first(); + $sources = DB::table('ai_source_documents')->where('ai_job_id', $row->id)->get()->map(fn ($document) => ['id' => $document->id, 'name' => $document->original_name, 'kind' => $document->kind, 'size' => $document->size, 'fragments' => DB::table('ai_source_fragments')->where('source_document_id', $document->id)->orderBy('position')->get(['id', 'locator', 'heading'])->map(fn ($fragment) => (array) $fragment)]); + + return response()->json(['data' => [...$this->payload($row), 'sources' => $sources, 'suggestion' => $suggestion ? ['id' => $suggestion->id, 'status' => $suggestion->status, 'payload' => json_decode($suggestion->payload, true), 'confidence' => $suggestion->confidence, 'rationale' => $suggestion->rationale] : null]]); + } + + public function cancel(Request $request, string $job): JsonResponse + { + $row = $this->job($request, $job); + abort_unless(in_array($row->status, ['queued', 'processing'], true), 409); + DB::table('ai_jobs')->where('id', $row->id)->update(['status' => 'cancelled', 'cancelled_at' => now(), 'updated_at' => now()]); + + return response()->json(['data' => ['cancelled' => true]]); + } + + public function retry(Request $request, string $job): JsonResponse + { + $row = $this->job($request, $job); + abort_unless($row->status === 'failed', 409); + DB::table('ai_jobs')->where('id', $row->id)->update(['status' => 'queued', 'progress' => 0, 'error' => null, 'started_at' => null, 'completed_at' => null, 'updated_at' => now()]); + ProcessAiJob::dispatch($row->id); + + return response()->json(['data' => ['queued' => true]]); + } + + public function assist(Request $request): JsonResponse + { + $this->author($request); + $data = $request->validate(['operation' => ['required', Rule::in(['generate_lesson', 'generate_quiz', 'rewrite', 'shorten', 'simplify', 'generate_examples', 'add_interaction', 'split_lesson', 'audit_course', 'check_objectives', 'check_assessment_alignment'])], 'content' => ['required', 'string', 'max:100000'], 'context' => ['nullable', 'array']]); + + $settings = $this->currentAiSettings(); + $context = [...($data['context'] ?? []), 'modelPolicy' => $settings['assistModel'] ?? 'fast']; + + return response()->json(['data' => $this->providers->forOrganization($this->tenant->id())->assist($data['operation'], $data['content'], $context)]); + } + + public function taxonomySuggestions(Request $request): JsonResponse + { + $this->author($request); + $data = $request->validate(['content' => ['required', 'string', 'max:50000']]); + $words = collect(preg_split('/\s+/u', mb_strtolower(strip_tags($data['content']))) ?: [])->filter(fn ($word) => mb_strlen($word) >= 3)->unique()->take(20); + $nodes = DB::table('taxonomy_nodes as n')->join('taxonomy_types as t', 't.id', '=', 'n.taxonomy_type_id')->where('n.organization_id', $this->tenant->id())->where('n.status', 'active')->whereIn('t.key', ['skill', 'skills', 'competency', 'competencies'])->get(['n.id', 'n.name', 'n.description', 't.key as kind']); + $suggestions = $nodes->map(function ($node) use ($words) { + $haystack = mb_strtolower($node->name.' '.($node->description ?? '')); + $matches = $words->filter(fn ($word) => str_contains($haystack, $word))->values(); + + return ['nodeId' => $node->id, 'name' => $node->name, 'kind' => $node->kind, 'confidence' => min(95, 45 + $matches->count() * 15), 'rationale' => $matches->isEmpty() ? 'Same-tenant taxonomy candidate; manual review required.' : 'Matched terms: '.$matches->implode(', '), 'status' => 'draft']; + })->sortByDesc('confidence')->take(8)->values(); + + return response()->json(['data' => $suggestions]); + } + + public function accept(Request $request, string $suggestion): JsonResponse + { + $this->author($request); + $row = DB::table('ai_suggestions')->where('organization_id', $this->tenant->id())->where('id', $suggestion)->where('status', 'draft')->first(); + abort_unless($row, 404); + $proposal = json_decode($row->payload, true); + $course = DB::transaction(function () use ($request, $row, $proposal): Course { + $course = Course::create(['organization_id' => $this->tenant->id(), 'title' => $proposal['title'], 'slug' => (Str::slug($proposal['title']) ?: 'ai-draft').'-'.Str::lower(Str::random(6)), 'status' => 'draft', 'created_by' => $request->user()->getKey()]); + $version = CourseVersion::create(['organization_id' => $this->tenant->id(), 'course_id' => $course->id, 'version_number' => 1, 'status' => 'draft', 'title' => $proposal['title'], 'description' => $proposal['description'] ?? null, 'settings' => [...($proposal['settings'] ?? []), 'aiProvenance' => ['jobId' => $row->ai_job_id, 'suggestionId' => $row->id, 'provider' => $proposal['providerDisclosure'] ?? null]]]); + foreach ($proposal['modules'] ?? [] as $mi => $moduleData) { + $module = CourseModule::create(['organization_id' => $this->tenant->id(), 'course_version_id' => $version->id, 'title' => $moduleData['title'], 'position' => $mi + 1]); + foreach ($moduleData['lessons'] ?? [] as $li => $lessonData) { + $lesson = Lesson::create(['organization_id' => $this->tenant->id(), 'course_version_id' => $version->id, 'course_module_id' => $module->id, 'title' => $lessonData['title'], 'position' => $li + 1, 'settings' => ['summary' => $lessonData['summary'] ?? null, 'sourceFragmentIds' => $lessonData['sourceFragmentIds'] ?? []]]); + foreach ($lessonData['blocks'] ?? [] as $bi => $blockData) { + Block::create(['organization_id' => $this->tenant->id(), 'course_version_id' => $version->id, 'lesson_id' => $lesson->id, 'type' => $blockData['type'], 'schema_version' => $blockData['schemaVersion'] ?? 1, 'data' => $blockData['data'], 'accessibility' => ['aiGenerated' => true, 'requiresHumanReview' => true], 'position' => $bi + 1]); + } + } + } + $jobInput = json_decode(DB::table('ai_jobs')->where('id', $row->ai_job_id)->value('input') ?: '{}', true); + foreach ($jobInput['taxonomyNodeIds'] ?? [] as $taxonomyNodeId) { + ContentTaxonomyMapping::query()->firstOrCreate([ + 'course_version_id' => $version->getKey(), 'mappable_type' => MappableType::Course, 'mappable_id' => $version->getKey(), 'taxonomy_node_id' => $taxonomyNodeId, 'mapping_type' => MappingType::Develops, + ], [ + 'organization_id' => $this->tenant->id(), 'mastery_level' => $jobInput['masteryLevel'] ?? 'applied', 'weight' => 1, 'source' => MappingSource::Manual, 'confirmation_status' => MappingConfirmationStatus::Confirmed, 'confirmed_by' => $request->user()->getKey(), 'confirmed_at' => now(), + ]); + } + DB::table('ai_suggestions')->where('id', $row->id)->update(['status' => 'accepted', 'entity_type' => 'course', 'entity_id' => $course->id, 'reviewed_by' => $request->user()->getKey(), 'reviewed_at' => now(), 'updated_at' => now()]); + + return $course; + }); + + return response()->json(['data' => ['courseId' => $course->id, 'status' => 'draft']], 201); + } + + public function reject(Request $request, string $suggestion): JsonResponse + { + $this->author($request); + $updated = DB::table('ai_suggestions')->where('organization_id', $this->tenant->id())->where('id', $suggestion)->where('status', 'draft')->update(['status' => 'rejected', 'reviewed_by' => $request->user()->getKey(), 'reviewed_at' => now(), 'updated_at' => now()]); + abort_unless($updated === 1, 404); + + return response()->json(['data' => ['rejected' => true]]); + } + + private function job(Request $request, string $id): object + { + $this->author($request); + $row = DB::table('ai_jobs')->where('organization_id', $this->tenant->id())->where('requested_by', $request->user()->getKey())->find($id); + abort_unless($row, 404); + + return $row; + } + + private function payload(object $row): array + { + return ['id' => $row->id, 'operation' => $row->operation, 'status' => $row->status, 'progress' => $row->progress, 'error' => $row->error, 'input' => json_decode($row->input ?: '{}', true), 'createdAt' => $row->created_at, 'completedAt' => $row->completed_at]; + } + + private function author(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } + + private function subscription(): ?Subscription + { + return Subscription::query()->where('organization_id', $this->tenant->id())->where('status', 'active')->where('starts_at', '<=', now())->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now()))->latest('starts_at')->first(); + } + + private function assertAiAvailable(): void + { + $provider = $this->providers->forOrganization($this->tenant->id()); + abort_if($provider->external() && $this->deployment->mode() === DeploymentMode::OnPremise, 403, 'External AI is disabled for On-Premise deployments.'); + $subscription = $this->subscription(); + abort_if($subscription?->ai_credit_quota !== null && $subscription->ai_credits_used >= $subscription->ai_credit_quota, 422, 'Organization AI credit quota is exhausted.'); + } + + /** @return array */ + private function aiDefaults(): array + { + return [ + 'provider' => 'local', 'courseModel' => 'balanced', 'assistModel' => 'fast', 'fallbackProvider' => 'local', 'fallbackConnectionId' => null, + 'capabilities' => ['courseGeneration' => true, 'lessonGeneration' => true, 'quizGeneration' => true, 'rewrite' => true, 'simplify' => true, 'examples' => true, 'interactions' => true, 'courseAudit' => true, 'skillMapping' => true, 'documentAnalysis' => true], + 'quality' => ['sourceGrounding' => true, 'humanApproval' => true, 'autoPublish' => false, 'language' => 'fa', 'tone' => 'professional', 'detail' => 'balanced', 'lessonCount' => 5, 'contentSafety' => true], + 'privacy' => ['allowExternalDocuments' => false, 'redactPersonalData' => true, 'retentionDays' => 30, 'logPrompts' => false, 'confirmExternal' => true], + 'limits' => ['perUserMonthly' => 50, 'concurrentJobs' => 2, 'warningPercent' => 80, 'stopAtLimit' => true], + 'reliability' => ['automaticRetry' => true, 'retryCount' => 2, 'fallbackEnabled' => true], + ]; + } + + /** @return array */ + private function currentAiSettings(): array + { + $row = DB::table('organization_profiles')->where('organization_id', $this->tenant->id())->first(); + $stored = json_decode($row?->settings ?: '{}', true); + + return array_replace_recursive($this->aiDefaults(), $stored['ai'] ?? []); + } +} diff --git a/backend/app/Modules/AI/Infrastructure/AiEndpointPolicy.php b/backend/app/Modules/AI/Infrastructure/AiEndpointPolicy.php new file mode 100644 index 0000000..0e07232 --- /dev/null +++ b/backend/app/Modules/AI/Infrastructure/AiEndpointPolicy.php @@ -0,0 +1,176 @@ +> */ + private const LOCAL_PROVIDER_PORTS = [ + 'ollama' => [11434], + 'lm_studio' => [1234], + 'vllm' => [8000], + 'localai' => [8080], + ]; + + private readonly Closure $resolver; + + /** @var list */ + private readonly array $customProviderHosts; + + /** + * @param (Closure(string): list)|null $resolver + * @param list|null $customProviderHosts + */ + public function __construct(?Closure $resolver = null, ?array $customProviderHosts = null) + { + $this->resolver = $resolver ?? fn (string $host): array => $this->resolveHost($host); + $configuredHosts = $customProviderHosts ?? config('ai.security.custom_provider_allowed_hosts', []); + $this->customProviderHosts = array_values(array_unique(array_filter(array_map( + fn (mixed $host): string => $this->normalizeHost((string) $host), + is_array($configuredHosts) ? $configuredHosts : [], + )))); + } + + public function assertAllowed(string $provider, string $mode, string $baseUrl): void + { + $endpoint = $this->endpoint($baseUrl); + + if (isset(self::LOCAL_PROVIDER_PORTS[$provider])) { + if ($mode !== 'local' || ! in_array($endpoint['port'], self::LOCAL_PROVIDER_PORTS[$provider], true)) { + throw new InvalidArgumentException('Local AI provider mode or port is not allowed.'); + } + if (! $this->isLoopbackHost($endpoint['host']) || ! in_array($endpoint['scheme'], ['http', 'https'], true)) { + throw new InvalidArgumentException('Local AI providers are restricted to loopback addresses.'); + } + + return; + } + + if ($provider === 'openai') { + if ($mode !== 'online' || $endpoint['scheme'] !== 'https' || $endpoint['host'] !== 'api.openai.com' || $endpoint['port'] !== 443) { + throw new InvalidArgumentException('OpenAI connections must use the official HTTPS endpoint.'); + } + + return; + } + + if ($provider !== 'openai_compatible' || $mode !== 'online') { + throw new InvalidArgumentException('Unknown AI provider policy.'); + } + + if ($endpoint['scheme'] !== 'https' || $endpoint['port'] !== 443 || ! in_array($endpoint['host'], $this->customProviderHosts, true)) { + throw new InvalidArgumentException('Custom online AI providers require an exact HTTPS host allowlist entry on port 443.'); + } + } + + /** @return array{allow_redirects: false, curl: array>} */ + public function requestOptions(string $provider, string $mode, string $baseUrl): array + { + $this->assertAllowed($provider, $mode, $baseUrl); + $endpoint = $this->endpoint($baseUrl); + $addresses = ($this->resolver)($endpoint['host']); + if ($addresses === []) { + throw new InvalidArgumentException('AI provider hostname could not be resolved safely.'); + } + + $local = isset(self::LOCAL_PROVIDER_PORTS[$provider]); + foreach ($addresses as $address) { + if (! filter_var($address, FILTER_VALIDATE_IP)) { + throw new InvalidArgumentException('AI provider resolved to an invalid address.'); + } + if ($local ? ! $this->isLoopbackIp($address) : ! $this->isPublicIp($address)) { + throw new InvalidArgumentException('AI provider resolved to a disallowed network address.'); + } + } + + if (! defined('CURLOPT_RESOLVE')) { + throw new InvalidArgumentException('Secure DNS pinning is unavailable in this PHP runtime.'); + } + + $address = str_contains($addresses[0], ':') ? '['.$addresses[0].']' : $addresses[0]; + + return [ + 'allow_redirects' => false, + 'curl' => [constant('CURLOPT_RESOLVE') => ["{$endpoint['host']}:{$endpoint['port']}:{$address}"]], + ]; + } + + /** @return array{scheme: string, host: string, port: int} */ + private function endpoint(string $baseUrl): array + { + if (! filter_var($baseUrl, FILTER_VALIDATE_URL)) { + throw new InvalidArgumentException('AI provider URL is invalid.'); + } + + $parts = parse_url($baseUrl); + if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) { + throw new InvalidArgumentException('AI provider URL is incomplete.'); + } + if (isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) { + throw new InvalidArgumentException('AI provider URL credentials, query strings, and fragments are not allowed.'); + } + + $scheme = strtolower($parts['scheme']); + $host = $this->normalizeHost($parts['host']); + if ($host === '' || (! filter_var($host, FILTER_VALIDATE_IP) && ! preg_match('/^[a-z0-9.-]+$/', $host))) { + throw new InvalidArgumentException('AI provider hostname is invalid.'); + } + + return [ + 'scheme' => $scheme, + 'host' => $host, + 'port' => (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80)), + ]; + } + + /** @return list */ + private function resolveHost(string $host): array + { + if (filter_var($host, FILTER_VALIDATE_IP)) { + return [$host]; + } + if ($host === 'localhost') { + return ['127.0.0.1']; + } + + $addresses = []; + foreach (dns_get_record($host, DNS_A | DNS_AAAA) ?: [] as $record) { + $address = $record['ip'] ?? $record['ipv6'] ?? null; + if (is_string($address)) { + $addresses[] = $address; + } + } + + return array_values(array_unique($addresses)); + } + + private function normalizeHost(string $host): string + { + return strtolower(rtrim(trim($host), '.')); + } + + private function isLoopbackHost(string $host): bool + { + return $host === 'localhost' || (filter_var($host, FILTER_VALIDATE_IP) && $this->isLoopbackIp($host)); + } + + private function isLoopbackIp(string $address): bool + { + if ($address === '::1') { + return true; + } + if (! filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return false; + } + + return str_starts_with($address, '127.'); + } + + private function isPublicIp(string $address): bool + { + return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false; + } +} diff --git a/backend/app/Modules/AI/Infrastructure/LocalStructuringProvider.php b/backend/app/Modules/AI/Infrastructure/LocalStructuringProvider.php new file mode 100644 index 0000000..e9a4a70 --- /dev/null +++ b/backend/app/Modules/AI/Infrastructure/LocalStructuringProvider.php @@ -0,0 +1,78 @@ + 'generated', 'locator' => null, 'heading' => $title, 'content' => (string) ($options['objective'] ?? $topic)]]; + } + $lessons = array_map(function (array $fragment, int $index) use ($language): array { + $heading = trim((string) ($fragment['heading'] ?? '')); + $content = trim($fragment['content']); + + return [ + 'title' => $heading !== '' ? Str::limit($heading, 150, '') : ($language === 'en' ? 'Lesson '.($index + 1) : 'درس '.($index + 1)), + 'summary' => Str::limit($content, 500), + 'sourceFragmentIds' => [$fragment['id']], + 'blocks' => $content === '' ? [] : [['type' => 'text', 'schemaVersion' => 1, 'data' => ['html' => '

'.nl2br(e($content)).'

']]], + ]; + }, $selected, array_keys($selected)); + + return [ + 'schemaVersion' => 1, + 'providerDisclosure' => 'Deterministic local structuring; no external generative model was used.', + 'title' => $title, + 'description' => (string) ($options['objective'] ?? ($language === 'en' ? 'Draft generated from the supplied source for Designer review.' : 'پیش‌نویس تولیدشده از منبع ورودی برای بازبینی طراح.')), + 'settings' => collect($options)->only(['audience', 'objective', 'duration', 'difficulty', 'language', 'tone', 'assessmentLevel', 'interactionDensity'])->all(), + 'modules' => [['title' => $language === 'en' ? 'Core content' : 'محتوای اصلی', 'lessons' => $lessons]], + ]; + } + + public function assist(string $operation, string $content, array $context): array + { + $plain = trim(preg_replace('/\s+/u', ' ', strip_tags($content)) ?? ''); + $result = match ($operation) { + 'generate_lesson' => ['title' => Str::limit($plain, 80), 'outline' => ['مقدمه', 'نکات کلیدی', 'تمرین کاربردی'], 'draft' => $plain], + 'generate_quiz' => ['prompt' => 'کدام گزینه با محتوای درس سازگار است؟', 'options' => [Str::limit($plain, 120), 'گزینه نیازمند بازبینی طراح'], 'answerIndex' => 0], + 'rewrite' => $plain, + 'shorten' => Str::limit($plain, max(120, (int) floor(mb_strlen($plain) * 0.6))), + 'simplify' => preg_replace('/[؛:]/u', '.', $plain) ?? $plain, + 'split_lesson' => collect(preg_split('/(?<=[.!؟])\s+/u', $plain) ?: [])->chunk(3)->map(fn ($chunk) => $chunk->implode(' '))->values()->all(), + 'generate_examples' => ['اصل یا مفهوم: '.Str::limit($plain, 180), 'مثال کاربردی باید توسط طراح با زمینه سازمان تکمیل شود.'], + 'add_interaction' => ['type' => 'flashcard', 'front' => Str::limit($plain, 180), 'back' => 'پاسخ باید توسط طراح تأیید شود.'], + 'audit_course', 'check_objectives', 'check_assessment_alignment' => ['summary' => 'Local structural audit completed.', 'issues' => $plain === '' ? ['Content is empty.'] : [], 'requiresDesignerReview' => true], + default => $plain, + }; + + return ['schemaVersion' => 1, 'operation' => $operation, 'proposal' => $result, 'context' => collect($context)->only(['entityType', 'entityId'])->all(), 'providerDisclosure' => 'Local deterministic assistant; review before accepting.']; + } +} diff --git a/backend/app/Modules/AI/Infrastructure/OpenAiCompatibleProvider.php b/backend/app/Modules/AI/Infrastructure/OpenAiCompatibleProvider.php new file mode 100644 index 0000000..90de19d --- /dev/null +++ b/backend/app/Modules/AI/Infrastructure/OpenAiCompatibleProvider.php @@ -0,0 +1,161 @@ +connection['id']) ? 'connection:'.$this->connection['id'] : 'openai'; + } + + public function external(): bool + { + return ($this->connection['mode'] ?? 'online') === 'online'; + } + + public function configured(): bool + { + if ($this->connection) { + return filled($this->connection['default_model'] ?? null) && (! $this->external() || filled($this->apiKey())); + } + + return filled($this->baseUrl()) && filled($this->apiKey()) && filled(config('ai.openai.models.balanced')); + } + + public function chat(string $prompt, ?string $systemPrompt = null): string + { + if (! $this->configured()) { + throw new RuntimeException('ارائه‌دهنده انتخاب‌شده هنوز تنظیمات معتبر ندارد.'); + } + + $messages = []; + if (filled($systemPrompt)) { + $messages[] = ['role' => 'system', 'content' => $systemPrompt]; + } + $messages[] = ['role' => 'user', 'content' => $prompt]; + $response = $this->client($this->timeout())->asJson()->retry(2, 500, throw: false)->post($this->baseUrl().'/chat/completions', [ + 'model' => $this->model('balanced'), + 'messages' => $messages, + ]); + if (! $response->successful()) { + throw new RuntimeException('AI provider request failed with status '.$response->status().'.'); + } + + $answer = trim((string) $response->json('choices.0.message.content')); + if ($answer === '') { + throw new RuntimeException('AI provider returned an empty response.'); + } + + return $answer; + } + + public function health(): array + { + if (! $this->configured()) { + return ['connected' => false, 'message' => 'کلید دسترسی و مدل پیش‌فرض اتصال را تکمیل کنید.']; + } + try { + $started = microtime(true); + $response = $this->client(15)->get($this->baseUrl().'/models'); + + return ['connected' => $response->successful(), 'latencyMs' => (int) round((microtime(true) - $started) * 1000), 'message' => $response->successful() ? 'اتصال برقرار است.' : 'ارائه‌دهنده پاسخ معتبر نداد.']; + } catch (\Throwable) { + return ['connected' => false, 'message' => 'اتصال با ارائه‌دهنده برقرار نشد.']; + } + } + + public function structure(array $fragments, array $options): array + { + $source = collect($fragments)->map(fn (array $item) => ['id' => $item['id'], 'heading' => $item['heading'], 'content' => mb_substr($item['content'], 0, 12000)])->all(); + $instruction = 'Create a source-grounded microlearning course draft. Return JSON only with: schemaVersion, providerDisclosure, title, description, settings, modules[].title, modules[].lessons[].title, summary, sourceFragmentIds, blocks[].type, schemaVersion, data. Use only text blocks with data.html. Preserve supplied fragment IDs. Never publish automatically.'; + $payload = $this->complete($instruction, ['source' => $source, 'options' => $options], (string) ($options['modelPolicy'] ?? 'balanced')); + abort_unless(isset($payload['title'], $payload['modules']) && is_array($payload['modules']), 502, 'پاسخ هوش مصنوعی ساختار معتبر دوره را نداشت.'); + $payload['schemaVersion'] = 1; + $payload['providerDisclosure'] = 'Generated by the configured OpenAI-compatible provider; human review is required.'; + + return $payload; + } + + public function assist(string $operation, string $content, array $context): array + { + $modelPolicy = (string) ($context['modelPolicy'] ?? 'fast'); + unset($context['modelPolicy']); + + return ['schemaVersion' => 1, 'operation' => $operation, 'proposal' => $this->complete('Perform the requested course-authoring operation. Return JSON only. Do not invent facts not present in the content.', ['operation' => $operation, 'content' => $content, 'context' => $context], $modelPolicy), 'providerDisclosure' => 'OpenAI-compatible provider; review before accepting.']; + } + + /** @return array */ + private function complete(string $system, array $input, string $modelPolicy): array + { + if (! $this->configured()) { + throw new RuntimeException('ارائه‌دهنده انتخاب‌شده هنوز کلید دسترسی معتبر ندارد.'); + } + $response = $this->client($this->timeout())->asJson()->retry(2, 500, throw: false)->post($this->baseUrl().'/chat/completions', [ + 'model' => $this->model($modelPolicy), 'temperature' => 0.2, 'response_format' => ['type' => 'json_object'], + 'messages' => [['role' => 'system', 'content' => $system], ['role' => 'user', 'content' => json_encode($input, JSON_UNESCAPED_UNICODE)]], + ]); + if (! $response->successful()) { + throw new RuntimeException('AI provider request failed with status '.$response->status().'.'); + } + $content = (string) $response->json('choices.0.message.content'); + $decoded = json_decode($content, true); + if (! is_array($decoded)) { + throw new RuntimeException('AI provider returned invalid JSON.'); + } + + return $decoded; + } + + private function client(int $timeout): PendingRequest + { + $client = Http::acceptJson() + ->timeout($timeout) + ->withOptions($this->endpointPolicy->requestOptions( + (string) ($this->connection['provider'] ?? 'openai'), + (string) ($this->connection['mode'] ?? 'online'), + $this->baseUrl(), + )); + if (filled($this->apiKey())) { + $client = $client->withToken($this->apiKey()); + } + + return $client; + } + + private function baseUrl(): string + { + return rtrim((string) ($this->connection['base_url'] ?? config('ai.openai.base_url')), '/'); + } + + private function apiKey(): string + { + return (string) ($this->connection['api_key'] ?? config('ai.openai.api_key')); + } + + private function model(string $policy): string + { + if ($this->connection) { + return in_array($policy, ['fast', 'balanced', 'advanced'], true) + ? (string) ($this->connection['default_model'] ?? '') + : $policy; + } + + return (string) config("ai.openai.models.{$policy}", config('ai.openai.models.balanced')); + } + + private function timeout(): int + { + return (int) ($this->connection['timeout_seconds'] ?? config('ai.openai.timeout', 90)); + } +} diff --git a/backend/app/Modules/Analytics/Application/AnalyticsProjectionService.php b/backend/app/Modules/Analytics/Application/AnalyticsProjectionService.php new file mode 100644 index 0000000..1276cec --- /dev/null +++ b/backend/app/Modules/Analytics/Application/AnalyticsProjectionService.php @@ -0,0 +1,78 @@ +insertOrIgnore([ + 'id' => (string) str()->ulid(), + 'organization_id' => $event->organization_id, + 'learning_event_id' => $event->getKey(), + 'processor' => self::PROCESSOR, + 'processor_version' => self::VERSION, + 'processed_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + if ($claimed === 0) { + return false; + } + + $this->refreshDate($event->organization_id, $event->occurred_at->toDateString()); + + return true; + }); + } + + public function rebuild(string $organizationId): int + { + DB::table('analytics_event_projections')->where('organization_id', $organizationId)->delete(); + DB::table('analytics_daily_metrics')->where('organization_id', $organizationId)->delete(); + $count = 0; + LearningEvent::query()->where('organization_id', $organizationId)->orderBy('occurred_at')->orderBy('id')->each(function (LearningEvent $event) use (&$count): void { + if ($this->process($event)) { + $count++; + } + }); + + return $count; + } + + private function refreshDate(string $organizationId, string $date): void + { + $events = LearningEvent::query()->where('organization_id', $organizationId)->whereDate('occurred_at', $date)->get(); + $durations = $events->sum(fn (LearningEvent $event) => max(0, min(21600, (int) ($event->payload['durationSeconds'] ?? 0)))); + $assessmentScores = $events->where('event_type', 'assessment.completed')->map(fn (LearningEvent $event) => $event->payload['score'] ?? null)->filter(fn ($score) => is_numeric($score)); + $metrics = [ + 'events' => $events->count(), + 'activeLearners' => $events->pluck('learner_id')->unique()->count(), + 'sessions' => $events->pluck('session_id')->filter()->unique()->count(), + 'learningMinutes' => round($durations / 60, 1), + 'coursesOpened' => $events->where('event_type', 'course.opened')->count(), + 'coursesCompleted' => $events->where('event_type', 'course.completed')->count(), + 'lessonsStarted' => $events->where('event_type', 'lesson.started')->count(), + 'lessonsCompleted' => $events->where('event_type', 'lesson.completed')->count(), + 'blocksViewed' => $events->where('event_type', 'block.viewed')->count(), + 'blocksInteracted' => $events->whereIn('event_type', ['block.interacted', 'block.completed'])->count(), + 'videoStarted' => $events->where('event_type', 'video.started')->count(), + 'videoCompleted' => $events->where('event_type', 'video.completed')->count(), + 'assessmentsCompleted' => $assessmentScores->count(), + 'assessmentAverage' => $assessmentScores->isEmpty() ? null : round((float) $assessmentScores->average(), 1), + ]; + + DB::table('analytics_daily_metrics')->updateOrInsert( + ['organization_id' => $organizationId, 'metric_date' => $date, 'scope_type' => 'organization', 'scope_id' => $organizationId], + ['id' => (string) str()->ulid(), 'metrics' => json_encode($metrics, JSON_THROW_ON_ERROR), 'calculated_at' => now(), 'created_at' => now(), 'updated_at' => now()], + ); + } +} diff --git a/backend/app/Modules/Analytics/Application/CourseAnalyticsDashboard.php b/backend/app/Modules/Analytics/Application/CourseAnalyticsDashboard.php new file mode 100644 index 0000000..504c03f --- /dev/null +++ b/backend/app/Modules/Analytics/Application/CourseAnalyticsDashboard.php @@ -0,0 +1,210 @@ + 'تعداد یادگیرندگان یکتایی که تا پایان بازه، نسخه انتخابی به آن‌ها تخصیص یافته است.', + 'starts' => 'تعداد یادگیرندگان تخصیص‌یافته‌ای که تا پایان بازه حداقل یک رویداد یادگیری معتبر در این نسخه ثبت کرده‌اند.', + 'completions' => 'تعداد یادگیرندگان یکتایی که طبق قوانین Completion نسخه، دوره را تا پایان بازه تکمیل کرده‌اند.', + 'averageProgress' => 'میانگین درصد پیشرفت جاری Assignmentهای یکتای نسخه. به‌دلیل نبود Snapshot تاریخی، تغییر دوره‌ای آن محاسبه نمی‌شود.', + 'averageScore' => 'میانگین نمره تلاش‌های تکمیل‌شده ارزیابی در بازه، پس از تبدیل نمره صفر تا یک به درصد.', + 'dropOff' => 'درصد شروع‌کنندگان یکتایی که تا پایان بازه دوره را تکمیل نکرده‌اند.', + 'lessonViews' => 'تعداد رویدادهای مشاهده درس یا بلوک‌های آن در بازه انتخابی.', + 'lessonTime' => 'مجموع زمان معتبر رویدادهای درس تقسیم بر تعداد یادگیرندگان فعال همان درس.', + 'lessonCompletion' => 'نسبت یادگیرندگان یکتای تکمیل‌کننده درس به یادگیرندگان یکتای فعال در آن درس.', + 'lessonScore' => 'میانگین امتیاز ثبت‌شده در رویدادهای ارزیابی همان درس؛ نبود امتیاز با «—» نمایش داده می‌شود.', + ]; + + /** @return array */ + public function build(CourseVersion $version, CarbonImmutable $from, CarbonImmutable $to, ?string $teamId, string $sort = 'position', string $direction = 'asc', int $page = 1, int $pageSize = 10): array + { + $previousTo = $from->subSecond(); + $days = max(1, $from->diffInDays($to) + 1); + $previousFrom = $previousTo->subDays($days - 1)->startOfDay(); + $baseAssignments = DB::table('assignment_users as au') + ->join('assignments as a', 'a.id', '=', 'au.assignment_id') + ->where('a.organization_id', $version->organization_id) + ->where('a.assignable_type', 'course') + ->where('a.assignable_id', $version->getKey()) + ->get(['au.user_id as userId', 'au.status', 'au.progress', 'au.assigned_at as assignedAt', 'au.completed_at as completedAt']); + if ($teamId) { + $teamUsers = DB::table('team_memberships')->where('team_id', $teamId)->pluck('user_id'); + $baseAssignments = $baseAssignments->whereIn('userId', $teamUsers); + } + $assignments = $baseAssignments->filter(fn ($row) => CarbonImmutable::parse($row->assignedAt)->lte($to))->values(); + $learnerIds = $assignments->pluck('userId')->unique()->values(); + $allEvents = $learnerIds->isEmpty() ? collect() : LearningEvent::query() + ->where('organization_id', $version->organization_id) + ->where('course_version_id', $version->getKey()) + ->whereIn('learner_id', $learnerIds) + ->where('occurred_at', '<=', $to) + ->orderBy('occurred_at') + ->get(); + $rangeEvents = $allEvents->filter(fn (LearningEvent $event) => $event->occurred_at->betweenIncluded($from, $to)); + $firstActivity = $allEvents->groupBy('learner_id')->map(fn (Collection $rows) => $rows->min('occurred_at')); + $completionDates = $assignments->groupBy('userId')->map(fn (Collection $rows) => $rows->pluck('completedAt')->filter()->min()); + + $current = $this->funnelAt($assignments, $firstActivity, $completionDates, $to); + $previous = $this->funnelAt($baseAssignments, $firstActivity, $completionDates, $previousTo); + $attempts = $learnerIds->isEmpty() ? collect() : DB::table('assessment_attempts') + ->where('organization_id', $version->organization_id) + ->where('course_version_id', $version->getKey()) + ->where('status', 'completed') + ->whereIn('learner_id', $learnerIds) + ->whereBetween('completed_at', [$previousFrom, $to]) + ->get(['learner_id', 'score', 'completed_at']); + $currentScores = $attempts->filter(fn ($row) => CarbonImmutable::parse($row->completed_at)->betweenIncluded($from, $to)); + $previousScores = $attempts->filter(fn ($row) => CarbonImmutable::parse($row->completed_at)->betweenIncluded($previousFrom, $previousTo)); + $averageScore = $currentScores->isEmpty() ? null : round((float) $currentScores->avg('score') * 100, 1); + $previousScore = $previousScores->isEmpty() ? null : round((float) $previousScores->avg('score') * 100, 1); + $averageProgress = $assignments->isEmpty() ? null : round((float) $assignments->groupBy('userId')->map(fn (Collection $rows) => $rows->max('progress'))->avg(), 1); + $trend = $this->trend($from, $to, $assignments, $firstActivity, $completionDates, $attempts); + + $metricSeries = [ + 'learners' => array_column($trend, 'assigned'), + 'starts' => array_column($trend, 'starts'), + 'completions' => array_column($trend, 'completions'), + 'averageProgress' => [], + 'averageScore' => array_column($trend, 'averageScore'), + 'dropOff' => array_column($trend, 'dropOff'), + ]; + $metrics = [ + 'learners' => $this->metric($current['assigned'], $previous['assigned'], 'نفر', $metricSeries['learners']), + 'starts' => $this->metric($current['started'], $previous['started'], 'نفر', $metricSeries['starts']), + 'completions' => $this->metric($current['completed'], $previous['completed'], 'نفر', $metricSeries['completions']), + 'averageProgress' => ['value' => $averageProgress, 'unit' => 'درصد', 'delta' => null, 'comparisonAvailable' => false, 'series' => []], + 'averageScore' => $this->metric($averageScore, $previousScore, 'از ۱۰۰', $metricSeries['averageScore']), + 'dropOff' => $this->metric($current['dropOff'], $previous['dropOff'], 'درصد', $metricSeries['dropOff']), + ]; + + $content = $this->content($version, $rangeEvents); + $content = $this->sortContent($content, $sort, $direction); + $contentTotal = $content->count(); + $lastPage = max(1, (int) ceil($contentTotal / $pageSize)); + $page = min($page, $lastPage); + $contentPage = $content->forPage($page, $pageSize)->values(); + $teamPerformance = $this->teamPerformance($version, $to); + $insights = $this->insights($version, $metrics, $content, $teamPerformance); + + return [ + 'range' => ['from' => $from->toDateString(), 'to' => $to->toDateString(), 'previousFrom' => $previousFrom->toDateString(), 'previousTo' => $previousTo->toDateString()], + 'definitions' => self::DEFINITIONS, + 'metrics' => $metrics, + 'funnel' => [ + ['key' => 'assigned', 'label' => 'تخصیص‌یافته', 'value' => $current['assigned'], 'percentage' => $current['assigned'] > 0 ? 100 : null, 'conversionFromPrevious' => null], + ['key' => 'started', 'label' => 'شروع‌کرده', 'value' => $current['started'], 'percentage' => $current['assigned'] > 0 ? round($current['started'] / $current['assigned'] * 100, 1) : null, 'conversionFromPrevious' => $current['assigned'] > 0 ? round($current['started'] / $current['assigned'] * 100, 1) : null], + ['key' => 'completed', 'label' => 'تکمیل‌کرده', 'value' => $current['completed'], 'percentage' => $current['assigned'] > 0 ? round($current['completed'] / $current['assigned'] * 100, 1) : null, 'conversionFromPrevious' => $current['started'] > 0 ? round($current['completed'] / $current['started'] * 100, 1) : null], + ], + 'trend' => $trend, + 'content' => ['items' => $contentPage->all(), 'meta' => ['page' => $page, 'pageSize' => $pageSize, 'total' => $contentTotal, 'lastPage' => $lastPage]], + 'teams' => $teamPerformance, + 'insights' => $insights, + 'hasEvents' => $allEvents->isNotEmpty(), + ]; + } + + /** @return array{assigned: int, started: int, completed: int, dropOff: float|null} */ + private function funnelAt(Collection $assignments, Collection $firstActivity, Collection $completionDates, CarbonImmutable $at): array + { + $assigned = $assignments->filter(fn ($row) => CarbonImmutable::parse($row->assignedAt)->lte($at))->pluck('userId')->unique(); + $started = $assigned->filter(fn ($id) => ($date = $firstActivity->get($id)) && CarbonImmutable::parse($date)->lte($at)); + $completed = $assigned->filter(fn ($id) => ($date = $completionDates->get($id)) && CarbonImmutable::parse($date)->lte($at)); + + return ['assigned' => $assigned->count(), 'started' => $started->count(), 'completed' => $completed->count(), 'dropOff' => $started->isEmpty() ? null : round((1 - min($started->count(), $completed->count()) / $started->count()) * 100, 1)]; + } + + /** @return array */ + private function metric(int|float|null $current, int|float|null $previous, string $unit, array $series): array + { + $comparable = $current !== null && $previous !== null && (float) $previous !== 0.0; + + return ['value' => $current, 'unit' => $unit, 'delta' => $comparable ? round(((float) $current - (float) $previous) / abs((float) $previous) * 100, 1) : null, 'comparisonAvailable' => $comparable, 'series' => $series]; + } + + private function trend(CarbonImmutable $from, CarbonImmutable $to, Collection $assignments, Collection $firstActivity, Collection $completionDates, Collection $attempts): array + { + return collect(range(0, $from->diffInDays($to)))->map(function (int $offset) use ($from, $assignments, $firstActivity, $completionDates, $attempts) { + $date = $from->addDays($offset)->endOfDay(); + $funnel = $this->funnelAt($assignments, $firstActivity, $completionDates, $date); + $scores = $attempts->filter(fn ($row) => CarbonImmutable::parse($row->completed_at)->betweenIncluded($from, $date)); + + return ['date' => $date->toDateString(), 'assigned' => $funnel['assigned'], 'starts' => $funnel['started'], 'completions' => $funnel['completed'], 'dropOff' => $funnel['dropOff'], 'averageScore' => $scores->isEmpty() ? null : round((float) $scores->avg('score') * 100, 1)]; + })->all(); + } + + private function content(CourseVersion $version, Collection $events): Collection + { + $lessons = DB::table('lessons as l')->join('course_modules as m', 'm.id', '=', 'l.course_module_id') + ->where('l.course_version_id', $version->getKey())->orderBy('m.position')->orderBy('l.position') + ->get(['l.id', 'l.title', 'l.position', 'm.title as moduleTitle']); + + return $lessons->map(function ($lesson) use ($events, $version) { + $rows = $events->where('lesson_id', $lesson->id); + $activeLearners = $rows->pluck('learner_id')->unique(); + $views = $rows->whereIn('event_type', ['lesson.opened', 'block.viewed'])->count(); + $completed = $rows->where('event_type', 'lesson.completed')->pluck('learner_id')->unique()->count(); + $duration = $rows->sum(fn (LearningEvent $event) => min(21600, max(0, (int) ($event->payload['durationSeconds'] ?? 0)))); + $scores = $rows->map(fn (LearningEvent $event) => $event->payload['score'] ?? null)->filter(fn ($score) => is_numeric($score)); + $completionRate = $activeLearners->isEmpty() ? null : round($completed / $activeLearners->count() * 100, 1); + $status = $completionRate === null ? 'no_data' : ($completionRate < 40 ? 'critical' : ($completionRate < 65 ? 'needs_review' : 'good')); + + return ['id' => $lesson->id, 'title' => $lesson->title, 'moduleTitle' => $lesson->moduleTitle, 'position' => (int) $lesson->position, 'views' => $views, 'averageTimeMinutes' => $activeLearners->isEmpty() ? null : round($duration / $activeLearners->count() / 60, 1), 'completionRate' => $completionRate, 'assessmentScore' => $scores->isEmpty() ? null : round((float) $scores->avg() * 100, 1), 'status' => $status, 'drilldown' => '/app/courses/'.$version->course_id.'/versions/'.$version->getKey().'/lessons/'.$lesson->id.'/builder']; + }); + } + + private function sortContent(Collection $content, string $sort, string $direction): Collection + { + $key = match ($sort) { + 'title' => 'title', 'views' => 'views', 'time' => 'averageTimeMinutes', 'completion' => 'completionRate', 'score' => 'assessmentScore', 'status' => 'status', default => 'position', + }; + + return ($direction === 'desc' ? $content->sortByDesc($key) : $content->sortBy($key))->values(); + } + + private function teamPerformance(CourseVersion $version, CarbonImmutable $to): array + { + $rows = DB::table('teams as t')->leftJoin('team_memberships as tm', 'tm.team_id', '=', 't.id') + ->where('t.organization_id', $version->organization_id)->orderBy('t.name')->get(['t.id', 't.name', 'tm.user_id']); + $assignments = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id') + ->where('a.organization_id', $version->organization_id)->where('a.assignable_type', 'course')->where('a.assignable_id', $version->getKey()) + ->where('au.assigned_at', '<=', $to)->get(['au.user_id as userId', 'au.progress', 'au.completed_at as completedAt']); + + return $rows->groupBy('id')->map(function (Collection $members) use ($assignments, $to) { + $memberIds = $members->pluck('user_id')->filter()->unique(); + $teamAssignments = $assignments->whereIn('userId', $memberIds)->groupBy('userId'); + $completed = $teamAssignments->filter(fn (Collection $items) => $items->contains(fn ($row) => $row->completedAt && CarbonImmutable::parse($row->completedAt)->lte($to)))->count(); + $count = $teamAssignments->count(); + + return ['id' => $members->first()->id, 'name' => $members->first()->name, 'learners' => $count, 'completionRate' => $count > 0 ? round($completed / $count * 100, 1) : null, 'averageProgress' => $count > 0 ? round((float) $teamAssignments->map(fn (Collection $items) => $items->max('progress'))->avg(), 1) : null]; + })->values()->all(); + } + + private function insights(CourseVersion $version, array $metrics, Collection $content, array $teams): array + { + $items = []; + $weakest = $content->whereNotNull('completionRate')->sortBy('completionRate')->first(); + if ($weakest && $weakest['completionRate'] < 65) { + $items[] = ['id' => 'weak-lesson-'.$weakest['id'], 'title' => 'درس «'.$weakest['title'].'» نیازمند بررسی است', 'description' => 'نرخ تکمیل این درس '.$weakest['completionRate'].'٪ است و از آستانه ۶۵٪ پایین‌تر قرار دارد.', 'severity' => $weakest['completionRate'] < 40 ? 'high' : 'medium', 'actionLabel' => 'بررسی درس', 'actionUrl' => $weakest['drilldown']]; + } + $weakTeam = collect($teams)->whereNotNull('completionRate')->sortBy('completionRate')->first(); + if ($weakTeam && $weakTeam['completionRate'] < 60) { + $items[] = ['id' => 'weak-team-'.$weakTeam['id'], 'title' => 'تیم «'.$weakTeam['name'].'» به مداخله نیاز دارد', 'description' => 'نرخ تکمیل این تیم '.$weakTeam['completionRate'].'٪ و میانگین پیشرفت آن '.($weakTeam['averageProgress'] ?? 0).'٪ است.', 'severity' => $weakTeam['completionRate'] < 35 ? 'high' : 'medium', 'actionLabel' => 'مشاهده تیم', 'actionUrl' => '/app/courses/'.$version->course_id.'?tab=learners&team='.$weakTeam['id']]; + } + if ($metrics['averageScore']['value'] !== null && $metrics['averageScore']['value'] < 70) { + $items[] = ['id' => 'assessment-score', 'title' => 'میانگین امتیاز ارزیابی پایین است', 'description' => 'میانگین امتیاز ثبت‌شده '.$metrics['averageScore']['value'].' از ۱۰۰ است؛ پاسخ‌ها و سؤال‌های دشوار را بررسی کنید.', 'severity' => $metrics['averageScore']['value'] < 50 ? 'high' : 'medium', 'actionLabel' => 'مشاهده ارزیابی', 'actionUrl' => '/app/assessments?course='.$version->getKey()]; + } + if ($metrics['dropOff']['value'] !== null && $metrics['dropOff']['value'] > 30) { + $items[] = ['id' => 'drop-off', 'title' => 'نرخ ریزش دوره بالاست', 'description' => $metrics['dropOff']['value'].'٪ از شروع‌کنندگان هنوز دوره را تکمیل نکرده‌اند.', 'severity' => $metrics['dropOff']['value'] > 50 ? 'high' : 'medium', 'actionLabel' => 'مقایسه نسخه‌ها', 'actionUrl' => '/app/courses/'.$version->course_id.'?tab=versions']; + } + + return $items; + } +} diff --git a/backend/app/Modules/Analytics/Http/CourseAnalyticsController.php b/backend/app/Modules/Analytics/Http/CourseAnalyticsController.php new file mode 100644 index 0000000..ebbe16f --- /dev/null +++ b/backend/app/Modules/Analytics/Http/CourseAnalyticsController.php @@ -0,0 +1,113 @@ +authorizeAnalytics($request, $permissions); + $filters = $this->filters($request); + $courseModel = Course::query()->where('organization_id', $user->organization_id)->findOrFail($course); + $version = $this->version($courseModel->getKey(), $user->organization_id, $filters['version'] ?? null); + $team = $this->team($user->organization_id, $filters['team'] ?? null); + [$from, $to] = $this->range($filters); + $data = $dashboard->build($version, $from, $to, $team, $filters['sort'] ?? 'position', $filters['direction'] ?? 'asc', (int) ($filters['page'] ?? 1), (int) ($filters['pageSize'] ?? 10)); + $data['course'] = ['id' => $courseModel->getKey(), 'title' => $courseModel->title, 'versionId' => $version->getKey(), 'version' => $version->version_number, 'publishedAt' => $version->published_at?->toISOString()]; + $data['filters'] = [ + 'versions' => CourseVersion::query()->where('organization_id', $user->organization_id)->where('course_id', $courseModel->getKey())->orderByDesc('version_number')->get(['id', 'version_number', 'published_at'])->map(fn (CourseVersion $item) => ['id' => $item->getKey(), 'version' => $item->version_number, 'publishedAt' => $item->published_at?->toISOString()]), + 'teams' => DB::table('teams')->where('organization_id', $user->organization_id)->orderBy('name')->get(['id', 'name']), + ]; + + return response()->json(['data' => $data]); + } + + public function report(Request $request, string $course, RolePermissions $permissions, CourseAnalyticsDashboard $dashboard): StreamedResponse + { + $user = $this->authorizeAnalytics($request, $permissions); + $filters = $this->filters($request); + $courseModel = Course::query()->where('organization_id', $user->organization_id)->findOrFail($course); + $version = $this->version($courseModel->getKey(), $user->organization_id, $filters['version'] ?? null); + $team = $this->team($user->organization_id, $filters['team'] ?? null); + [$from, $to] = $this->range($filters); + $data = $dashboard->build($version, $from, $to, $team, $filters['sort'] ?? 'position', $filters['direction'] ?? 'asc', 1, 5000); + $filename = (preg_replace('/[^\pL\pN_-]+/u', '-', $courseModel->title) ?: 'course').'-تحلیل-نسخه-'.$version->version_number.'.csv'; + + return response()->streamDownload(function () use ($data): void { + $stream = fopen('php://output', 'wb'); + if (! $stream) { + return; + } + fwrite($stream, "\xEF\xBB\xBF"); + fputcsv($stream, ['شاخص', 'مقدار', 'واحد', 'تعریف']); + $labels = ['learners' => 'یادگیرندگان', 'starts' => 'شروع دوره', 'completions' => 'تکمیل', 'averageProgress' => 'میانگین پیشرفت', 'averageScore' => 'میانگین امتیاز', 'dropOff' => 'نرخ ریزش']; + foreach ($data['metrics'] as $key => $metric) { + fputcsv($stream, [$labels[$key] ?? $key, $metric['value'] ?? '—', $metric['unit'], $data['definitions'][$key]]); + } + fputcsv($stream, []); + fputcsv($stream, ['درس', 'ماژول', 'بازدید', 'میانگین زمان (دقیقه)', 'نرخ تکمیل', 'امتیاز ارزیابی', 'وضعیت']); + foreach ($data['content']['items'] as $lesson) { + fputcsv($stream, [$lesson['title'], $lesson['moduleTitle'], $lesson['views'], $lesson['averageTimeMinutes'] ?? '—', $lesson['completionRate'] ?? '—', $lesson['assessmentScore'] ?? '—', $lesson['status']]); + } + fclose($stream); + }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + private function authorizeAnalytics(Request $request, RolePermissions $permissions): User + { + $user = $request->user(); + abort_unless($user instanceof User && $permissions->allows($user, Permission::OrganizationAnalyticsView), 403); + + return $user; + } + + /** @return array */ + private function filters(Request $request): array + { + return $request->validate([ + 'from' => ['nullable', 'date'], 'to' => ['nullable', 'date', 'after_or_equal:from'], + 'version' => ['nullable', 'string'], 'team' => ['nullable', 'string'], + 'sort' => ['nullable', 'in:position,title,views,time,completion,score,status'], 'direction' => ['nullable', 'in:asc,desc'], + 'page' => ['nullable', 'integer', 'min:1'], 'pageSize' => ['nullable', 'integer', 'in:5,10,25,50'], + ]); + } + + /** @param array $filters @return array{CarbonImmutable, CarbonImmutable} */ + private function range(array $filters): array + { + $from = CarbonImmutable::parse($filters['from'] ?? now()->subDays(29)->toDateString())->startOfDay(); + $to = CarbonImmutable::parse($filters['to'] ?? now()->toDateString())->endOfDay(); + abort_if($from->diffInDays($to) > 366, 422, 'Date range cannot exceed 366 days.'); + + return [$from, $to]; + } + + private function version(string $courseId, string $organizationId, ?string $versionId): CourseVersion + { + $query = CourseVersion::query()->where('organization_id', $organizationId)->where('course_id', $courseId); + + return $versionId ? $query->findOrFail($versionId) : $query->orderByDesc('version_number')->firstOrFail(); + } + + private function team(string $organizationId, ?string $teamId): ?string + { + if ($teamId) { + abort_unless(DB::table('teams')->where('organization_id', $organizationId)->where('id', $teamId)->exists(), 404); + } + + return $teamId; + } +} diff --git a/backend/app/Modules/Analytics/Jobs/ProcessLearningEvent.php b/backend/app/Modules/Analytics/Jobs/ProcessLearningEvent.php new file mode 100644 index 0000000..58abfb1 --- /dev/null +++ b/backend/app/Modules/Analytics/Jobs/ProcessLearningEvent.php @@ -0,0 +1,29 @@ +onQueue('analytics'); + } + + public function handle(AnalyticsProjectionService $analytics, MonitoringEngine $monitoring): void + { + $event = LearningEvent::query()->findOrFail($this->eventId); + if ($analytics->process($event)) { + $monitoring->refresh($event->organization_id); + } + } +} diff --git a/backend/app/Modules/Assessments/Application/QuestionSchema.php b/backend/app/Modules/Assessments/Application/QuestionSchema.php new file mode 100644 index 0000000..59cebe6 --- /dev/null +++ b/backend/app/Modules/Assessments/Application/QuestionSchema.php @@ -0,0 +1,117 @@ + */ + public function types(): array + { + return ['single_choice', 'multiple_choice', 'true_false', 'matching', 'sorting', 'drag_drop', 'hotspot', 'scenario', 'branching_scenario']; + } + + /** @param array $configuration @return array */ + public function validate(string $type, array $configuration): array + { + Validator::make(['type' => $type], ['type' => ['required', Rule::in($this->types())]])->validate(); + $rules = match ($type) { + 'single_choice', 'multiple_choice' => [ + 'options' => ['required', 'array', 'min:2', 'max:12'], + 'options.*.id' => ['required', 'string', 'distinct', 'max:80'], + 'options.*.text' => ['required', 'string', 'max:1000'], + 'options.*.correct' => ['required', 'boolean'], + 'options.*.feedback' => ['nullable', 'string', 'max:2000'], + ], + 'true_false' => ['answer' => ['required', 'boolean'], 'feedback' => ['nullable', 'string', 'max:2000']], + 'matching' => [ + 'pairs' => ['required', 'array', 'min:2', 'max:12'], + 'pairs.*.left' => ['required', 'string', 'max:500'], + 'pairs.*.right' => ['required', 'string', 'max:500'], + ], + 'sorting' => ['items' => ['required', 'array', 'min:2', 'max:12'], 'items.*' => ['required', 'string', 'distinct', 'max:500']], + 'drag_drop' => [ + 'items' => ['required', 'array', 'min:2', 'max:16'], + 'items.*.text' => ['required', 'string', 'max:500'], + 'items.*.target' => ['required', 'string', 'max:160'], + ], + 'hotspot' => [ + 'imageAssetId' => ['required', 'string'], + 'hotspots' => ['required', 'array', 'min:1', 'max:12'], + 'hotspots.*.x' => ['required', 'numeric', 'between:0,100'], + 'hotspots.*.y' => ['required', 'numeric', 'between:0,100'], + 'hotspots.*.radius' => ['required', 'numeric', 'between:1,40'], + 'hotspots.*.label' => ['required', 'string', 'max:160'], + 'hotspots.*.correct' => ['required', 'boolean'], + ], + 'scenario' => [ + 'context' => ['required', 'string', 'max:5000'], + 'choices' => ['required', 'array', 'min:2', 'max:8'], + 'choices.*.text' => ['required', 'string', 'max:1000'], + 'choices.*.score' => ['required', 'numeric', 'between:0,1'], + 'choices.*.feedback' => ['required', 'string', 'max:3000'], + ], + 'branching_scenario' => [ + 'startNodeId' => ['required', 'string'], + 'nodes' => ['required', 'array', 'min:2', 'max:80'], + 'nodes.*.id' => ['required', 'string', 'distinct', 'max:80'], + 'nodes.*.type' => ['required', Rule::in(['scene', 'question', 'result'])], + 'nodes.*.title' => ['required', 'string', 'max:240'], + 'nodes.*.body' => ['nullable', 'string', 'max:5000'], + 'nodes.*.choices' => ['nullable', 'array', 'max:12'], + 'nodes.*.choices.*.text' => ['required_with:nodes.*.choices', 'string', 'max:1000'], + 'nodes.*.choices.*.targetNodeId' => ['required_with:nodes.*.choices', 'string'], + ], + }; + $validated = Validator::make($configuration, $rules)->validate(); + if (in_array($type, ['single_choice', 'multiple_choice'], true)) { + $correct = collect($validated['options'])->where('correct', true)->count(); + if (($type === 'single_choice' && $correct !== 1) || ($type === 'multiple_choice' && $correct < 1)) { + throw ValidationException::withMessages(['configuration.options' => [$type === 'single_choice' ? 'دقیقاً یک گزینه باید صحیح باشد.' : 'حداقل یک گزینه باید صحیح باشد.']]); + } + } + if ($type === 'branching_scenario') { + $this->validateGraph($validated); + } + + return $validated; + } + + /** @param array $graph */ + private function validateGraph(array $graph): void + { + $nodes = collect($graph['nodes'])->keyBy('id'); + if (! $nodes->has($graph['startNodeId'])) { + throw ValidationException::withMessages(['configuration.startNodeId' => ['گره شروع وجود ندارد.']]); + } + foreach ($nodes as $node) { + foreach ($node['choices'] ?? [] as $choice) { + if (! $nodes->has($choice['targetNodeId'])) { + throw ValidationException::withMessages(['configuration.nodes' => ["مسیر به گره ناموجود {$choice['targetNodeId']} اشاره می‌کند."]]); + } + } + } + $reachable = []; + $queue = [$graph['startNodeId']]; + while ($queue !== []) { + $id = array_shift($queue); + if (isset($reachable[$id])) { + continue; + } + $reachable[$id] = true; + foreach (($nodes[$id]['choices'] ?? []) as $choice) { + $queue[] = $choice['targetNodeId']; + } + } + $unreachable = $nodes->keys()->reject(fn (string $id): bool => isset($reachable[$id]))->values(); + if ($unreachable->isNotEmpty()) { + throw ValidationException::withMessages(['configuration.nodes' => ['گره‌های غیرقابل‌دسترسی: '.$unreachable->implode('، ')]]); + } + if (! $nodes->contains(fn (array $node): bool => $node['type'] === 'result')) { + throw ValidationException::withMessages(['configuration.nodes' => ['سناریو باید حداقل یک گره نتیجه داشته باشد.']]); + } + } +} diff --git a/backend/app/Modules/Assessments/Domain/Assessment.php b/backend/app/Modules/Assessments/Domain/Assessment.php new file mode 100644 index 0000000..25f7d5f --- /dev/null +++ b/backend/app/Modules/Assessments/Domain/Assessment.php @@ -0,0 +1,24 @@ + 'array']; + } + + public function questions(): HasMany + { + return $this->hasMany(Question::class)->orderBy('position'); + } +} diff --git a/backend/app/Modules/Assessments/Domain/AssessmentAttempt.php b/backend/app/Modules/Assessments/Domain/AssessmentAttempt.php new file mode 100644 index 0000000..f68645c --- /dev/null +++ b/backend/app/Modules/Assessments/Domain/AssessmentAttempt.php @@ -0,0 +1,30 @@ + 'integer', 'score' => 'decimal:4', + 'started_at' => 'immutable_datetime', 'completed_at' => 'immutable_datetime', + ]; + } + + public function questionResults(): HasMany + { + return $this->hasMany(QuestionResult::class); + } +} diff --git a/backend/app/Modules/Assessments/Domain/Question.php b/backend/app/Modules/Assessments/Domain/Question.php new file mode 100644 index 0000000..979314f --- /dev/null +++ b/backend/app/Modules/Assessments/Domain/Question.php @@ -0,0 +1,34 @@ + 'array', 'tags' => 'array', 'position' => 'integer', 'schema_version' => 'integer', 'is_bank_item' => 'boolean']; + } + + public function questionResults(): HasMany + { + return $this->hasMany(QuestionResult::class); + } + + public function category(): BelongsTo + { + return $this->belongsTo(QuestionCategory::class, 'question_category_id'); + } +} diff --git a/backend/app/Modules/Assessments/Domain/QuestionCategory.php b/backend/app/Modules/Assessments/Domain/QuestionCategory.php new file mode 100644 index 0000000..eee3326 --- /dev/null +++ b/backend/app/Modules/Assessments/Domain/QuestionCategory.php @@ -0,0 +1,30 @@ +belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + public function questions(): HasMany + { + return $this->hasMany(Question::class, 'question_category_id'); + } +} diff --git a/backend/app/Modules/Assessments/Domain/QuestionResult.php b/backend/app/Modules/Assessments/Domain/QuestionResult.php new file mode 100644 index 0000000..ebf18ce --- /dev/null +++ b/backend/app/Modules/Assessments/Domain/QuestionResult.php @@ -0,0 +1,35 @@ + 'array', 'normalized_value' => 'decimal:4', 'is_correct' => 'boolean', + 'occurred_at' => 'immutable_datetime', 'metadata' => 'array', + ]; + } + + public function attempt(): BelongsTo + { + return $this->belongsTo(AssessmentAttempt::class, 'assessment_attempt_id'); + } + + public function question(): BelongsTo + { + return $this->belongsTo(Question::class); + } +} diff --git a/backend/app/Modules/Assessments/Http/AssessmentController.php b/backend/app/Modules/Assessments/Http/AssessmentController.php new file mode 100644 index 0000000..699398e --- /dev/null +++ b/backend/app/Modules/Assessments/Http/AssessmentController.php @@ -0,0 +1,273 @@ +authorize($request); + $versions = CourseVersion::query()->with(['course:id,title', 'lessons:id,course_version_id,title']) + ->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Draft) + ->latest()->get()->map(fn (CourseVersion $version) => [ + 'id' => $version->getKey(), 'title' => $version->title, 'course' => ['id' => $version->course->getKey(), 'title' => $version->course->title], + 'lessons' => $version->lessons->map(fn (Lesson $lesson) => ['id' => $lesson->getKey(), 'title' => $lesson->title])->values(), + ]); + + return response()->json(['data' => $versions]); + } + + public function index(Request $request): JsonResponse + { + $this->authorize($request); + $items = Assessment::query()->with('questions') + ->where('organization_id', $this->tenant->id())->latest()->get() + ->map(fn (Assessment $assessment) => $this->assessmentPayload($assessment)); + + return response()->json(['data' => $items]); + } + + public function store(Request $request): JsonResponse + { + $this->authorize($request); + $data = $this->assessmentData($request); + $version = $this->draftVersion($data['courseVersionId']); + $lesson = isset($data['lessonId']) ? Lesson::query()->where('organization_id', $this->tenant->id())->where('course_version_id', $version->getKey())->findOrFail($data['lessonId']) : null; + $assessment = Assessment::query()->create([ + 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), + 'lesson_id' => $lesson?->getKey(), 'title' => $data['title'], 'settings' => $data['settings'], + ]); + + return response()->json(['data' => $this->assessmentPayload($assessment->load('questions'))], 201); + } + + public function show(Request $request, string $assessment): JsonResponse + { + $this->authorize($request); + $model = $this->assessment($assessment)->load('questions'); + + return response()->json(['data' => $this->assessmentPayload($model)]); + } + + public function update(Request $request, string $assessment): JsonResponse + { + $this->authorize($request); + $model = $this->assessment($assessment); + $this->assertDraft($model->course_version_id); + $rules = ['title' => ['sometimes', 'required', 'string', 'max:240'], 'settings' => ['sometimes', 'array']]; + if ($request->has('settings')) { + $rules = [...$rules, ...$this->settingRules('settings.')]; + } + $data = $request->validate($rules); + $model->update($data); + + return response()->json(['data' => $this->assessmentPayload($model->fresh('questions'))]); + } + + public function destroy(Request $request, string $assessment): JsonResponse + { + $this->authorize($request); + $model = $this->assessment($assessment); + $this->assertDraft($model->course_version_id); + abort_if($model->questions()->whereHas('questionResults')->exists(), 409, 'Assessment with learner results cannot be deleted.'); + $model->delete(); + + return response()->json(status: 204); + } + + public function storeQuestion(Request $request, string $assessment): JsonResponse + { + $this->authorize($request); + $model = $this->assessment($assessment); + $version = $this->draftVersion((string) $model->course_version_id); + $data = $request->validate([ + 'sourceQuestionId' => ['nullable', 'string'], + 'type' => ['required_without:sourceQuestionId', Rule::in($this->schema->types())], + 'prompt' => ['required_without:sourceQuestionId', 'string', 'max:5000'], + 'configuration' => ['required_without:sourceQuestionId', 'array'], + 'difficulty' => ['nullable', Rule::in(['beginner', 'intermediate', 'advanced'])], + 'topic' => ['nullable', 'string', 'max:160'], 'tags' => ['nullable', 'array', 'max:20'], 'tags.*' => ['string', 'max:80'], + 'explanation' => ['nullable', 'string', 'max:5000'], 'saveToBank' => ['nullable', 'boolean'], + ]); + $source = isset($data['sourceQuestionId']) ? Question::query()->where('organization_id', $this->tenant->id())->where('is_bank_item', true)->findOrFail($data['sourceQuestionId']) : null; + $type = $source?->type ?? $data['type']; + $configuration = $this->schema->validate($type, $source?->configuration ?? $data['configuration']); + $question = Question::query()->create([ + 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), 'assessment_id' => $model->getKey(), + 'source_question_id' => $source?->getKey(), 'type' => $type, 'prompt' => $source?->prompt ?? $data['prompt'], + 'configuration' => $configuration, 'difficulty' => $source?->difficulty ?? ($data['difficulty'] ?? null), + 'topic' => $source?->topic ?? ($data['topic'] ?? null), 'tags' => $source?->tags ?? ($data['tags'] ?? []), + 'explanation' => $source?->explanation ?? ($data['explanation'] ?? null), 'position' => ($model->questions()->max('position') ?? 0) + 1, + ]); + if (($data['saveToBank'] ?? false) && ! $source) { + $bank = $question->replicate(['assessment_id', 'position']); + $bank->assessment_id = null; + $bank->position = 0; + $bank->is_bank_item = true; + $bank->save(); + $question->update(['source_question_id' => $bank->getKey()]); + } + + return response()->json(['data' => $this->questionPayload($question->fresh())], 201); + } + + public function updateQuestion(Request $request, string $question): JsonResponse + { + $this->authorize($request); + $model = $this->question($question); + if ($model->course_version_id) { + $this->assertDraft($model->course_version_id); + } + $data = $request->validate([ + 'prompt' => ['sometimes', 'required', 'string', 'max:5000'], 'configuration' => ['sometimes', 'array'], + 'difficulty' => ['sometimes', 'nullable', Rule::in(['beginner', 'intermediate', 'advanced'])], + 'topic' => ['sometimes', 'nullable', 'string', 'max:160'], 'tags' => ['sometimes', 'array', 'max:20'], 'tags.*' => ['string', 'max:80'], + 'explanation' => ['sometimes', 'nullable', 'string', 'max:5000'], + ]); + if (isset($data['configuration'])) { + $data['configuration'] = $this->schema->validate($model->type, $data['configuration']); + } + $model->update($data); + + return response()->json(['data' => $this->questionPayload($model->fresh())]); + } + + public function destroyQuestion(Request $request, string $question): JsonResponse + { + $this->authorize($request); + $model = $this->question($question); + if ($model->course_version_id) { + $this->assertDraft($model->course_version_id); + } + abort_if(QuestionResult::query()->where('question_id', $model->getKey())->exists(), 409, 'Question with learner results cannot be deleted.'); + DB::transaction(function () use ($model): void { + $assessmentId = $model->assessment_id; + $position = $model->position; + $model->delete(); + if ($assessmentId) { + Question::query()->where('assessment_id', $assessmentId)->where('position', '>', $position)->decrement('position'); + } + }); + + return response()->json(status: 204); + } + + public function reorderQuestions(Request $request, string $assessment): JsonResponse + { + $this->authorize($request); + $model = $this->assessment($assessment); + $this->assertDraft($model->course_version_id); + $ids = $request->validate(['questionIds' => ['required', 'array'], 'questionIds.*' => ['string']])['questionIds']; + $current = $model->questions()->pluck('id')->map(fn ($id): string => (string) $id)->all(); + if (count($ids) !== count($current) || array_diff($ids, $current) || array_diff($current, $ids)) { + throw ValidationException::withMessages(['questionIds' => ['فهرست کامل سؤال‌ها الزامی است.']]); + } + DB::transaction(fn () => collect($ids)->each(fn (string $id, int $index) => Question::query()->whereKey($id)->update(['position' => 10000 + $index]))); + DB::transaction(fn () => collect($ids)->each(fn (string $id, int $index) => Question::query()->whereKey($id)->update(['position' => $index + 1]))); + + return response()->json(['data' => $model->fresh('questions')->questions->map(fn (Question $item) => $this->questionPayload($item))]); + } + + /** @return array */ + private function assessmentData(Request $request): array + { + return $request->validate([ + 'courseVersionId' => ['required', 'string'], 'lessonId' => ['nullable', 'string'], 'title' => ['required', 'string', 'max:240'], + 'settings' => ['required', 'array'], ...$this->settingRules('settings.'), + ]); + } + + /** @return array> */ + private function settingRules(string $prefix): array + { + return [ + $prefix.'randomSelection' => ['required', 'boolean'], $prefix.'questionPoolSize' => ['nullable', 'integer', 'min:1', 'max:500'], + $prefix.'shuffleQuestions' => ['required', 'boolean'], $prefix.'shuffleOptions' => ['required', 'boolean'], + $prefix.'passingScore' => ['required', 'integer', 'between:0,100'], $prefix.'attemptLimit' => ['nullable', 'integer', 'min:1', 'max:100'], + $prefix.'feedbackMode' => ['required', Rule::in(['immediate', 'after_submission', 'none'])], + $prefix.'timeLimitSeconds' => ['nullable', 'integer', 'min:30', 'max:86400'], + ]; + } + + private function draftVersion(string $id): CourseVersion + { + $version = CourseVersion::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + $this->assertDraft($version->getKey()); + + return $version; + } + + private function assertDraft(string $versionId): void + { + $status = CourseVersion::query()->where('organization_id', $this->tenant->id())->whereKey($versionId)->value('status'); + $statusValue = $status instanceof CourseVersionStatus ? $status->value : $status; + if ($statusValue !== CourseVersionStatus::Draft->value) { + throw ValidationException::withMessages(['courseVersionId' => ['Published course version assessments are immutable.']]); + } + } + + private function assessment(string $id): Assessment + { + return Assessment::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function question(string $id): Question + { + return Question::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function authorize(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } + + /** @return array */ + private function assessmentPayload(Assessment $assessment): array + { + return [ + 'id' => $assessment->getKey(), 'courseVersionId' => $assessment->course_version_id, 'lessonId' => $assessment->lesson_id, + 'title' => $assessment->title, 'settings' => $assessment->settings ?? [], + 'questionCount' => $assessment->questions->count(), 'questions' => $assessment->questions->map(fn (Question $question) => $this->questionPayload($question))->values(), + 'updatedAt' => $assessment->updated_at?->toISOString(), + ]; + } + + /** @return array */ + private function questionPayload(Question $question): array + { + $usage = $question->is_bank_item ? Question::query()->where('source_question_id', $question->getKey())->count() : 0; + $performance = QuestionResult::query()->where('question_id', $question->getKey())->avg('normalized_value'); + + return [ + 'id' => $question->getKey(), 'courseVersionId' => $question->course_version_id, 'assessmentId' => $question->assessment_id, + 'sourceQuestionId' => $question->source_question_id, 'isBankItem' => $question->is_bank_item, + 'type' => $question->type, 'schemaVersion' => $question->schema_version, 'prompt' => $question->prompt, + 'configuration' => $question->configuration, 'difficulty' => $question->difficulty, 'topic' => $question->topic, + 'tags' => $question->tags ?? [], 'explanation' => $question->explanation, 'position' => $question->position, + 'usageCount' => $usage, 'performance' => $performance === null ? null : round(((float) $performance) * 100, 1), + ]; + } +} diff --git a/backend/app/Modules/Assessments/Http/QuestionBankController.php b/backend/app/Modules/Assessments/Http/QuestionBankController.php new file mode 100644 index 0000000..8464042 --- /dev/null +++ b/backend/app/Modules/Assessments/Http/QuestionBankController.php @@ -0,0 +1,137 @@ +authorize($request); + $filters = $request->validate([ + 'search' => ['nullable', 'string', 'max:160'], 'type' => ['nullable', Rule::in($this->schema->types())], + 'difficulty' => ['nullable', Rule::in(['beginner', 'intermediate', 'advanced'])], 'topic' => ['nullable', 'string', 'max:160'], + 'categoryId' => ['nullable', 'string', Rule::exists('question_categories', 'id')->where(fn ($query) => $query->where('organization_id', $this->tenant->id())->whereNull('parent_id'))], + 'subcategoryId' => ['nullable', 'string', Rule::exists('question_categories', 'id')->where(fn ($query) => $query->where('organization_id', $this->tenant->id())->whereNotNull('parent_id'))], + ]); + $items = Question::query()->where('organization_id', $this->tenant->id())->where('is_bank_item', true) + ->when($filters['search'] ?? null, fn ($query, string $search) => $query->where(fn ($inner) => $inner->where('prompt', 'like', "%{$search}%")->orWhere('topic', 'like', "%{$search}%"))) + ->when($filters['type'] ?? null, fn ($query, string $type) => $query->where('type', $type)) + ->when($filters['difficulty'] ?? null, fn ($query, string $difficulty) => $query->where('difficulty', $difficulty)) + ->when($filters['topic'] ?? null, fn ($query, string $topic) => $query->where('topic', $topic)) + ->when($filters['subcategoryId'] ?? null, fn ($query, string $id) => $query->where('question_category_id', $id)) + ->when(($filters['categoryId'] ?? null) && ! ($filters['subcategoryId'] ?? null), fn ($query) => $query->whereHas('category', fn ($category) => $category->where('parent_id', $filters['categoryId'])->orWhere('id', $filters['categoryId']))) + ->with('category.parent')->latest()->limit(300)->get()->map(fn (Question $question) => $this->payload($question)); + + return response()->json(['data' => $items]); + } + + public function store(Request $request): JsonResponse + { + $this->authorize($request); + $data = $this->data($request, true); + $question = Question::query()->create([ + 'organization_id' => $this->tenant->id(), 'course_version_id' => null, 'assessment_id' => null, + 'is_bank_item' => true, 'type' => $data['type'], 'prompt' => $data['prompt'], + 'configuration' => $this->schema->validate($data['type'], $data['configuration']), + 'difficulty' => $data['difficulty'] ?? null, 'topic' => $data['topic'] ?? null, 'question_category_id' => $data['subcategoryId'] ?? $data['categoryId'] ?? null, 'tags' => $data['tags'] ?? [], + 'explanation' => $data['explanation'] ?? null, 'position' => 0, + ]); + + return response()->json(['data' => $this->payload($question)], 201); + } + + public function update(Request $request, string $question): JsonResponse + { + $this->authorize($request); + $model = $this->model($question); + $data = $this->data($request, false); + if (isset($data['configuration'])) { + $data['configuration'] = $this->schema->validate($model->type, $data['configuration']); + } + if (array_key_exists('subcategoryId', $data) || array_key_exists('categoryId', $data)) { + $data['question_category_id'] = $data['subcategoryId'] ?? $data['categoryId'] ?? null; + } + unset($data['type'], $data['categoryId'], $data['subcategoryId']); + $model->update($data); + + return response()->json(['data' => $this->payload($model->fresh())]); + } + + public function destroy(Request $request, string $question): JsonResponse + { + $this->authorize($request); + $model = $this->model($question); + abort_if(Question::query()->where('source_question_id', $model->getKey())->exists(), 409, 'Question is used by an assessment and cannot be deleted.'); + $model->delete(); + + return response()->json(status: 204); + } + + /** @return array */ + private function data(Request $request, bool $creating): array + { + $data = $request->validate([ + 'type' => [$creating ? 'required' : 'sometimes', Rule::in($this->schema->types())], + 'prompt' => [$creating ? 'required' : 'sometimes', 'string', 'max:5000'], + 'configuration' => [$creating ? 'required' : 'sometimes', 'array'], + 'difficulty' => ['nullable', Rule::in(['beginner', 'intermediate', 'advanced'])], + 'topic' => ['nullable', 'string', 'max:160'], 'tags' => ['nullable', 'array', 'max:20'], 'tags.*' => ['string', 'max:80'], + 'categoryId' => ['nullable', 'string', Rule::exists('question_categories', 'id')->where(fn ($query) => $query->where('organization_id', $this->tenant->id())->whereNull('parent_id'))], + 'subcategoryId' => ['nullable', 'string', Rule::exists('question_categories', 'id')->where(fn ($query) => $query->where('organization_id', $this->tenant->id())->whereNotNull('parent_id'))], + 'explanation' => ['nullable', 'string', 'max:5000'], + ]); + if (($data['subcategoryId'] ?? null) && ($data['categoryId'] ?? null)) { + $belongs = QuestionCategory::query()->where('organization_id', $this->tenant->id())->whereKey($data['subcategoryId'])->where('parent_id', $data['categoryId'])->exists(); + if (! $belongs) { + throw ValidationException::withMessages(['subcategoryId' => ['زیردسته‌بندی به دسته‌بندی انتخاب‌شده تعلق ندارد.']]); + } + } + + return $data; + } + + private function model(string $id): Question + { + return Question::query()->where('organization_id', $this->tenant->id())->where('is_bank_item', true)->findOrFail($id); + } + + private function authorize(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } + + /** @return array */ + private function payload(Question $question): array + { + $usage = Question::query()->where('source_question_id', $question->getKey())->count(); + $copyIds = Question::query()->where('source_question_id', $question->getKey())->pluck('id'); + $performance = $copyIds->isEmpty() ? null : QuestionResult::query()->whereIn('question_id', $copyIds)->avg('normalized_value'); + + $categoryId = $question->category?->parent_id ?? $question->category?->getKey(); + + return [ + 'id' => $question->getKey(), 'type' => $question->type, 'prompt' => $question->prompt, + 'configuration' => $question->configuration, 'difficulty' => $question->difficulty, 'topic' => $question->topic, + 'tags' => $question->tags ?? [], 'explanation' => $question->explanation, 'usageCount' => $usage, + 'categoryId' => $categoryId, 'categoryName' => $question->category?->parent?->name ?? $question->category?->name, + 'subcategoryId' => $question->category?->parent_id ? $question->category?->getKey() : null, + 'subcategoryName' => $question->category?->parent_id ? $question->category?->name : null, + 'performance' => $performance === null ? null : round(((float) $performance) * 100, 1), + ]; + } +} diff --git a/backend/app/Modules/Assessments/Http/QuestionCategoryController.php b/backend/app/Modules/Assessments/Http/QuestionCategoryController.php new file mode 100644 index 0000000..a789f57 --- /dev/null +++ b/backend/app/Modules/Assessments/Http/QuestionCategoryController.php @@ -0,0 +1,63 @@ +authorize($request); + $categories = QuestionCategory::query()->where('organization_id', $this->tenant->id())->whereNull('parent_id') + ->with(['children' => fn ($query) => $query->withCount('questions')->orderBy('name')])->withCount('questions')->orderBy('name')->get(); + + return response()->json(['data' => $categories->map(fn (QuestionCategory $category) => $this->payload($category))]); + } + + public function store(Request $request): JsonResponse + { + $this->authorize($request); + $data = $request->validate([ + 'name' => ['required', 'string', 'max:120'], + 'parentId' => ['nullable', 'string', Rule::exists('question_categories', 'id')->where(fn ($query) => $query->where('organization_id', $this->tenant->id())->whereNull('parent_id'))], + ]); + $exists = QuestionCategory::query()->where('organization_id', $this->tenant->id())->where('parent_id', $data['parentId'] ?? null)->where('name', trim($data['name']))->exists(); + if ($exists) { + throw ValidationException::withMessages(['name' => ['این نام در سطح انتخاب‌شده وجود دارد.']]); + } + $category = QuestionCategory::query()->create(['organization_id' => $this->tenant->id(), 'parent_id' => $data['parentId'] ?? null, 'name' => trim($data['name'])]); + + return response()->json(['data' => ['id' => $category->getKey(), 'parentId' => $category->parent_id, 'name' => $category->name, 'questionCount' => 0, 'children' => []]], 201); + } + + public function destroy(Request $request, string $category): JsonResponse + { + $this->authorize($request); + $model = QuestionCategory::query()->where('organization_id', $this->tenant->id())->findOrFail($category); + abort_if($model->children()->exists() || $model->questions()->exists(), 409, 'Category is in use and cannot be deleted.'); + $model->delete(); + + return response()->json(status: 204); + } + + private function payload(QuestionCategory $category): array + { + return ['id' => $category->getKey(), 'parentId' => $category->parent_id, 'name' => $category->name, 'questionCount' => $category->questions_count, 'children' => $category->children->map(fn (QuestionCategory $child) => ['id' => $child->getKey(), 'parentId' => $child->parent_id, 'name' => $child->name, 'questionCount' => $child->questions_count])]; + } + + private function authorize(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } +} diff --git a/backend/app/Modules/Assets/Application/AssetUsage.php b/backend/app/Modules/Assets/Application/AssetUsage.php new file mode 100644 index 0000000..1330443 --- /dev/null +++ b/backend/app/Modules/Assets/Application/AssetUsage.php @@ -0,0 +1,100 @@ + */ + public function counts(string $organizationId): array + { + $counts = []; + Block::query()->where('organization_id', $organizationId)->get(['data'])->each(function (Block $block) use (&$counts) { + foreach ($this->assetIds($block->data) as $id) { + $counts[$id] = ($counts[$id] ?? 0) + 1; + } + }); + Course::query()->where('organization_id', $organizationId)->whereNotNull('cover_asset_id')->pluck('cover_asset_id')->each(function (string $id) use (&$counts) { + $counts[$id] = ($counts[$id] ?? 0) + 1; + }); + foreach (['review_threads', 'review_replies'] as $table) { + DB::table($table)->where('organization_id', $organizationId)->whereNotNull('attachment_asset_ids')->pluck('attachment_asset_ids')->each(function ($value) use (&$counts) { + foreach (json_decode($value, true) ?? [] as $id) { + $counts[$id] = ($counts[$id] ?? 0) + 1; + } + }); + } + + return $counts; + } + + /** @param array|list $value @return list */ + public function assetIds(array $value): array + { + $ids = []; + foreach ($value as $key => $item) { + if (is_string($key) && ($key === 'assetId' || str_ends_with($key, 'AssetId')) && is_string($item) && $item !== '') { + $ids[] = $item; + } elseif (is_array($item)) { + array_push($ids, ...$this->assetIds($item)); + } + } + + return array_values(array_unique($ids)); + } + + public function detach(string $organizationId, string $assetId): void + { + $publishedVersionIds = CourseVersion::query() + ->where('organization_id', $organizationId) + ->where('status', CourseVersionStatus::Published) + ->pluck('id') + ->map(fn ($id): string => (string) $id) + ->all(); + $blocks = Block::query()->where('organization_id', $organizationId)->get(); + foreach ($blocks as $block) { + if (in_array($assetId, $this->assetIds($block->data), true) && in_array((string) $block->course_version_id, $publishedVersionIds, true)) { + throw ValidationException::withMessages(['asset' => ['این فایل در نسخه منتشرشده استفاده شده و تا زمان ایجاد نسخه قابل‌ویرایش قابل حذف نیست.']]); + } + } + foreach ($blocks as $block) { + if (! in_array($assetId, $this->assetIds($block->data), true)) { + continue; + } + $block->update(['data' => $this->withoutAsset($block->data, $assetId), 'revision' => $block->revision + 1]); + } + Course::query()->where('organization_id', $organizationId)->where('cover_asset_id', $assetId)->update(['cover_asset_id' => null]); + foreach (['review_threads', 'review_replies'] as $table) { + DB::table($table)->where('organization_id', $organizationId)->whereNotNull('attachment_asset_ids')->get(['id', 'attachment_asset_ids'])->each(function ($row) use ($table, $assetId): void { + $ids = array_values(array_filter(json_decode($row->attachment_asset_ids, true) ?? [], fn ($id) => $id !== $assetId)); + DB::table($table)->where('id', $row->id)->update(['attachment_asset_ids' => json_encode($ids), 'updated_at' => now()]); + }); + } + } + + /** @param array|list $value @return array|list */ + private function withoutAsset(array $value, string $assetId): array + { + if (array_is_list($value)) { + return array_values(array_map( + fn ($item) => is_array($item) ? $this->withoutAsset($item, $assetId) : $item, + array_filter($value, fn ($item): bool => ! (is_array($item) && ($item['assetId'] ?? null) === $assetId)), + )); + } + foreach ($value as $key => $item) { + if (is_string($key) && ($key === 'assetId' || str_ends_with($key, 'AssetId')) && $item === $assetId) { + $value[$key] = null; + } elseif (is_array($item)) { + $value[$key] = $this->withoutAsset($item, $assetId); + } + } + + return $value; + } +} diff --git a/backend/app/Modules/Assets/Domain/Asset.php b/backend/app/Modules/Assets/Domain/Asset.php new file mode 100644 index 0000000..583b72e --- /dev/null +++ b/backend/app/Modules/Assets/Domain/Asset.php @@ -0,0 +1,21 @@ + 'integer', 'metadata' => 'array']; + } +} diff --git a/backend/app/Modules/Assets/Http/AssetController.php b/backend/app/Modules/Assets/Http/AssetController.php new file mode 100644 index 0000000..287b074 --- /dev/null +++ b/backend/app/Modules/Assets/Http/AssetController.php @@ -0,0 +1,132 @@ +authorizeView($request); + $request->validate(['kind' => ['nullable', 'in:image,video,audio,document'], 'search' => ['nullable', 'string', 'max:120']]); + $counts = $this->usage->counts($this->tenant->id()); + $assets = Asset::query()->where('organization_id', $this->tenant->id()) + ->when($request->filled('kind'), fn ($query) => $query->where('kind', $request->string('kind'))) + ->when($request->filled('search'), function ($query) use ($request) { + $search = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $request->string('search')).'%'; + $query->where('original_name', 'like', $search); + })->latest()->limit(200)->get()->map(fn (Asset $asset) => $this->payload($asset, $counts[$asset->getKey()] ?? 0)); + + return response()->json(['data' => $assets]); + } + + public function store(StoreAssetRequest $request): JsonResponse + { + $this->authorizeManage($request); + $file = $request->file('file'); + $hash = hash_file('sha256', $file->getRealPath()); + $existing = Asset::query()->where('organization_id', $this->tenant->id())->where('sha256', $hash)->first(); + if ($existing) { + return response()->json(['data' => $this->payload($existing, $this->usage->counts($this->tenant->id())[$existing->getKey()] ?? 0)]); + } + + $kind = $this->kind((string) $file->getMimeType()); + $asset = new Asset([ + 'organization_id' => $this->tenant->id(), 'uploaded_by' => $request->user()->getKey(), + 'kind' => $kind, 'original_name' => $file->getClientOriginalName(), 'disk' => 'local', + 'mime_type' => (string) $file->getMimeType(), 'size' => $file->getSize(), 'sha256' => $hash, + 'alt_text' => $request->validated('altText'), 'metadata' => null, + ]); + $asset->id = (string) Str::ulid(); + $asset->path = $file->storeAs("assets/{$this->tenant->id()}/{$asset->getKey()}", $file->hashName(), 'local'); + $asset->save(); + + return response()->json(['data' => $this->payload($asset, 0)], 201); + } + + public function show(Request $request, string $asset): JsonResponse + { + $this->authorizeView($request); + $model = Asset::query()->where('organization_id', $this->tenant->id())->findOrFail($asset); + $usageCount = $this->usage->counts($this->tenant->id())[$model->getKey()] ?? 0; + + return response()->json(['data' => $this->payload($model, $usageCount)]); + } + + public function destroy(Request $request, string $asset): JsonResponse + { + $this->authorizeManage($request); + $data = $request->validate(['detach' => ['nullable', 'boolean']]); + $model = Asset::query()->where('organization_id', $this->tenant->id())->findOrFail($asset); + $usageCount = $this->usage->counts($this->tenant->id())[$model->getKey()] ?? 0; + if ($usageCount > 0 && ! ($data['detach'] ?? false)) { + throw ValidationException::withMessages(['asset' => ['This asset is used by course content and cannot be deleted.']]); + } + $disk = $model->disk; + $path = $model->path; + DB::transaction(function () use ($model, $usageCount): void { + if ($usageCount > 0) { + $this->usage->detach($this->tenant->id(), (string) $model->getKey()); + } + $model->delete(); + }); + Storage::disk($disk)->delete($path); + + return response()->json(status: 204); + } + + public function content(Request $request, string $asset): StreamedResponse + { + abort_unless($request->hasValidRelativeSignature(), 403); + $model = Asset::query()->findOrFail($asset); + abort_unless(Storage::disk($model->disk)->exists($model->path), 404); + + return Storage::disk($model->disk)->response($model->path, $model->original_name, ['Content-Type' => $model->mime_type, 'X-Content-Type-Options' => 'nosniff']); + } + + private function payload(Asset $asset, int $usageCount): array + { + return [ + 'id' => $asset->getKey(), 'kind' => $asset->kind, 'name' => $asset->original_name, + 'mimeType' => $asset->mime_type, 'size' => $asset->size, 'altText' => $asset->alt_text, + 'usageCount' => $usageCount, 'createdAt' => $asset->created_at?->toISOString(), + 'contentUrl' => URL::temporarySignedRoute('assets.content', now()->addMinutes(10), ['asset' => $asset->getKey()], absolute: false), + ]; + } + + private function kind(string $mime): string + { + return str_starts_with($mime, 'image/') ? 'image' : (str_starts_with($mime, 'video/') ? 'video' : (str_starts_with($mime, 'audio/') ? 'audio' : 'document')); + } + + private function authorizeView(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } + + private function authorizeManage(Request $request): void + { + $this->authorizeView($request); + } +} diff --git a/backend/app/Modules/Assets/Http/Requests/StoreAssetRequest.php b/backend/app/Modules/Assets/Http/Requests/StoreAssetRequest.php new file mode 100644 index 0000000..1669f0a --- /dev/null +++ b/backend/app/Modules/Assets/Http/Requests/StoreAssetRequest.php @@ -0,0 +1,21 @@ + ['required', 'file', 'max:2097152', 'mimes:jpg,jpeg,png,gif,webp,avif,mp4,webm,mov,m4v,mp3,wav,ogg,m4a,pdf,doc,docx,ppt,pptx,xls,xlsx,txt'], + 'altText' => ['nullable', 'string', 'max:300'], + ]; + } +} diff --git a/backend/app/Modules/Assignments/Application/AssignmentAudienceImport.php b/backend/app/Modules/Assignments/Application/AssignmentAudienceImport.php new file mode 100644 index 0000000..e6f0a22 --- /dev/null +++ b/backend/app/Modules/Assignments/Application/AssignmentAudienceImport.php @@ -0,0 +1,102 @@ +, matched: int, missingEmails: list} */ + public function resolve(string $organizationId, UploadedFile $file): array + { + $rows = Str::lower($file->getClientOriginalExtension()) === 'xlsx' ? $this->xlsx($file->getRealPath()) : $this->csv($file->getRealPath()); + if (count($rows) > 1001) { + throw ValidationException::withMessages(['file' => ['هر فایل می‌تواند حداکثر ۱۰۰۰ مخاطب داشته باشد.']]); + } + $headers = array_map(fn ($value) => $this->normalize((string) $value), array_shift($rows) ?? []); + $emailColumn = collect($headers)->search(fn (string $header) => in_array($header, ['آدرس ایمیل', 'ایمیل', 'email', 'email address'], true)); + if ($emailColumn === false) { + throw ValidationException::withMessages(['file' => ['ستون «آدرس ایمیل» یا Email پیدا نشد.']]); + } + $emails = collect($rows)->map(fn (array $row) => Str::lower(trim((string) ($row[$emailColumn] ?? ''))))->filter()->unique()->values(); + if ($emails->isEmpty()) { + throw ValidationException::withMessages(['file' => ['فایل هیچ آدرس ایمیلی ندارد.']]); + } + $users = User::query()->where('organization_id', $organizationId)->where('status', 'active')->whereIn('email', $emails)->get(['id', 'email']); + $found = $users->pluck('email')->map(fn ($email) => Str::lower($email)); + + return ['userIds' => $users->pluck('id')->map(fn ($id) => (string) $id)->values()->all(), 'matched' => $users->count(), 'missingEmails' => $emails->diff($found)->values()->all()]; + } + + /** @return list> */ + private function csv(string $path): array + { + $handle = fopen($path, 'rb'); + if (! $handle) { + throw ValidationException::withMessages(['file' => ['فایل قابل خواندن نیست.']]); + } + $rows = []; + while (($row = fgetcsv($handle)) !== false) { + $rows[] = array_map(fn ($value) => preg_replace('/^\xEF\xBB\xBF/', '', $value ?? '') ?? '', $row); + } + fclose($handle); + + return $rows; + } + + /** @return list> */ + private function xlsx(string $path): array + { + $zip = new ZipArchive; + if ($zip->open($path) !== true) { + throw ValidationException::withMessages(['file' => ['ساختار XLSX معتبر نیست.']]); + } + $shared = []; + if (($xml = $zip->getFromName('xl/sharedStrings.xml')) !== false) { + $document = new \DOMDocument; + $document->loadXML($xml, LIBXML_NONET); + $xpath = new \DOMXPath($document); + $xpath->registerNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + foreach ($xpath->query('//x:si') ?: [] as $node) { + $shared[] = collect(iterator_to_array($xpath->query('.//x:t', $node) ?: []))->map(fn (\DOMNode $text) => $text->textContent)->implode(''); + } + } + $xml = $zip->getFromName('xl/worksheets/sheet1.xml'); + $zip->close(); + if ($xml === false) { + throw ValidationException::withMessages(['file' => ['اولین worksheet پیدا نشد.']]); + } + $document = new \DOMDocument; + $document->loadXML($xml, LIBXML_NONET); + $xpath = new \DOMXPath($document); + $xpath->registerNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $rows = []; + foreach ($xpath->query('//x:sheetData/x:row') ?: [] as $rowNode) { + $row = []; + foreach ($xpath->query('./x:c', $rowNode) ?: [] as $cell) { + preg_match('/^[A-Z]+/', $cell->attributes?->getNamedItem('r')?->nodeValue ?? '', $match); + $index = 0; + foreach (str_split($match[0] ?? 'A') as $letter) { + $index = $index * 26 + ord($letter) - 64; + } + $type = $cell->attributes?->getNamedItem('t')?->nodeValue; + $value = $type === 'inlineStr' ? ($xpath->query('.//x:t', $cell)?->item(0)?->textContent ?? '') : ($xpath->query('./x:v', $cell)?->item(0)?->textContent ?? ''); + $row[max(0, $index - 1)] = $type === 's' ? ($shared[(int) $value] ?? '') : $value; + } + if ($row !== []) { + $rows[] = array_map(fn ($index) => (string) ($row[$index] ?? ''), range(0, max(array_keys($row)))); + } + } + + return $rows; + } + + private function normalize(string $value): string + { + return Str::of($value)->replace(['ي', 'ك', "\u{200C}"], ['ی', 'ک', ' '])->squish()->lower()->toString(); + } +} diff --git a/backend/app/Modules/Assignments/Application/AssignmentResolver.php b/backend/app/Modules/Assignments/Application/AssignmentResolver.php new file mode 100644 index 0000000..a4bb466 --- /dev/null +++ b/backend/app/Modules/Assignments/Application/AssignmentResolver.php @@ -0,0 +1,74 @@ +status !== 'active') { + return 0; + } + + $users = $this->resolve($assignment); + $rows = $users->mapWithKeys(fn (User $user): array => [(string) $user->getKey() => [ + 'status' => 'assigned', 'assigned_at' => now(), 'starts_at' => $assignment->starts_at, + 'due_at' => $assignment->due_at, 'progress' => 0, 'created_at' => now(), 'updated_at' => now(), + ]])->all(); + $existing = $assignment->users()->pluck('users.id')->map(fn ($id) => (string) $id); + $assignment->users()->syncWithoutDetaching(collect($rows)->except($existing)->all()); + + return count($rows); + } + + public function syncOrganization(string $organizationId): void + { + Assignment::query()->where('organization_id', $organizationId)->where('status', 'active')->each(fn (Assignment $assignment) => $this->sync($assignment)); + } + + /** @return Collection */ + private function resolve(Assignment $assignment): Collection + { + $query = User::query()->where('organization_id', $assignment->organization_id)->where('status', AccountStatus::Active); + + if ($assignment->target_type === 'individual') { + return $query->whereKey($assignment->target_id)->get(); + } + if ($assignment->target_type === 'team') { + $team = Team::query()->where('organization_id', $assignment->organization_id)->find($assignment->target_id); + + return $team ? $query->whereIn('id', $team->members()->pluck('users.id'))->get() : collect(); + } + if ($assignment->target_type === 'department') { + return $query->where('department', $assignment->target_value)->get(); + } + if ($assignment->target_type === 'organization') { + return $query->whereIn('role', [UserRole::Learner, UserRole::Manager])->get(); + } + if ($assignment->target_type === 'rule') { + $rule = json_decode((string) $assignment->target_value, true) ?: []; + if (($rule['field'] ?? null) === 'department') { + $query->where('department', $rule['value'] ?? ''); + } elseif (($rule['field'] ?? null) === 'job_level') { + $query->where('job_level', $rule['value'] ?? ''); + } elseif (($rule['field'] ?? null) === 'team') { + $memberIds = DB::table('team_memberships')->where('team_id', $rule['value'] ?? '')->pluck('user_id'); + $query->whereIn('id', $memberIds); + } else { + return collect(); + } + + return $query->get(); + } + + return collect(); + } +} diff --git a/backend/app/Modules/Assignments/Domain/Assignment.php b/backend/app/Modules/Assignments/Domain/Assignment.php new file mode 100644 index 0000000..b680442 --- /dev/null +++ b/backend/app/Modules/Assignments/Domain/Assignment.php @@ -0,0 +1,25 @@ + 'boolean', 'starts_at' => 'immutable_datetime', 'due_at' => 'immutable_datetime', 'cancelled_at' => 'immutable_datetime', 'escalation_policy' => 'array']; + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'assignment_users')->withPivot(['status', 'assigned_at', 'starts_at', 'due_at', 'completed_at', 'progress'])->withTimestamps(); + } +} diff --git a/backend/app/Modules/Assignments/Http/AssignmentController.php b/backend/app/Modules/Assignments/Http/AssignmentController.php new file mode 100644 index 0000000..eaefdaf --- /dev/null +++ b/backend/app/Modules/Assignments/Http/AssignmentController.php @@ -0,0 +1,337 @@ +authorize($request); + $request->validate(['file' => ['required', 'file', 'mimes:xlsx,csv,txt', 'max:10240']]); + + return response()->json(['data' => $this->audienceImport->resolve($this->tenant->id(), $request->file('file'))]); + } + + public function contexts(Request $request): JsonResponse + { + $this->authorize($request); + $courses = CourseVersion::query()->with('course:id,title')->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->orderByDesc('published_at')->get()->map(fn (CourseVersion $version) => [ + 'id' => $version->getKey(), 'type' => 'course', 'title' => $version->course->title, 'version' => $version->version_number, + ]); + $paths = LearningPathVersion::query()->with('path:id,title')->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->orderByDesc('published_at')->get()->map(fn (LearningPathVersion $version) => [ + 'id' => $version->getKey(), 'type' => 'learning_path', 'title' => $version->path->title, 'version' => $version->version_number, + ]); + $users = User::query()->where('organization_id', $this->tenant->id())->where('status', 'active')->orderBy('name')->get(['id', 'name', 'email', 'department', 'job_level']); + $teams = Team::query()->where('organization_id', $this->tenant->id())->where('status', 'active')->withCount('members')->orderBy('name')->get(['id', 'name']); + $departments = User::query()->where('organization_id', $this->tenant->id())->whereNotNull('department')->distinct()->orderBy('department')->pluck('department')->values(); + + return response()->json(['data' => ['content' => $courses->concat($paths)->values(), 'users' => $users, 'teams' => $teams, 'departments' => $departments]]); + } + + public function index(Request $request): JsonResponse + { + $this->authorize($request); + $filters = $request->validate([ + 'workspace' => ['nullable', 'boolean'], 'courseVersionId' => ['nullable', 'string'], 'search' => ['nullable', 'string', 'max:120'], + 'status' => ['nullable', Rule::in(['active', 'scheduled', 'completed', 'draft', 'stopped', 'cancelled'])], + 'targetType' => ['nullable', Rule::in(['individual', 'team', 'department', 'organization', 'rule', 'bulk'])], + 'sort' => ['nullable', Rule::in(['dueAt', 'progress', 'status', 'updatedAt'])], 'direction' => ['nullable', Rule::in(['asc', 'desc'])], + 'page' => ['nullable', 'integer', 'min:1'], 'pageSize' => ['nullable', Rule::in([10, 25, 50, 100])], + ]); + $query = Assignment::query()->where('organization_id', $this->tenant->id()) + ->when($filters['courseVersionId'] ?? null, fn ($builder, string $value) => $builder->where('assignable_type', 'course')->where('assignable_id', $value)) + ->when($filters['targetType'] ?? null, fn ($builder, string $value) => $builder->where('target_type', $value)); + if (! ($filters['workspace'] ?? false)) { + $items = $query->when($filters['status'] ?? null, fn ($builder, string $value) => $builder->where('status', $value === 'stopped' ? 'cancelled' : $value)) + ->withCount('users')->latest()->get()->map(fn (Assignment $assignment) => $this->payload($assignment)); + + return response()->json(['data' => $items]); + } + $all = $this->withWorkspaceMetrics($query)->latest()->get()->map(fn (Assignment $assignment) => $this->payload($assignment)); + $summary = ['total' => $all->count(), 'active' => $all->where('status', 'active')->count(), 'completed' => $all->where('status', 'completed')->count(), + 'dueSoon' => $all->filter(fn (array $item) => $item['remainingDays'] !== null && $item['remainingDays'] >= 0 && $item['remainingDays'] <= 7 && in_array($item['status'], ['active', 'scheduled'], true))->count()]; + $items = $all + ->when($filters['status'] ?? null, fn ($collection, string $value) => $collection->where('status', $value === 'cancelled' ? 'stopped' : $value)) + ->when($filters['search'] ?? null, function ($collection, string $value) { + $needle = mb_strtolower($value); + + return $collection->filter(fn (array $item) => str_contains(mb_strtolower($item['contentTitle'].' '.$item['targetLabel']), $needle)); + }); + $sort = $filters['sort'] ?? 'updatedAt'; + $sortKey = ['dueAt' => 'dueAt', 'progress' => 'progress', 'status' => 'status', 'updatedAt' => 'updatedAt'][$sort]; + $items = ($filters['direction'] ?? 'desc') === 'asc' ? $items->sortBy($sortKey, SORT_NATURAL) : $items->sortByDesc($sortKey, SORT_NATURAL); + $page = (int) ($filters['page'] ?? 1); + $pageSize = (int) ($filters['pageSize'] ?? 10); + $total = $items->count(); + + return response()->json(['data' => ['items' => $items->slice(($page - 1) * $pageSize, $pageSize)->values(), 'summary' => $summary, + 'meta' => ['page' => $page, 'pageSize' => $pageSize, 'total' => $total, 'lastPage' => max(1, (int) ceil($total / $pageSize))]]]); + } + + public function show(Request $request, string $assignment): JsonResponse + { + $this->authorize($request); + $model = $this->withWorkspaceMetrics(Assignment::query()->where('organization_id', $this->tenant->id()))->findOrFail($assignment); + $history = DB::table('audit_logs')->where('organization_id', $this->tenant->id())->where('entity_type', 'assignment')->where('entity_id', $model->getKey()) + ->where('action', 'like', '%reminder%')->latest('created_at')->limit(10)->get(['action', 'metadata', 'created_at'])->map(fn ($row) => [ + 'action' => $row->action, 'metadata' => json_decode((string) $row->metadata, true) ?: [], 'createdAt' => $row->created_at, + ]); + + return response()->json(['data' => [...$this->payload($model), 'reminderHistory' => $history]]); + } + + public function remind(Request $request, string $assignment): JsonResponse + { + $this->authorize($request); + $model = Assignment::query()->where('organization_id', $this->tenant->id())->with(['users' => fn ($query) => $query->wherePivotNotIn('status', ['completed', 'cancelled'])])->findOrFail($assignment); + abort_if($model->status !== 'active', 422, 'فقط برای تخصیص فعال می‌توان یادآوری فرستاد.'); + $result = ['requested' => $model->users->count(), 'sent' => 0, 'skipped' => 0]; + foreach ($model->users as $recipient) { + $sent = $this->notifications->send($recipient, $request->user(), ['type' => 'learning.reminder', 'title' => 'یادآوری یادگیری', + 'body' => 'یک محتوای یادگیری تخصیص‌یافته در انتظار پیگیری شماست.', 'targetUrl' => '/learn/home', 'entityType' => 'assignment', + 'entityId' => $model->getKey(), 'preferenceKey' => 'deadlineReminders', 'mandatory' => false, + 'idempotencyKey' => 'designer-reminder:'.$model->getKey().':'.$recipient->getKey().':'.now()->toDateString()]); + $result[$sent ? 'sent' : 'skipped']++; + } + DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'organization_id' => $this->tenant->id(), 'actor_id' => $request->user()->getKey(), + 'action' => 'assignment.reminder_sent', 'entity_type' => 'assignment', 'entity_id' => $model->getKey(), 'metadata' => json_encode(['schemaVersion' => 1, ...$result]), + 'ip_address' => $request->ip(), 'created_at' => now()]); + + return response()->json(['data' => $result]); + } + + public function bulk(Request $request): JsonResponse + { + $this->authorize($request); + $data = $request->validate(['ids' => ['required', 'array', 'min:1', 'max:100'], 'ids.*' => ['string', 'distinct'], + 'action' => ['required', Rule::in(['cancel', 'activate', 'extend', 'remind'])], 'dueAt' => ['nullable', 'date', 'after:now']]); + $models = Assignment::query()->where('organization_id', $this->tenant->id())->whereIn('id', $data['ids'])->get(); + abort_if($models->count() !== count($data['ids']), 404); + if ($data['action'] === 'extend' && empty($data['dueAt'])) { + throw ValidationException::withMessages(['dueAt' => ['مهلت جدید را مشخص کنید.']]); + } + $dueAt = isset($data['dueAt']) ? Carbon::parse($data['dueAt']) : null; + $affected = 0; + foreach ($models as $model) { + if ($data['action'] === 'cancel' && $model->status === 'active') { + $model->update(['status' => 'cancelled', 'cancelled_at' => now()]); + $affected++; + } + if ($data['action'] === 'activate' && $model->status === 'cancelled') { + $model->update(['status' => 'active', 'cancelled_at' => null]); + DB::table('assignment_users')->where('assignment_id', $model->getKey())->where('status', 'cancelled') + ->update(['status' => 'assigned', 'updated_at' => now()]); + $affected++; + } + if ($data['action'] === 'extend') { + $model->update(['due_at' => $dueAt]); + DB::table('assignment_users')->where('assignment_id', $model->getKey())->whereNotIn('status', ['completed', 'cancelled'])->update(['due_at' => $dueAt, 'updated_at' => now()]); + $affected++; + } + if ($data['action'] === 'remind') { + $this->remind($request, (string) $model->getKey()); + $affected++; + } + } + + return response()->json(['data' => ['affected' => $affected]]); + } + + public function store(Request $request): JsonResponse + { + $this->authorize($request); + $data = $request->validate([ + 'assignableType' => ['required', Rule::in(['course', 'learning_path'])], 'assignableId' => ['required', 'string'], + 'targetType' => ['required', Rule::in(['individual', 'team', 'department', 'organization', 'rule', 'bulk'])], + 'targetId' => ['nullable', 'string'], 'targetValue' => ['nullable', 'string', 'max:2000'], + 'userIds' => ['nullable', 'array', 'max:1000'], 'userIds.*' => ['string'], 'mandatory' => ['required', 'boolean'], + 'startsAt' => ['nullable', 'date'], 'dueAt' => ['nullable', 'date', 'after_or_equal:startsAt'], + 'recurringMonths' => ['nullable', 'integer', 'between:1,60'], 'reminderDays' => ['nullable', 'integer', 'between:1,365'], + 'escalationEnabled' => ['nullable', 'boolean'], + ]); + $this->assertAssignable($data['assignableType'], $data['assignableId']); + $this->assertTarget($data); + $targetId = $data['targetId'] ?? null; + $targetValue = $data['targetValue'] ?? null; + if ($data['targetType'] === 'bulk') { + $ids = User::query()->where('organization_id', $this->tenant->id())->whereIn('id', $data['userIds'] ?? [])->pluck('id')->map(fn ($id) => (string) $id)->values(); + if ($ids->count() !== count($data['userIds'] ?? [])) { + throw ValidationException::withMessages(['userIds' => ['یک یا چند کاربر متعلق به این سازمان نیستند.']]); + } + $targetValue = $ids->toJson(); + } + $duplicate = Assignment::query()->where('organization_id', $this->tenant->id())->where('assignable_type', $data['assignableType'])->where('assignable_id', $data['assignableId'])->where('target_type', $data['targetType'])->where('target_id', $targetId)->where('target_value', $targetValue)->where('status', 'active')->exists(); + if ($duplicate) { + throw ValidationException::withMessages(['target' => ['این محتوا قبلاً به همین مخاطب تخصیص داده شده است.']]); + } + $assignment = DB::transaction(function () use ($request, $data, $targetId, $targetValue): Assignment { + $assignment = Assignment::query()->create([ + 'organization_id' => $this->tenant->id(), 'assignable_type' => $data['assignableType'], 'assignable_id' => $data['assignableId'], + 'target_type' => $data['targetType'], 'target_id' => $targetId, 'target_value' => $targetValue, 'status' => 'active', + 'mandatory' => $data['mandatory'], 'starts_at' => $data['startsAt'] ?? null, 'due_at' => $data['dueAt'] ?? null, + 'recurring_months' => $data['recurringMonths'] ?? null, 'reminder_days' => $data['reminderDays'] ?? null, + 'escalation_policy' => ['enabled' => (bool) ($data['escalationEnabled'] ?? false)], 'source' => $data['targetType'] === 'bulk' ? 'bulk' : 'manual', + 'assigned_by' => $request->user()->getKey(), + ]); + if ($assignment->target_type === 'bulk') { + $users = json_decode((string) $assignment->target_value, true) ?: []; + $assignment->users()->sync(collect($users)->mapWithKeys(fn (string $id) => [$id => ['status' => 'assigned', 'assigned_at' => now(), 'starts_at' => $assignment->starts_at, 'due_at' => $assignment->due_at, 'progress' => 0, 'created_at' => now(), 'updated_at' => now()]])->all()); + } else { + $this->resolver->sync($assignment); + } + + return $assignment; + }); + + return response()->json(['data' => $this->payload($assignment->loadCount('users'))], 201); + } + + public function cancel(Request $request, string $assignment): JsonResponse + { + $this->authorize($request); + $model = Assignment::query()->where('organization_id', $this->tenant->id())->findOrFail($assignment); + $model->update(['status' => 'cancelled', 'cancelled_at' => now()]); + DB::table('assignment_users')->where('assignment_id', $model->getKey())->where('status', 'assigned')->update(['status' => 'cancelled', 'updated_at' => now()]); + + return response()->json(['data' => $this->payload($model->loadCount('users'))]); + } + + public function update(Request $request, string $assignment): JsonResponse + { + $this->authorize($request); + $model = Assignment::query()->where('organization_id', $this->tenant->id())->findOrFail($assignment); + abort_if($model->status !== 'active', 422, 'فقط تخصیص فعال قابل ویرایش است.'); + $data = $request->validate([ + 'mandatory' => ['required', 'boolean'], + 'startsAt' => ['nullable', 'date'], + 'dueAt' => ['nullable', 'date', 'after_or_equal:startsAt'], + 'recurringMonths' => ['nullable', 'integer', 'between:1,60'], + 'reminderDays' => ['nullable', 'integer', 'between:1,365'], + 'escalationEnabled' => ['nullable', 'boolean'], + ]); + + DB::transaction(function () use ($model, $data): void { + $model->update([ + 'mandatory' => $data['mandatory'], + 'starts_at' => $data['startsAt'] ?? null, + 'due_at' => $data['dueAt'] ?? null, + 'recurring_months' => $data['recurringMonths'] ?? null, + 'reminder_days' => $data['reminderDays'] ?? null, + 'escalation_policy' => ['enabled' => (bool) ($data['escalationEnabled'] ?? false)], + ]); + DB::table('assignment_users')->where('assignment_id', $model->getKey()) + ->whereNotIn('status', ['completed', 'cancelled']) + ->update(['starts_at' => $model->starts_at, 'due_at' => $model->due_at, 'updated_at' => now()]); + }); + + return response()->json(['data' => $this->payload($model->fresh()->loadCount('users'))]); + } + + public function sync(Request $request): JsonResponse + { + $this->authorize($request); + $this->resolver->syncOrganization($this->tenant->id()); + + return response()->json(['data' => ['synced' => true]]); + } + + private function assertAssignable(string $type, string $id): void + { + $model = $type === 'course' + ? CourseVersion::query()->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->find($id) + : LearningPathVersion::query()->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->find($id); + if (! $model) { + throw ValidationException::withMessages(['assignableId' => ['فقط یک نسخه منتشرشده و فعال قابل تخصیص است.']]); + } + } + + /** @param array $data */ + private function assertTarget(array $data): void + { + $type = $data['targetType']; + if (in_array($type, ['individual', 'team'], true) && empty($data['targetId'])) { + throw ValidationException::withMessages(['targetId' => ['مخاطب را انتخاب کنید.']]); + } + if (in_array($type, ['department', 'rule'], true) && empty($data['targetValue'])) { + throw ValidationException::withMessages(['targetValue' => ['قاعده یا دپارتمان را مشخص کنید.']]); + } + if ($type === 'individual' && ! User::query()->where('organization_id', $this->tenant->id())->whereKey($data['targetId'])->exists()) { + throw ValidationException::withMessages(['targetId' => ['کاربر معتبر نیست.']]); + } + if ($type === 'team' && ! Team::query()->where('organization_id', $this->tenant->id())->whereKey($data['targetId'])->exists()) { + throw ValidationException::withMessages(['targetId' => ['تیم معتبر نیست.']]); + } + } + + /** @return array */ + private function payload(Assignment $assignment): array + { + $content = $assignment->assignable_type === 'course' + ? CourseVersion::query()->with('course:id,title')->find($assignment->assignable_id)?->course?->title + : LearningPathVersion::query()->with('path:id,title')->find($assignment->assignable_id)?->path?->title; + $target = match ($assignment->target_type) { + 'individual' => User::query()->find($assignment->target_id)?->name, + 'team' => Team::query()->find($assignment->target_id)?->name, + 'department' => $assignment->target_value, + 'organization' => 'کل سازمان', + 'rule' => 'قاعده پویا', + 'bulk' => 'فهرست انتخابی', + default => '—', + }; + + $recipientCount = (int) ($assignment->users_count ?? $assignment->users()->count()); + $completedCount = (int) ($assignment->completed_count ?? 0); + $progress = isset($assignment->average_progress) ? (int) round((float) $assignment->average_progress) : null; + $status = $assignment->status === 'cancelled' ? 'stopped' + : ($assignment->status === 'draft' ? 'draft' + : ($assignment->starts_at?->isFuture() ? 'scheduled' : ($recipientCount > 0 && $completedCount >= $recipientCount ? 'completed' : 'active'))); + $remainingDays = $assignment->due_at ? now()->startOfDay()->diffInDays($assignment->due_at->startOfDay(), false) : null; + + return [ + 'id' => $assignment->getKey(), 'assignableType' => $assignment->assignable_type, 'assignableId' => $assignment->assignable_id, + 'contentTitle' => $content ?? 'محتوای حذف‌شده', 'targetType' => $assignment->target_type, 'targetId' => $assignment->target_id, + 'targetValue' => $assignment->target_value, 'targetLabel' => $target ?? '—', 'status' => $status, 'rawStatus' => $assignment->status, + 'mandatory' => $assignment->mandatory, 'startsAt' => $assignment->starts_at?->toISOString(), 'dueAt' => $assignment->due_at?->toISOString(), + 'recurringMonths' => $assignment->recurring_months, 'reminderDays' => $assignment->reminder_days, + 'escalationEnabled' => (bool) ($assignment->escalation_policy['enabled'] ?? false), 'recipientCount' => $recipientCount, + 'completedCount' => $completedCount, 'progress' => $progress, 'remainingDays' => $remainingDays, + 'lastActivityAt' => $assignment->last_activity_at ?? null, 'averageScore' => null, + 'createdAt' => $assignment->created_at?->toISOString(), 'updatedAt' => $assignment->updated_at?->toISOString(), + ]; + } + + private function withWorkspaceMetrics($query) + { + return $query->withCount('users') + ->withCount(['users as completed_count' => fn ($builder) => $builder->where('assignment_users.status', 'completed')]) + ->withAvg('users as average_progress', 'assignment_users.progress') + ->withMax('users as last_activity_at', 'assignment_users.updated_at'); + } + + private function authorize(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::AssignmentsManage), 403); + } +} diff --git a/backend/app/Modules/Capability/Application/CapabilityScoreEngine.php b/backend/app/Modules/Capability/Application/CapabilityScoreEngine.php new file mode 100644 index 0000000..5b4ecb5 --- /dev/null +++ b/backend/app/Modules/Capability/Application/CapabilityScoreEngine.php @@ -0,0 +1,140 @@ +where('organization_id', $organizationId) + ->where('learner_id', $learnerId) + ->where('taxonomy_node_id', $taxonomyNodeId) + ->orderBy('occurred_at') + ->get(); + + $projection = $this->project($evidence); + + return DB::transaction(function () use ($organizationId, $learnerId, $taxonomyNodeId, $projection) { + $current = CapabilityScore::query() + ->where('organization_id', $organizationId) + ->where('learner_id', $learnerId) + ->where('taxonomy_node_id', $taxonomyNodeId) + ->first(); + + $changed = ! $current + || (float) ($current->score ?? -1) !== (float) ($projection['score'] ?? -1) + || (float) $current->confidence !== $projection['confidence'] + || $current->evidence_count !== $projection['evidence_count'] + || $current->scoring_model_version !== $projection['scoring_model_version']; + + $trend = $current?->score !== null && $projection['score'] !== null + ? round($projection['score'] - (float) $current->score, 2) + : null; + + $score = CapabilityScore::query()->updateOrCreate( + [ + 'organization_id' => $organizationId, + 'learner_id' => $learnerId, + 'taxonomy_node_id' => $taxonomyNodeId, + ], + [...$projection, 'trend' => $trend, 'calculated_at' => now()], + ); + + if ($changed) { + CapabilitySnapshot::query()->create([ + 'organization_id' => $organizationId, + 'learner_id' => $learnerId, + 'taxonomy_node_id' => $taxonomyNodeId, + ...collect($projection)->only([ + 'score', 'confidence', 'confidence_level', 'evidence_count', + 'scoring_model_version', 'explanation', + ])->all(), + 'captured_at' => now(), + ]); + } + + return $score; + }); + } + + /** @param Collection $evidence */ + private function project(Collection $evidence): array + { + $halfLife = max(1, (int) config('capability.recency_half_life_days')); + $weighted = $evidence->map(function (EvidenceRecord $item) use ($halfLife) { + $ageDays = max(0, $item->occurred_at->diffInDays(now())); + $recency = 0.5 ** ($ageDays / $halfLife); + $effectiveWeight = (float) $item->strength * (float) $item->mapping_weight * $recency; + + return ['record' => $item, 'recency' => $recency, 'effective_weight' => $effectiveWeight]; + }); + + $effectiveEvidence = $weighted->sum('effective_weight'); + $minimum = (float) config('capability.minimum_effective_evidence'); + $score = $effectiveEvidence > 0 + ? round($weighted->sum(fn ($item) => (float) $item['record']->normalized_value * $item['effective_weight']) / $effectiveEvidence * 100, 2) + : null; + + $quantity = 1 - exp(-$effectiveEvidence / 2); + $diversity = min(1, $evidence->pluck('source_type')->unique()->count() / 3); + $recency = $weighted->max('recency') ?? 0; + $mean = $effectiveEvidence > 0 ? ($score ?? 0) / 100 : 0; + $variance = $effectiveEvidence > 0 + ? $weighted->sum(fn ($item) => $item['effective_weight'] * (((float) $item['record']->normalized_value - $mean) ** 2)) / $effectiveEvidence + : 1; + $consistency = max(0, 1 - min(1, sqrt($variance) / 0.5)); + $confidence = 0.45 * $quantity + 0.20 * $diversity + 0.20 * $recency + 0.15 * $consistency; + + if ($evidence->isNotEmpty() && $evidence->every(fn (EvidenceRecord $item) => $item->evidence_type === EvidenceType::Exposure)) { + $confidence = min($confidence, (float) config('capability.confidence.exposure_only_cap')); + } + + $confidence = round(max(0, min(1, $confidence)), 4); + $level = $this->confidenceLevel($confidence, $effectiveEvidence, $minimum); + + if ($level === ConfidenceLevel::Insufficient) { + $score = null; + } + + return [ + 'score' => $score, + 'confidence' => $confidence, + 'confidence_level' => $level, + 'evidence_count' => $evidence->count(), + 'last_evidence_at' => $evidence->last()?->occurred_at, + 'scoring_model_version' => (string) config('capability.scoring_model_version'), + 'explanation' => [ + 'effectiveEvidence' => round($effectiveEvidence, 4), + 'components' => compact('quantity', 'diversity', 'recency', 'consistency'), + 'evidenceRecordIds' => $evidence->modelKeys(), + 'policy' => ['recencyHalfLifeDays' => $halfLife, 'minimumEffectiveEvidence' => $minimum], + ], + ]; + } + + private function confidenceLevel(float $confidence, float $effectiveEvidence, float $minimum): ConfidenceLevel + { + if ($effectiveEvidence < $minimum) { + return ConfidenceLevel::Insufficient; + } + + if ($confidence >= (float) config('capability.confidence.high_threshold')) { + return ConfidenceLevel::High; + } + + if ($confidence >= (float) config('capability.confidence.medium_threshold')) { + return ConfidenceLevel::Medium; + } + + return ConfidenceLevel::Low; + } +} diff --git a/backend/app/Modules/Capability/Application/RecalculateCapability.php b/backend/app/Modules/Capability/Application/RecalculateCapability.php new file mode 100644 index 0000000..7182414 --- /dev/null +++ b/backend/app/Modules/Capability/Application/RecalculateCapability.php @@ -0,0 +1,18 @@ +engine->recalculate($event->organizationId, $event->learnerId, $event->taxonomyNodeId); + } +} diff --git a/backend/app/Modules/Capability/Domain/CapabilityScore.php b/backend/app/Modules/Capability/Domain/CapabilityScore.php new file mode 100644 index 0000000..215e04c --- /dev/null +++ b/backend/app/Modules/Capability/Domain/CapabilityScore.php @@ -0,0 +1,28 @@ + 'decimal:2', 'confidence' => 'decimal:4', + 'confidence_level' => ConfidenceLevel::class, 'evidence_count' => 'integer', + 'last_evidence_at' => 'immutable_datetime', 'explanation' => 'array', + 'calculated_at' => 'immutable_datetime', + ]; + } +} diff --git a/backend/app/Modules/Capability/Domain/CapabilitySnapshot.php b/backend/app/Modules/Capability/Domain/CapabilitySnapshot.php new file mode 100644 index 0000000..39a8d7e --- /dev/null +++ b/backend/app/Modules/Capability/Domain/CapabilitySnapshot.php @@ -0,0 +1,26 @@ + 'decimal:2', 'confidence' => 'decimal:4', + 'confidence_level' => ConfidenceLevel::class, 'evidence_count' => 'integer', + 'explanation' => 'array', 'captured_at' => 'immutable_datetime', + ]; + } +} diff --git a/backend/app/Modules/Capability/Domain/Enums/ConfidenceLevel.php b/backend/app/Modules/Capability/Domain/Enums/ConfidenceLevel.php new file mode 100644 index 0000000..1f82e02 --- /dev/null +++ b/backend/app/Modules/Capability/Domain/Enums/ConfidenceLevel.php @@ -0,0 +1,11 @@ +where('user_id', $learner->getKey())->where('course_version_id', $version->getKey())->first(); + if ($existing) { + return $existing->id; + } + $profile = DB::table('organization_profiles')->where('organization_id', $learner->organization_id)->first(); + $template = $templateId ? DB::table('certificate_templates')->where('organization_id', $learner->organization_id)->where('id', $templateId)->first() : DB::table('certificate_templates')->where('organization_id', $learner->organization_id)->where('is_default', true)->where('is_active', true)->first(); + $id = (string) Str::ulid(); + $code = Str::lower(Str::random(32)); + $number = 'ML-'.now()->format('Y').'-'.strtoupper(substr($id, -8)); + $url = rtrim((string) config('app.frontend_url'), '/').'/certificate/verify/'.$code; + $snapshot = ['schemaVersion' => 1, 'learnerName' => $learner->name, 'courseTitle' => $version->title, 'organizationName' => $learner->organization?->name, 'signatory' => $profile?->certificate_signatory, 'primaryColor' => $profile?->primary_color ?? '#5b3fd3', 'accentColor' => $profile?->accent_color ?? '#09bdd1', 'verificationUrl' => $url, 'template' => $template ? json_decode($template->canvas, true) : null]; + $qr = (new SvgWriter)->write(new QrCode(data: $url))->getString(); + $html = $this->html($snapshot, $number, now()->toDateString(), $qr); + $dompdf = new Dompdf(['isRemoteEnabled' => false]); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->setPaper('A4', 'landscape'); + $dompdf->render(); + $disk = (string) config('exports.disk', 'local'); + $path = 'certificates/'.$learner->organization_id.'/'.$id.'.pdf'; + Storage::disk($disk)->put($path, $dompdf->output()); + DB::transaction(function () use ($id, $learner, $version, $template, $code, $number, $expiresInMonths, $snapshot, $disk, $path): void { + DB::table('certificates')->insert(['id' => $id, 'organization_id' => $learner->organization_id, 'user_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'template_id' => $template?->id, 'serial' => (string) Str::uuid(), 'verification_code' => $code, 'certificate_number' => $number, 'issued_at' => now(), 'expires_at' => $expiresInMonths ? now()->addMonths($expiresInMonths) : null, 'snapshot' => json_encode($snapshot), 'disk' => $disk, 'path' => $path, 'created_at' => now(), 'updated_at' => now()]); + DB::table('in_app_notifications')->insert(['id' => (string) Str::ulid(), 'organization_id' => $learner->organization_id, 'recipient_id' => $learner->getKey(), 'actor_id' => null, 'type' => 'certificate.issued', 'title' => 'گواهی‌نامه جدید صادر شد', 'body' => 'گواهی‌نامه دوره «'.$version->title.'» آماده دریافت است.', 'target_url' => '/learn/progress', 'entity_type' => 'certificate', 'entity_id' => $id, 'data' => json_encode(['certificateNumber' => $number]), 'created_at' => now(), 'updated_at' => now()]); + }); + + return $id; + } + + /** @param array $snapshot */ + private function html(array $snapshot, string $number, string $issued, string $qr): string + { + $color = htmlspecialchars((string) $snapshot['primaryColor']); + $safe = fn (mixed $value) => htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + $qrData = 'data:image/svg+xml;base64,'.base64_encode($qr); + + return '
MicroLearn · '.$safe($snapshot['organizationName'] ?? '').'

گواهی‌نامه پایان دوره

گواهی می‌شود

'.$safe($snapshot['learnerName']).'

دوره

'.$safe($snapshot['courseTitle']).'
شماره: '.$safe($number).'
تاریخ صدور: '.$safe($issued).'
امضاکننده: '.$safe($snapshot['signatory'] ?? 'مدیریت آموزش').'
'; + } +} diff --git a/backend/app/Modules/Certificates/Http/CertificateController.php b/backend/app/Modules/Certificates/Http/CertificateController.php new file mode 100644 index 0000000..d037ad1 --- /dev/null +++ b/backend/app/Modules/Certificates/Http/CertificateController.php @@ -0,0 +1,145 @@ +authorize($request); + $items = DB::table('certificates as c')->join('users as u', 'u.id', '=', 'c.user_id')->join('course_versions as cv', 'cv.id', '=', 'c.course_version_id')->where('c.organization_id', $this->tenant->id())->orderByDesc('c.issued_at')->limit(100)->get(['c.id', 'c.certificate_number as number', 'c.verification_code as verificationCode', 'c.issued_at as issuedAt', 'c.expires_at as expiresAt', 'c.revoked_at as revokedAt', 'u.name as learnerName', 'cv.title as courseTitle']); + $templates = DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->where('is_active', true)->orderByDesc('is_default')->get()->map(fn ($row) => ['id' => $row->id, 'name' => $row->name, 'canvas' => json_decode($row->canvas, true), 'isDefault' => (bool) $row->is_default]); + $eligible = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id')->join('users as u', 'u.id', '=', 'au.user_id')->join('course_versions as cv', 'cv.id', '=', 'a.assignable_id')->where('a.organization_id', $this->tenant->id())->where('a.assignable_type', 'course')->where('au.status', 'completed')->whereNotExists(fn ($query) => $query->selectRaw('1')->from('certificates as c')->whereColumn('c.user_id', 'au.user_id')->whereColumn('c.course_version_id', 'a.assignable_id'))->limit(100)->get(['u.id as userId', 'u.name as learnerName', 'cv.id as courseVersionId', 'cv.title as courseTitle']); + + return response()->json(['data' => ['items' => $items, 'templates' => $templates, 'eligible' => $eligible]]); + } + + public function storeTemplate(Request $request): JsonResponse + { + $this->authorize($request); + $data = $request->validate(['name' => ['required', 'string', 'max:120'], 'canvas' => ['required', 'array'], 'isDefault' => ['nullable', 'boolean']]); + $id = (string) Str::ulid(); + DB::transaction(function () use ($request, $data, $id): void { + if ($data['isDefault'] ?? false) { + DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->update(['is_default' => false]); + } + DB::table('certificate_templates')->insert(['id' => $id, 'organization_id' => $this->tenant->id(), 'created_by' => $request->user()->getKey(), 'name' => $data['name'], 'canvas' => json_encode($data['canvas']), 'is_default' => $data['isDefault'] ?? false, 'is_active' => true, 'created_at' => now(), 'updated_at' => now()]); + }); + + return response()->json(['data' => ['id' => $id]], 201); + } + + public function updateTemplate(Request $request, string $template): JsonResponse + { + $this->authorize($request); + $data = $request->validate(['name' => ['required', 'string', 'max:120'], 'canvas' => ['required', 'array'], 'isDefault' => ['nullable', 'boolean']]); + $exists = DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->where('id', $template)->where('is_active', true)->exists(); + abort_unless($exists, 404); + DB::transaction(function () use ($data, $template): void { + if ($data['isDefault'] ?? false) { + DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->update(['is_default' => false]); + } + DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->where('id', $template)->update(['name' => $data['name'], 'canvas' => json_encode($data['canvas']), 'is_default' => $data['isDefault'] ?? false, 'updated_at' => now()]); + }); + + return response()->json(['data' => ['updated' => true]]); + } + + public function archiveTemplate(Request $request, string $template): JsonResponse + { + $this->authorize($request); + $updated = DB::table('certificate_templates')->where('organization_id', $this->tenant->id())->where('id', $template)->where('is_active', true)->update(['is_active' => false, 'is_default' => false, 'updated_at' => now()]); + abort_unless($updated === 1, 404); + + return response()->json(['data' => ['archived' => true]]); + } + + public function issue(Request $request): JsonResponse + { + $this->authorize($request); + $data = $request->validate(['userId' => ['required', 'string'], 'courseVersionId' => ['required', 'string'], 'templateId' => ['nullable', 'string'], 'expiresInMonths' => ['nullable', 'integer', 'min:1', 'max:120']]); + $eligible = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id')->where('a.organization_id', $this->tenant->id())->where('a.assignable_type', 'course')->where('a.assignable_id', $data['courseVersionId'])->where('au.user_id', $data['userId'])->where('au.status', 'completed')->exists(); + abort_unless($eligible, 422, 'Completion rules are not satisfied for this learner and course version.'); + $learner = User::query()->where('organization_id', $this->tenant->id())->findOrFail($data['userId']); + $version = CourseVersion::query()->where('organization_id', $this->tenant->id())->findOrFail($data['courseVersionId']); + $id = $this->issuer->issue($learner, $version, $data['templateId'] ?? null, $data['expiresInMonths'] ?? null); + + return response()->json(['data' => ['id' => $id]], 201); + } + + public function issueBulk(Request $request): JsonResponse + { + $this->authorize($request); + $data = $request->validate(['items' => ['required', 'array', 'min:1', 'max:100'], 'items.*.userId' => ['required', 'string'], 'items.*.courseVersionId' => ['required', 'string'], 'templateId' => ['nullable', 'string'], 'expiresInMonths' => ['nullable', 'integer', 'min:1', 'max:120']]); + $issued = 0; + $skipped = 0; + foreach ($data['items'] as $item) { + $eligible = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id')->where('a.organization_id', $this->tenant->id())->where('a.assignable_type', 'course')->where('a.assignable_id', $item['courseVersionId'])->where('au.user_id', $item['userId'])->where('au.status', 'completed')->exists(); + $exists = DB::table('certificates')->where('organization_id', $this->tenant->id())->where('user_id', $item['userId'])->where('course_version_id', $item['courseVersionId'])->exists(); + if (! $eligible || $exists) { + $skipped++; + + continue; + } + $learner = User::query()->where('organization_id', $this->tenant->id())->find($item['userId']); + $version = CourseVersion::query()->where('organization_id', $this->tenant->id())->find($item['courseVersionId']); + if (! $learner || ! $version) { + $skipped++; + + continue; + } + $this->issuer->issue($learner, $version, $data['templateId'] ?? null, $data['expiresInMonths'] ?? null); + $issued++; + } + + return response()->json(['data' => ['issued' => $issued, 'skipped' => $skipped]], 201); + } + + public function revoke(Request $request, string $certificate): JsonResponse + { + $this->authorize($request); + $data = $request->validate(['reason' => ['required', 'string', 'max:500']]); + $updated = DB::table('certificates')->where('organization_id', $this->tenant->id())->where('id', $certificate)->whereNull('revoked_at')->update(['revoked_at' => now(), 'revocation_reason' => $data['reason'], 'updated_at' => now()]); + abort_unless($updated === 1, 404); + + return response()->json(['data' => ['revoked' => true]]); + } + + public function download(Request $request, string $certificate) + { + $this->authorize($request); + $row = DB::table('certificates')->where('organization_id', $this->tenant->id())->where('id', $certificate)->first(); + abort_unless($row && $row->path && $row->disk && Storage::disk($row->disk)->exists($row->path), 404); + + return Storage::disk($row->disk)->download($row->path, ($row->certificate_number ?: 'certificate').'.pdf'); + } + + public function verify(string $code): JsonResponse + { + $row = DB::table('certificates as c')->join('users as u', 'u.id', '=', 'c.user_id')->join('course_versions as cv', 'cv.id', '=', 'c.course_version_id')->join('organizations as o', 'o.id', '=', 'c.organization_id')->where('c.verification_code', $code)->first(['c.certificate_number as number', 'c.issued_at as issuedAt', 'c.expires_at as expiresAt', 'c.revoked_at as revokedAt', 'c.revocation_reason as revocationReason', 'u.name as learnerName', 'cv.title as courseTitle', 'o.name as organizationName']); + abort_unless($row, 404); + $status = $row->revokedAt ? 'revoked' : ($row->expiresAt && now()->isAfter($row->expiresAt) ? 'expired' : 'valid'); + + return response()->json(['data' => ['status' => $status, ...((array) $row)]]); + } + + private function authorize(Request $request): void + { + abort_unless($request->user() && $this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } +} diff --git a/backend/app/Modules/Collaboration/Application/CollaborationChangeFeed.php b/backend/app/Modules/Collaboration/Application/CollaborationChangeFeed.php new file mode 100644 index 0000000..ab37984 --- /dev/null +++ b/backend/app/Modules/Collaboration/Application/CollaborationChangeFeed.php @@ -0,0 +1,22 @@ + $payload */ + public function publish(string $organizationId, string $versionId, ?User $actor, string $type, array $payload): string + { + $id = (string) str()->ulid(); + DB::table('collaboration_changes')->insert([ + 'id' => $id, 'organization_id' => $organizationId, 'course_version_id' => $versionId, + 'actor_id' => $actor?->getKey(), 'schema_version' => 1, 'type' => $type, + 'payload' => json_encode($payload, JSON_THROW_ON_ERROR), 'occurred_at' => now(), 'created_at' => now(), 'updated_at' => now(), + ]); + + return $id; + } +} diff --git a/backend/app/Modules/Collaboration/Application/NotificationOrchestrator.php b/backend/app/Modules/Collaboration/Application/NotificationOrchestrator.php new file mode 100644 index 0000000..c5334a8 --- /dev/null +++ b/backend/app/Modules/Collaboration/Application/NotificationOrchestrator.php @@ -0,0 +1,119 @@ + $message */ + public function send(User $recipient, ?User $actor, array $message): bool + { + if (! $this->allowedByPreference($recipient, $message['preferenceKey'] ?? null, (bool) ($message['mandatory'] ?? false))) { + return false; + } + + return DB::table('in_app_notifications')->insertOrIgnore([ + 'id' => (string) Str::ulid(), 'organization_id' => $recipient->organization_id, + 'recipient_id' => $recipient->getKey(), 'actor_id' => $actor?->getKey(), 'type' => $message['type'], + 'title' => $message['title'], 'body' => Str::limit($message['body'], 1000), 'target_url' => $message['targetUrl'] ?? null, + 'entity_type' => $message['entityType'], 'entity_id' => $message['entityId'] ?? null, + 'data' => json_encode(['schemaVersion' => 1, 'mandatory' => (bool) ($message['mandatory'] ?? false)]), + 'idempotency_key' => $message['idempotencyKey'], 'read_at' => null, 'created_at' => now(), 'updated_at' => now(), + ]) === 1; + } + + /** @param array $message */ + public function schedule(User $recipient, ?User $actor, array $message): bool + { + $existing = DB::table('notification_schedules')->where('idempotency_key', $message['idempotencyKey'])->first(); + if ($existing && $existing->status === 'sent') { + return false; + } + $values = [ + 'organization_id' => $recipient->organization_id, 'recipient_id' => $recipient->getKey(), 'actor_id' => $actor?->getKey(), + 'type' => $message['type'], 'title' => $message['title'], 'body' => Str::limit($message['body'], 1000), + 'target_url' => $message['targetUrl'] ?? null, 'entity_type' => $message['entityType'], 'entity_id' => $message['entityId'] ?? null, + 'preference_key' => $message['preferenceKey'] ?? null, 'mandatory' => (bool) ($message['mandatory'] ?? false), + 'condition' => json_encode($message['condition'] ?? []), 'scheduled_at' => $message['scheduledAt'], + 'status' => 'pending', 'last_error' => null, 'updated_at' => now(), + ]; + if ($existing) { + DB::table('notification_schedules')->where('id', $existing->id)->update($values); + + return true; + } + + return DB::table('notification_schedules')->insert([...$values, 'id' => (string) Str::ulid(), 'idempotency_key' => $message['idempotencyKey'], 'created_at' => now()]); + } + + public function cancelPending(string $organizationId, string $entityType, string $entityId): int + { + return DB::table('notification_schedules')->where('organization_id', $organizationId) + ->where('entity_type', $entityType)->where('entity_id', $entityId)->where('status', 'pending') + ->update(['status' => 'cancelled', 'updated_at' => now()]); + } + + /** @return array{sent: int, skipped: int, failed: int} */ + public function deliverDue(): array + { + $result = ['sent' => 0, 'skipped' => 0, 'failed' => 0]; + $rows = DB::table('notification_schedules')->where('status', 'pending')->where('scheduled_at', '<=', now())->orderBy('scheduled_at')->limit(500)->get(); + foreach ($rows as $row) { + if (DB::table('notification_schedules')->where('id', $row->id)->where('status', 'pending')->update(['status' => 'processing', 'updated_at' => now()]) !== 1) { + continue; + } + try { + $condition = json_decode($row->condition ?: '{}', true); + if (! $this->conditionMatches($row, $condition)) { + DB::table('notification_schedules')->where('id', $row->id)->update(['status' => 'skipped', 'updated_at' => now()]); + $result['skipped']++; + + continue; + } + $recipient = User::query()->where('organization_id', $row->organization_id)->find($row->recipient_id); + $actor = $row->actor_id ? User::query()->find($row->actor_id) : null; + $sent = $recipient && $this->send($recipient, $actor, [ + 'type' => $row->type, 'title' => $row->title, 'body' => $row->body, 'targetUrl' => $row->target_url, + 'entityType' => $row->entity_type, 'entityId' => $row->entity_id, 'preferenceKey' => $row->preference_key, + 'mandatory' => (bool) $row->mandatory, 'idempotencyKey' => 'schedule:'.$row->idempotency_key, + ]); + DB::table('notification_schedules')->where('id', $row->id)->update(['status' => $sent ? 'sent' : 'skipped', 'sent_at' => $sent ? now() : null, 'updated_at' => now()]); + $result[$sent ? 'sent' : 'skipped']++; + } catch (Throwable $exception) { + DB::table('notification_schedules')->where('id', $row->id)->update(['status' => 'failed', 'last_error' => Str::limit($exception->getMessage(), 1000), 'updated_at' => now()]); + $result['failed']++; + } + } + + return $result; + } + + /** @param array $condition */ + private function conditionMatches(object $schedule, array $condition): bool + { + if ($schedule->entity_type !== 'assignment') { + return true; + } + $status = DB::table('assignment_users')->where('assignment_id', $schedule->entity_id)->where('user_id', $schedule->recipient_id)->value('status'); + if (! $status || in_array($status, ['completed', 'cancelled'], true)) { + return false; + } + + return ($condition['status'] ?? null) !== 'not_started' || $status === 'assigned'; + } + + private function allowedByPreference(User $recipient, ?string $key, bool $mandatory): bool + { + if ($mandatory || ! $key) { + return true; + } + $stored = DB::table('user_preferences')->where('user_id', $recipient->getKey())->value('preferences'); + $preferences = json_decode($stored ?: '{}', true); + + return ($preferences[$key] ?? true) !== false && ($preferences['inAppNotifications'] ?? true) !== false; + } +} diff --git a/backend/app/Modules/Collaboration/Application/SoftLockService.php b/backend/app/Modules/Collaboration/Application/SoftLockService.php new file mode 100644 index 0000000..b6ba017 --- /dev/null +++ b/backend/app/Modules/Collaboration/Application/SoftLockService.php @@ -0,0 +1,22 @@ +where('block_id', $block->getKey())->where('expires_at', '>', now())->first(); + if ($lock && $lock->holder_id !== $actor->getKey()) { + $holder = User::query()->find($lock->holder_id); + throw ValidationException::withMessages(['lock' => ['This block is being edited by '.($holder?->name ?? 'another designer').'. Retry after the soft lock expires.']]); + } + } +} diff --git a/backend/app/Modules/Collaboration/Http/CollaborationController.php b/backend/app/Modules/Collaboration/Http/CollaborationController.php new file mode 100644 index 0000000..786efe4 --- /dev/null +++ b/backend/app/Modules/Collaboration/Http/CollaborationController.php @@ -0,0 +1,413 @@ +version($request, $version); + $data = $request->validate(['clientSessionId' => ['required', 'uuid'], 'lessonId' => ['nullable', 'string'], 'since' => ['nullable', 'string', 'max:26']]); + if (! empty($data['lessonId'])) { + abort_unless($versionModel->lessons()->whereKey($data['lessonId'])->exists(), 404); + } + $this->expire(); + $session = DB::table('collaboration_sessions')->where('course_version_id', $version)->where('user_id', $request->user()->getKey())->where('client_session_id', $data['clientSessionId'])->first(); + if ($session) { + DB::table('collaboration_sessions')->where('id', $session->id)->update(['lesson_id' => $data['lessonId'] ?? null, 'last_seen_at' => now(), 'updated_at' => now()]); + } else { + DB::table('collaboration_sessions')->insert(['id' => (string) str()->ulid(), 'organization_id' => $this->tenant->id(), 'course_version_id' => $version, 'lesson_id' => $data['lessonId'] ?? null, 'user_id' => $request->user()->getKey(), 'client_session_id' => $data['clientSessionId'], 'transport' => 'change-feed', 'last_seen_at' => now(), 'created_at' => now(), 'updated_at' => now()]); + $this->changes->publish($this->tenant->id(), $version, $request->user(), 'presence.joined', ['userId' => $request->user()->getKey()]); + } + + $presence = DB::table('collaboration_sessions as cs')->join('users as u', 'u.id', '=', 'cs.user_id')->where('cs.course_version_id', $version)->where('cs.last_seen_at', '>', now()->subSeconds(75))->orderBy('u.name')->get(['u.id', 'u.name', 'cs.lesson_id as lessonId', 'cs.last_seen_at as lastSeenAt'])->unique('id')->values(); + $locks = DB::table('block_soft_locks as l')->join('users as u', 'u.id', '=', 'l.holder_id')->where('l.course_version_id', $version)->where('l.expires_at', '>', now())->get(['l.block_id as blockId', 'l.holder_id as holderId', 'u.name as holderName', 'l.lock_token as lockToken', 'l.expires_at as expiresAt'])->map(fn ($lock) => ['blockId' => $lock->blockId, 'holderId' => $lock->holderId, 'holderName' => $lock->holderName, 'lockToken' => $lock->holderId === $request->user()->getKey() ? $lock->lockToken : null, 'expiresAt' => $lock->expiresAt]); + $changeQuery = DB::table('collaboration_changes')->where('course_version_id', $version)->orderBy('id')->limit(100); + if (! empty($data['since'])) { + $changeQuery->where('id', '>', $data['since']); + } + $changeRows = $changeQuery->get()->map(fn ($row) => ['cursor' => $row->id, 'schemaVersion' => $row->schema_version, 'type' => $row->type, 'actorId' => $row->actor_id, 'payload' => json_decode($row->payload, true), 'occurredAt' => $row->occurred_at]); + + return response()->json(['data' => ['transport' => ['mode' => 'change-feed', 'pollAfterMs' => 5000, 'degraded' => false], 'presence' => $presence, 'locks' => $locks, 'changes' => $changeRows, 'cursor' => $changeRows->last()['cursor'] ?? ($data['since'] ?? null), 'threads' => $this->threadPayloads($versionModel, $request->user())]]); + } + + public function acquireLock(Request $request, string $block): JsonResponse + { + $model = $this->block($request, $block); + abort_if($this->version($request, $model->course_version_id)->status !== CourseVersionStatus::Draft, 409, 'Only draft blocks can be locked for editing.'); + $data = $request->validate(['clientSessionId' => ['required', 'uuid']]); + $result = DB::transaction(function () use ($request, $model, $data) { + $existing = DB::table('block_soft_locks')->where('block_id', $model->getKey())->lockForUpdate()->first(); + if ($existing && CarbonImmutable::parse($existing->expires_at)->isFuture() && $existing->holder_id !== $request->user()->getKey()) { + $holder = User::query()->find($existing->holder_id); + + return ['conflict' => true, 'holderId' => $holder?->getKey(), 'holderName' => $holder?->name ?? 'another designer']; + } + $token = $existing && $existing->holder_id === $request->user()->getKey() ? $existing->lock_token : (string) Str::uuid(); + DB::table('block_soft_locks')->updateOrInsert(['block_id' => $model->getKey()], ['id' => $existing?->id ?? (string) str()->ulid(), 'organization_id' => $this->tenant->id(), 'course_version_id' => $model->course_version_id, 'lesson_id' => $model->lesson_id, 'holder_id' => $request->user()->getKey(), 'client_session_id' => $data['clientSessionId'], 'lock_token' => $token, 'expires_at' => now()->addSeconds(SoftLockService::LEASE_SECONDS), 'created_at' => $existing?->created_at ?? now(), 'updated_at' => now()]); + + return ['conflict' => false, 'token' => $token, 'expiresAt' => now()->addSeconds(SoftLockService::LEASE_SECONDS)->toISOString()]; + }); + if ($result['conflict']) { + $holder = ! empty($result['holderId']) ? User::query()->find($result['holderId']) : null; + if ($holder) { + $this->notify(collect([$request->user()]), $holder, 'collaboration.lock_conflict', 'Block currently locked', 'This block is being edited by '.$result['holderName'].'.', '/app/reviews', 'block', $model->getKey()); + } + throw ValidationException::withMessages(['lock' => ['This block is being edited by '.$result['holderName'].'.']]); + } + unset($result['conflict']); + $this->changes->publish($this->tenant->id(), $model->course_version_id, $request->user(), 'lock.acquired', ['blockId' => $model->getKey(), 'holderId' => $request->user()->getKey()]); + + return response()->json(['data' => $result]); + } + + public function renewLock(Request $request, string $block): JsonResponse + { + $model = $this->block($request, $block); + $data = $request->validate(['lockToken' => ['required', 'uuid']]); + $updated = DB::table('block_soft_locks')->where('block_id', $model->getKey())->where('holder_id', $request->user()->getKey())->where('lock_token', $data['lockToken'])->where('expires_at', '>', now())->update(['expires_at' => now()->addSeconds(SoftLockService::LEASE_SECONDS), 'updated_at' => now()]); + abort_unless($updated === 1, 409, 'The soft lock expired or belongs to another session.'); + + return response()->json(['data' => ['lockToken' => $data['lockToken'], 'expiresAt' => now()->addSeconds(SoftLockService::LEASE_SECONDS)->toISOString()]]); + } + + public function releaseLock(Request $request, string $block): JsonResponse + { + $model = $this->block($request, $block); + $data = $request->validate(['lockToken' => ['required', 'uuid']]); + DB::table('block_soft_locks')->where('block_id', $model->getKey())->where('holder_id', $request->user()->getKey())->where('lock_token', $data['lockToken'])->delete(); + $this->changes->publish($this->tenant->id(), $model->course_version_id, $request->user(), 'lock.released', ['blockId' => $model->getKey()]); + + return response()->json(['data' => ['released' => true]]); + } + + public function createThread(Request $request, string $version): JsonResponse + { + $versionModel = $this->version($request, $version); + $data = $request->validate(['title' => ['nullable', 'string', 'max:180'], 'body' => ['required', 'string', 'max:5000'], 'lessonId' => ['nullable', 'string'], 'blockId' => ['nullable', 'string'], 'mentionUserIds' => ['nullable', 'array', 'max:20'], 'mentionUserIds.*' => ['string', 'distinct'], 'assigneeUserIds' => ['nullable', 'array', 'max:20'], 'assigneeUserIds.*' => ['string', 'distinct'], 'attachmentAssetIds' => ['nullable', 'array', 'max:10'], 'attachmentAssetIds.*' => ['string', 'distinct'], 'clientMutationId' => ['nullable', 'uuid']]); + if (! empty($data['clientMutationId'])) { + $existing = DB::table('review_threads')->where('organization_id', $this->tenant->id())->where('author_id', $request->user()->getKey())->where('client_mutation_id', $data['clientMutationId'])->first(); + if ($existing) { + return response()->json(['data' => $this->threadPayloads($versionModel, $request->user())->firstWhere('id', $existing->id)]); + } + } + $block = ! empty($data['blockId']) ? $this->block($request, $data['blockId']) : null; + abort_if($block && $block->course_version_id !== $version, 422, 'Comment target does not belong to the Course Version.'); + $mentions = $this->mentions($data['mentionUserIds'] ?? []); + $assignees = $this->mentions($data['assigneeUserIds'] ?? []); + $attachments = $this->attachments($data['attachmentAssetIds'] ?? []); + $id = (string) str()->ulid(); + DB::table('review_threads')->insert(['id' => $id, 'organization_id' => $this->tenant->id(), 'course_version_id' => $version, 'lesson_id' => $data['lessonId'] ?? $block?->lesson_id, 'block_id' => $block?->getKey(), 'author_id' => $request->user()->getKey(), 'client_mutation_id' => $data['clientMutationId'] ?? null, 'status' => 'open', 'title' => $data['title'] ?? null, 'body' => trim($data['body']), 'mention_user_ids' => json_encode($mentions->pluck('id')->all()), 'assignee_user_ids' => json_encode($assignees->pluck('id')->all()), 'attachment_asset_ids' => json_encode($attachments->pluck('id')->all()), 'created_at' => now(), 'updated_at' => now()]); + $url = '/app/courses/'.$versionModel->course_id.'/versions/'.$version.'/lessons/'.($data['lessonId'] ?? $block?->lesson_id).'/builder?thread='.$id; + $this->notify($mentions, $request->user(), 'review.mentioned', 'You were mentioned in a review', $data['body'], $url, 'review_thread', $id); + $this->notify($assignees, $request->user(), 'review.assigned', 'A course review was assigned to you', $data['body'], $url, 'review_thread', $id); + $this->changes->publish($this->tenant->id(), $version, $request->user(), 'thread.created', ['threadId' => $id, 'blockId' => $block?->getKey()]); + + return response()->json(['data' => $this->threadPayloads($versionModel, $request->user())->firstWhere('id', $id)], 201); + } + + public function reply(Request $request, string $thread): JsonResponse + { + $row = $this->thread($request, $thread); + $data = $request->validate(['body' => ['required', 'string', 'max:5000'], 'mentionUserIds' => ['nullable', 'array', 'max:20'], 'mentionUserIds.*' => ['string', 'distinct'], 'attachmentAssetIds' => ['nullable', 'array', 'max:10'], 'attachmentAssetIds.*' => ['string', 'distinct'], 'clientMutationId' => ['nullable', 'uuid']]); + if (! empty($data['clientMutationId'])) { + $existing = DB::table('review_replies')->where('organization_id', $this->tenant->id())->where('author_id', $request->user()->getKey())->where('client_mutation_id', $data['clientMutationId'])->first(); + if ($existing) { + return response()->json(['data' => ['id' => $existing->id]]); + } + } + $mentions = $this->mentions($data['mentionUserIds'] ?? []); + $attachments = $this->attachments($data['attachmentAssetIds'] ?? []); + $id = (string) str()->ulid(); + DB::table('review_replies')->insert(['id' => $id, 'organization_id' => $this->tenant->id(), 'review_thread_id' => $thread, 'author_id' => $request->user()->getKey(), 'client_mutation_id' => $data['clientMutationId'] ?? null, 'body' => trim($data['body']), 'mention_user_ids' => json_encode($mentions->pluck('id')->all()), 'attachment_asset_ids' => json_encode($attachments->pluck('id')->all()), 'created_at' => now(), 'updated_at' => now()]); + $recipients = User::query()->whereIn('id', collect([$row->author_id])->merge($mentions->pluck('id'))->unique()->reject(fn ($id) => $id === $request->user()->getKey()))->get(); + $this->notify($recipients, $request->user(), 'review.replied', 'New review reply', $data['body'], '/app/reviews?thread='.$thread, 'review_thread', $thread); + $this->changes->publish($this->tenant->id(), $row->course_version_id, $request->user(), 'thread.replied', ['threadId' => $thread, 'replyId' => $id]); + + return response()->json(['data' => $this->messagePayload(DB::table('review_replies')->where('id', $id)->firstOrFail(), $request->user())], 201); + } + + public function resolve(Request $request, string $thread): JsonResponse + { + $row = $this->thread($request, $thread); + $data = $request->validate(['resolved' => ['required', 'boolean']]); + DB::table('review_threads')->where('id', $thread)->update(['status' => $data['resolved'] ? 'resolved' : 'open', 'resolved_by' => $data['resolved'] ? $request->user()->getKey() : null, 'resolved_at' => $data['resolved'] ? now() : null, 'updated_at' => now()]); + $author = User::query()->where('organization_id', $this->tenant->id())->find($row->author_id); + if ($author) { + $this->notify(collect([$author]), $request->user(), $data['resolved'] ? 'review.resolved' : 'review.reopened', $data['resolved'] ? 'Review resolved' : 'Review reopened', $data['resolved'] ? 'Your review thread was marked as resolved.' : 'Your review thread was reopened.', '/app/reviews?thread='.$thread, 'review_thread', $thread); + } + $this->changes->publish($this->tenant->id(), $row->course_version_id, $request->user(), $data['resolved'] ? 'thread.resolved' : 'thread.reopened', ['threadId' => $thread]); + + return response()->json(['data' => ['id' => $thread, 'status' => $data['resolved'] ? 'resolved' : 'open']]); + } + + public function assign(Request $request, string $thread): JsonResponse + { + $row = $this->thread($request, $thread); + $data = $request->validate(['userIds' => ['present', 'array', 'max:20'], 'userIds.*' => ['string', 'distinct']]); + $assignees = $this->mentions($data['userIds']); + $previous = collect(json_decode($row->assignee_user_ids ?? '[]', true)); + DB::table('review_threads')->where('id', $thread)->update(['assignee_user_ids' => json_encode($assignees->pluck('id')->all()), 'updated_at' => now()]); + $newAssignees = $assignees->reject(fn (User $user) => $previous->contains($user->getKey())); + $this->notify($newAssignees, $request->user(), 'review.assigned', 'A course review was assigned to you', $row->body, '/app/reviews?thread='.$thread, 'review_thread', $thread); + $this->changes->publish($this->tenant->id(), $row->course_version_id, $request->user(), 'thread.assigned', ['threadId' => $thread, 'assigneeUserIds' => $assignees->pluck('id')->all()]); + + return response()->json(['data' => ['id' => $thread, 'assignees' => $assignees->map(fn (User $user) => ['id' => $user->getKey(), 'name' => $user->name])->values()]]); + } + + public function markRead(Request $request, string $thread): JsonResponse + { + $this->thread($request, $thread); + DB::table('in_app_notifications')->where('organization_id', $this->tenant->id())->where('recipient_id', $request->user()->getKey())->where('entity_type', 'review_thread')->where('entity_id', $thread)->whereNull('read_at')->update(['read_at' => now(), 'updated_at' => now()]); + + return response()->json(['data' => ['read' => true]]); + } + + public function updateMessage(Request $request, string $kind, string $message): JsonResponse + { + $data = $request->validate(['body' => ['required', 'string', 'max:5000']]); + $table = $kind === 'thread' ? 'review_threads' : ($kind === 'reply' ? 'review_replies' : null); + abort_unless($table, 404); + $row = DB::table($table)->where('organization_id', $this->tenant->id())->where('id', $message)->firstOrFail(); + $this->authorizeReview($request); + abort_unless($row->author_id === $request->user()->getKey(), 403); + $changes = ['body' => trim($data['body']), 'updated_at' => now()]; + if ($kind === 'reply') { + $changes['edited_at'] = now(); + } + DB::table($table)->where('id', $message)->update($changes); + + return response()->json(['data' => ['id' => $message, 'body' => $changes['body'], 'editedAt' => now()->toISOString()]]); + } + + public function deleteMessage(Request $request, string $kind, string $message): JsonResponse + { + $table = $kind === 'thread' ? 'review_threads' : ($kind === 'reply' ? 'review_replies' : null); + abort_unless($table, 404); + $row = DB::table($table)->where('organization_id', $this->tenant->id())->where('id', $message)->firstOrFail(); + $this->authorizeReview($request); + abort_unless($row->author_id === $request->user()->getKey(), 403); + if ($kind === 'thread') { + abort_if(DB::table('review_replies')->where('review_thread_id', $message)->exists(), 409, 'A conversation with replies cannot be deleted.'); + } + DB::table($table)->where('id', $message)->delete(); + + return response()->json(status: 204); + } + + public function react(Request $request, string $thread): JsonResponse + { + $row = $this->thread($request, $thread); + $data = $request->validate(['reaction' => ['required', 'in:helpful,like,celebrate']]); + $existing = DB::table('review_reactions')->where('review_thread_id', $thread)->where('user_id', $request->user()->getKey())->where('reaction', $data['reaction']); + $active = ! $existing->exists(); + if ($active) { + DB::table('review_reactions')->insert(['id' => (string) str()->ulid(), 'organization_id' => $this->tenant->id(), 'review_thread_id' => $thread, 'review_reply_id' => null, 'user_id' => $request->user()->getKey(), 'reaction' => $data['reaction'], 'created_at' => now(), 'updated_at' => now()]); + } else { + $existing->delete(); + } + if ($active) { + $author = User::query()->where('organization_id', $this->tenant->id())->find($row->author_id); + if ($author) { + $this->notify(collect([$author]), $request->user(), 'review.reacted', 'New reaction to your review', 'A teammate reacted to your review.', '/app/reviews?thread='.$thread, 'review_thread', $thread); + } + } + $this->changes->publish($this->tenant->id(), $row->course_version_id, $request->user(), 'thread.reacted', ['threadId' => $thread, 'reaction' => $data['reaction'], 'active' => $active]); + + return response()->json(['data' => ['active' => $active]]); + } + + public function reviewCenter(Request $request): JsonResponse + { + $this->authorizeReview($request); + $filters = $request->validate([ + 'courseId' => ['nullable', 'string'], 'versionId' => ['nullable', 'string'], + 'search' => ['nullable', 'string', 'max:160'], 'status' => ['nullable', 'in:all,open,resolved,mine'], + 'sort' => ['nullable', 'in:newest,oldest,activity'], + ]); + $versions = CourseVersion::query()->where('organization_id', $this->tenant->id()) + ->when($filters['courseId'] ?? null, fn ($query, string $id) => $query->where('course_id', $id)) + ->when($filters['versionId'] ?? null, fn ($query, string $id) => $query->whereKey($id)) + ->whereIn('id', DB::table('review_threads')->where('organization_id', $this->tenant->id())->select('course_version_id'))->get(); + $all = $versions->flatMap(fn (CourseVersion $version) => $this->threadPayloads($version, $request->user()))->values(); + $summary = [ + 'open' => $all->where('status', 'open')->count(), + 'resolved' => $all->where('status', 'resolved')->count(), + 'mentions' => $all->where('isMentioned', true)->count(), + ]; + $items = $all + ->when(($filters['status'] ?? 'all') === 'open', fn (Collection $items) => $items->where('status', 'open')) + ->when(($filters['status'] ?? 'all') === 'resolved', fn (Collection $items) => $items->where('status', 'resolved')) + ->when(($filters['status'] ?? 'all') === 'mine', fn (Collection $items) => $items->filter(fn (array $item) => $item['isMine'])) + ->when($filters['search'] ?? null, function (Collection $items, string $search) { + $needle = mb_strtolower($search); + + return $items->filter(fn (array $item) => str_contains(mb_strtolower($item['title'].' '.$item['body'].' '.$item['location']['label']), $needle)); + }); + $items = match ($filters['sort'] ?? 'activity') { + 'oldest' => $items->sortBy('createdAt'), + 'newest' => $items->sortByDesc('createdAt'), + default => $items->sortByDesc('updatedAt'), + }; + + return response()->json(['data' => ['summary' => $summary, 'items' => $items->values(), 'permissions' => ['canResolve' => true, 'canAssign' => true]]]); + } + + private function authorize(Request $request): void + { + abort_unless($request->user() && $this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } + + private function authorizeReview(Request $request): void + { + abort_unless($request->user() && $this->permissions->allows($request->user(), Permission::CoursesReview), 403); + } + + private function version(Request $request, string $id): CourseVersion + { + $this->authorize($request); + + return CourseVersion::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function block(Request $request, string $id): Block + { + $this->authorize($request); + + return Block::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function thread(Request $request, string $id): object + { + $this->authorizeReview($request); + + return DB::table('review_threads')->where('organization_id', $this->tenant->id())->where('id', $id)->firstOrFail(); + } + + /** @param list $ids */ + private function mentions(array $ids): Collection + { + $users = User::query()->where('organization_id', $this->tenant->id())->where('status', 'active')->whereIn('role', [UserRole::CourseDesigner, UserRole::Manager])->whereIn('id', $ids)->get(); + if ($users->count() !== count($ids)) { + throw ValidationException::withMessages(['mentionUserIds' => ['Every mention must identify an active user in this organization.']]); + } + + return $users; + } + + /** @param list $ids */ + private function attachments(array $ids): Collection + { + $assets = Asset::query()->where('organization_id', $this->tenant->id())->whereIn('id', $ids)->get(); + if ($assets->count() !== count($ids)) { + throw ValidationException::withMessages(['attachmentAssetIds' => ['Every attachment must identify a file in this organization.']]); + } + + return $assets; + } + + private function notify(Collection $recipients, User $actor, string $type, string $title, string $body, string $url, string $entityType, string $entityId): void + { + foreach ($recipients->reject(fn (User $user) => $user->is($actor)) as $recipient) { + DB::table('in_app_notifications')->insert(['id' => (string) str()->ulid(), 'organization_id' => $this->tenant->id(), 'recipient_id' => $recipient->getKey(), 'actor_id' => $actor->getKey(), 'type' => $type, 'title' => $title, 'body' => Str::limit($body, 500), 'target_url' => $url, 'entity_type' => $entityType, 'entity_id' => $entityId, 'data' => json_encode(['schemaVersion' => 1]), 'read_at' => null, 'created_at' => now(), 'updated_at' => now()]); + } + } + + private function expire(): void + { + DB::table('collaboration_sessions')->where('last_seen_at', '<=', now()->subSeconds(75))->delete(); + DB::table('block_soft_locks')->where('expires_at', '<=', now())->delete(); + } + + private function threadPayloads(CourseVersion $version, User $viewer): Collection + { + $threads = DB::table('review_threads as rt')->join('users as u', 'u.id', '=', 'rt.author_id')->where('rt.course_version_id', $version->getKey())->orderByDesc('rt.updated_at')->get(['rt.*', 'u.name as authorName']); + + return $threads->map(function ($thread) use ($version, $viewer) { + $replyRows = DB::table('review_replies as rr')->join('users as u', 'u.id', '=', 'rr.author_id')->where('rr.review_thread_id', $thread->id)->orderBy('rr.created_at')->get(['rr.*', 'u.name as authorName']); + $replies = $replyRows->map(fn ($reply) => $this->messagePayload($reply, $viewer)); + $reactions = DB::table('review_reactions')->where('review_thread_id', $thread->id)->select('reaction', DB::raw('count(*) as count'))->groupBy('reaction')->pluck('count', 'reaction'); + $mentionIds = collect(json_decode($thread->mention_user_ids ?? '[]', true)); + $assigneeIds = collect(json_decode($thread->assignee_user_ids ?? '[]', true)); + $replyMentionIds = $replyRows->flatMap(fn ($reply) => json_decode($reply->mention_user_ids ?? '[]', true)); + $participantIds = collect([$thread->author_id])->merge($replyRows->pluck('author_id'))->merge($mentionIds)->merge($assigneeIds)->unique(); + $people = User::query()->where('organization_id', $this->tenant->id())->whereIn('id', $participantIds)->get(['id', 'name'])->keyBy('id'); + $lesson = $thread->lesson_id ? Lesson::query()->where('organization_id', $this->tenant->id())->with('courseModule:id,title')->find($thread->lesson_id) : null; + $blockType = $thread->block_id ? Block::query()->where('organization_id', $this->tenant->id())->whereKey($thread->block_id)->value('type') : null; + $locationParts = collect([$lesson?->courseModule?->title, $lesson?->title, $blockType ? 'بلوک '.$blockType : null])->filter()->values(); + $opening = [ + 'id' => $thread->id, 'kind' => 'thread', 'body' => $thread->body, + 'authorId' => $thread->author_id, 'authorName' => $thread->authorName, + 'createdAt' => $thread->created_at, 'editedAt' => $thread->updated_at !== $thread->created_at ? $thread->updated_at : null, + 'attachments' => $this->attachmentPayloads(json_decode($thread->attachment_asset_ids ?? '[]', true)), + 'canEdit' => $thread->author_id === $viewer->getKey(), 'canDelete' => $thread->author_id === $viewer->getKey() && $replyRows->isEmpty(), + ]; + $isMentioned = $mentionIds->merge($replyMentionIds)->contains($viewer->getKey()); + $isMine = $thread->author_id === $viewer->getKey() || $isMentioned || $assigneeIds->contains($viewer->getKey()) || $replyRows->pluck('author_id')->contains($viewer->getKey()); + $unread = DB::table('in_app_notifications')->where('organization_id', $this->tenant->id())->where('recipient_id', $viewer->getKey())->where('entity_type', 'review_thread')->where('entity_id', $thread->id)->whereNull('read_at')->exists(); + + return [ + 'id' => $thread->id, 'courseId' => $version->course_id, 'courseVersionId' => $version->getKey(), 'courseTitle' => $version->title, + 'lessonId' => $thread->lesson_id, 'blockId' => $thread->block_id, 'authorId' => $thread->author_id, 'authorName' => $thread->authorName, + 'title' => $thread->title ?: Str::limit(preg_replace('/\s+/u', ' ', $thread->body), 90, '…'), 'status' => $thread->status, 'body' => $thread->body, + 'mentions' => $mentionIds->values(), 'assignees' => $assigneeIds->map(fn ($id) => isset($people[$id]) ? ['id' => $id, 'name' => $people[$id]->name] : null)->filter()->values(), + 'participants' => $participantIds->map(fn ($id) => isset($people[$id]) ? ['id' => $id, 'name' => $people[$id]->name] : null)->filter()->values(), + 'location' => ['moduleTitle' => $lesson?->courseModule?->title, 'lessonTitle' => $lesson?->title, 'blockType' => $blockType, 'label' => $locationParts->isEmpty() ? 'سطح دوره' : $locationParts->implode(' · ')], + 'messages' => collect([$opening])->merge($replies)->values(), 'replies' => $replies, 'replyCount' => $replies->count(), 'reactions' => $reactions, + 'isMentioned' => $isMentioned, 'isMine' => $isMine, 'unread' => $unread, + 'createdAt' => $thread->created_at, 'updatedAt' => $thread->updated_at, + 'targetUrl' => $thread->lesson_id ? '/app/courses/'.$version->course_id.'/versions/'.$version->getKey().'/lessons/'.$thread->lesson_id.'/builder?thread='.$thread->id.($thread->block_id ? '&block='.$thread->block_id : '') : '/app/courses/'.$version->course_id.'?tab=discussion&thread='.$thread->id, + ]; + }); + } + + private function messagePayload(object $reply, User $viewer): array + { + $authorName = $reply->authorName ?? User::query()->where('organization_id', $this->tenant->id())->whereKey($reply->author_id)->value('name') ?? 'کاربر حذف‌شده'; + + return [ + 'id' => $reply->id, 'kind' => 'reply', 'body' => $reply->body, + 'authorId' => $reply->author_id, 'authorName' => $authorName, + 'createdAt' => $reply->created_at, 'editedAt' => $reply->edited_at ?? null, + 'attachments' => $this->attachmentPayloads(json_decode($reply->attachment_asset_ids ?? '[]', true)), + 'canEdit' => $reply->author_id === $viewer->getKey(), 'canDelete' => $reply->author_id === $viewer->getKey(), + ]; + } + + /** @param list $ids */ + private function attachmentPayloads(array $ids): Collection + { + return Asset::query()->where('organization_id', $this->tenant->id())->whereIn('id', $ids)->get()->map(fn (Asset $asset) => [ + 'id' => $asset->getKey(), 'name' => $asset->original_name, 'mimeType' => $asset->mime_type, 'size' => $asset->size, + 'contentUrl' => URL::temporarySignedRoute('assets.content', now()->addMinutes(10), ['asset' => $asset->getKey()], absolute: false), + ])->values(); + } +} diff --git a/backend/app/Modules/Collaboration/Http/NotificationController.php b/backend/app/Modules/Collaboration/Http/NotificationController.php new file mode 100644 index 0000000..9aea64d --- /dev/null +++ b/backend/app/Modules/Collaboration/Http/NotificationController.php @@ -0,0 +1,37 @@ +where('organization_id', $this->tenant->id())->where('recipient_id', $request->user()->getKey())->whereNull('dismissed_at')->orderByDesc('created_at')->limit(100)->get()->map(fn ($row) => ['id' => $row->id, 'type' => $row->type, 'title' => $row->title, 'body' => $row->body, 'targetUrl' => $row->target_url, 'readAt' => $row->read_at, 'createdAt' => $row->created_at]); + + return response()->json(['data' => ['items' => $items, 'unread' => $items->whereNull('readAt')->count()]]); + } + + public function read(Request $request, string $notification): JsonResponse + { + $updated = DB::table('in_app_notifications')->where('organization_id', $this->tenant->id())->where('recipient_id', $request->user()->getKey())->where('id', $notification)->update(['read_at' => now(), 'updated_at' => now()]); + abort_unless($updated === 1, 404); + + return response()->json(['data' => ['read' => true]]); + } + + public function dismiss(Request $request, string $notification): JsonResponse + { + $updated = DB::table('in_app_notifications')->where('organization_id', $this->tenant->id())->where('recipient_id', $request->user()->getKey())->where('id', $notification)->update(['dismissed_at' => now(), 'read_at' => now(), 'updated_at' => now()]); + abort_unless($updated === 1, 404); + + return response()->json(['data' => ['dismissed' => true]]); + } +} diff --git a/backend/app/Modules/Courses/Application/BlockContractValidator.php b/backend/app/Modules/Courses/Application/BlockContractValidator.php new file mode 100644 index 0000000..1bba583 --- /dev/null +++ b/backend/app/Modules/Courses/Application/BlockContractValidator.php @@ -0,0 +1,42 @@ + $input @return array */ + public function validate(array $input): array + { + return Validator::make($input, [ + 'style' => ['sometimes', 'nullable', 'array:alignment,width,spacing,background,border,radius,fontSize,fontWeight,textColor,lineHeight,maxWidth,aspectRatio,objectFit'], + 'style.alignment' => ['nullable', 'in:start,center,end'], + 'style.width' => ['nullable', 'in:narrow,normal,wide,full'], + 'style.spacing' => ['nullable', 'in:xs,s,m,l,xl'], + 'style.background' => ['nullable', 'string', 'max:64'], + 'style.border' => ['nullable', 'in:none,subtle,strong'], + 'style.radius' => ['nullable', 'in:none,s,m,l,xl'], + 'style.fontSize' => ['nullable', 'integer', 'min:12', 'max:72'], + 'style.fontWeight' => ['nullable', 'integer', 'in:300,400,500,600,700,800'], + 'style.textColor' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'], + 'style.lineHeight' => ['nullable', 'numeric', 'min:1', 'max:3'], + 'style.maxWidth' => ['nullable', 'integer', 'min:240', 'max:1200'], + 'style.aspectRatio' => ['nullable', 'in:original,1/1,4/3,16/9'], + 'style.objectFit' => ['nullable', 'in:cover,contain,fill'], + 'behavior' => ['sometimes', 'nullable', 'array:hidden,completion,animation,locked'], + 'behavior.hidden' => ['nullable', 'boolean'], + 'behavior.completion' => ['nullable', 'in:view,interact,complete'], + 'behavior.animation' => ['nullable', 'in:none,fade,slide'], + 'behavior.locked' => ['nullable', 'boolean'], + 'responsive' => ['sometimes', 'nullable', 'array:mobileStack,mobileOrder'], + 'responsive.mobileStack' => ['nullable', 'boolean'], + 'responsive.mobileOrder' => ['nullable', 'in:logical,reverse'], + 'accessibility' => ['sometimes', 'nullable', 'array:label,alt,decorative,transcript'], + 'accessibility.label' => ['nullable', 'string', 'max:300'], + 'accessibility.alt' => ['nullable', 'string', 'max:300'], + 'accessibility.decorative' => ['nullable', 'boolean'], + 'accessibility.transcript' => ['nullable', 'string', 'max:50000'], + ])->validate(); + } +} diff --git a/backend/app/Modules/Courses/Application/BlockDefinition.php b/backend/app/Modules/Courses/Application/BlockDefinition.php new file mode 100644 index 0000000..4a6fae4 --- /dev/null +++ b/backend/app/Modules/Courses/Application/BlockDefinition.php @@ -0,0 +1,61 @@ + $defaultData + * @param array> $rules + * @param list $capabilities + * @param list $webBehavior + * @param array $exportCompatibility + * @param array): array> $migrations + */ + public function __construct( + public string $type, + public string $category, + public string $label, + public string $icon, + public int $schemaVersion, + public array $defaultData, + public array $rules, + public array $capabilities = [], + public array $webBehavior = ['responsive'], + public array $exportCompatibility = ['pdf' => 'native', 'scorm' => 'native', 'mp4' => 'static'], + public array $migrations = [], + ) {} + + /** @return array */ + public function metadata(): array + { + return [ + 'type' => $this->type, + 'category' => $this->category, + 'label' => $this->label, + 'icon' => $this->icon, + 'schemaVersion' => $this->schemaVersion, + 'defaultData' => $this->defaultData, + 'capabilities' => $this->capabilities, + 'webBehavior' => $this->webBehavior, + 'exportCompatibility' => $this->exportCompatibility, + 'latestSchemaVersion' => $this->schemaVersion, + ]; + } + + /** @param array $data @return array{version: int, data: array} */ + public function migrate(int $fromVersion, array $data): array + { + $version = $fromVersion; + while ($version < $this->schemaVersion) { + $migration = $this->migrations[$version] ?? null; + if (! $migration) { + throw new \LogicException("Missing {$this->type} schema migration from version {$version}."); + } + $data = $migration($data); + $version++; + } + + return ['version' => $version, 'data' => $data]; + } +} diff --git a/backend/app/Modules/Courses/Application/BlockRegistry.php b/backend/app/Modules/Courses/Application/BlockRegistry.php new file mode 100644 index 0000000..054c710 --- /dev/null +++ b/backend/app/Modules/Courses/Application/BlockRegistry.php @@ -0,0 +1,180 @@ + */ + private array $definitions; + + public function __construct(private readonly RichTextSanitizer $richText) + { + $this->definitions = collect($this->definitions()) + ->keyBy(fn (BlockDefinition $definition) => $definition->type) + ->all(); + } + + /** @return list> */ + public function metadata(): array + { + return array_values(array_map( + fn (BlockDefinition $definition) => $definition->metadata(), + $this->definitions, + )); + } + + public function definition(string $type): BlockDefinition + { + $definition = $this->definitions[$type] ?? null; + + if (! $definition) { + throw ValidationException::withMessages(['type' => ['The selected block type is not registered.']]); + } + + return $definition; + } + + /** @param array $data @return array */ + public function validate(string $type, int $schemaVersion, array $data): array + { + $definition = $this->definition($type); + + if ($schemaVersion !== $definition->schemaVersion) { + throw ValidationException::withMessages([ + 'schemaVersion' => ["Schema version {$schemaVersion} is not supported for {$type}."], + ]); + } + + $allowedFields = collect(array_keys($definition->rules)) + ->map(fn (string $field): string => str($field)->before('.')->toString()) + ->unique() + ->all(); + $unknownFields = array_diff(array_keys($data), $allowedFields); + if ($unknownFields !== []) { + throw ValidationException::withMessages(collect($unknownFields) + ->mapWithKeys(fn (string $field): array => ["data.{$field}" => ['This field is not defined by the registered block schema.']]) + ->all()); + } + + $validated = Validator::make($data, $definition->rules)->validate(); + if ($type === 'text') { + $validated['html'] = $this->richText->sanitize($validated['html']); + } + + return $validated; + } + + /** @param array $data @return array{version: int, data: array} */ + public function migrate(string $type, int $schemaVersion, array $data): array + { + return $this->definition($type)->migrate($schemaVersion, $data); + } + + /** @return list */ + private function definitions(): array + { + return [ + new BlockDefinition('heading', 'basic', 'عنوان', 'heading', 1, + ['text' => 'عنوان بخش', 'level' => 2], + ['text' => ['required', 'string', 'max:240'], 'level' => ['required', 'integer', 'between:1,3']]), + new BlockDefinition('text', 'basic', 'متن', 'text', 1, + ['html' => '

متن خود را اینجا بنویسید.

'], + ['html' => ['required', 'string', 'max:50000']], ['searchable']), + new BlockDefinition('quote', 'basic', 'نقل‌قول', 'quote', 1, + ['text' => 'متن نقل‌قول', 'cite' => ''], + ['text' => ['required', 'string', 'max:3000'], 'cite' => ['nullable', 'string', 'max:240']]), + new BlockDefinition('key_point', 'basic', 'نکته کلیدی', 'lightbulb', 1, + ['title' => 'نکته کلیدی', 'body' => 'پیام مهم این بخش'], + ['title' => ['required', 'string', 'max:160'], 'body' => ['required', 'string', 'max:2000']]), + new BlockDefinition('divider', 'basic', 'جداکننده', 'minus', 1, + ['label' => ''], ['label' => ['nullable', 'string', 'max:120']]), + new BlockDefinition('button', 'basic', 'دکمه', 'mouse-pointer-click', 1, + ['label' => 'ادامه', 'url' => null, 'openInNewTab' => false], + ['label' => ['required', 'string', 'max:120'], 'url' => ['nullable', 'url', 'max:2048'], 'openInNewTab' => ['required', 'boolean']], ['click']), + new BlockDefinition('image', 'media', 'تصویر', 'image', 1, + ['assetId' => null, 'url' => null, 'alt' => '', 'caption' => '', 'decorative' => false], + ['assetId' => ['nullable', 'string'], 'url' => ['nullable', 'url', 'max:2048'], 'alt' => ['nullable', 'string', 'max:300'], 'caption' => ['nullable', 'string', 'max:500'], 'decorative' => ['required', 'boolean']], ['downloadable']), + new BlockDefinition('gallery', 'media', 'گالری', 'images', 1, + ['items' => [], 'layout' => 'grid'], + ['items' => ['present', 'array', 'max:12'], 'items.*.assetId' => ['required', 'string'], 'items.*.alt' => ['nullable', 'string', 'max:300'], 'items.*.caption' => ['nullable', 'string', 'max:500'], 'layout' => ['required', 'in:grid,carousel']], ['interaction']), + new BlockDefinition('video', 'media', 'ویدئو', 'video', 1, + ['assetId' => null, 'url' => null, 'title' => '', 'transcript' => '', 'captionsUrl' => null], + ['assetId' => ['nullable', 'string'], 'url' => ['nullable', 'url', 'max:2048'], 'title' => ['nullable', 'string', 'max:240'], 'transcript' => ['nullable', 'string', 'max:50000'], 'captionsUrl' => ['nullable', 'url', 'max:2048']], ['completion_tracking']), + new BlockDefinition('audio', 'media', 'صوت', 'audio-lines', 1, + ['assetId' => null, 'url' => null, 'title' => '', 'transcript' => ''], + ['assetId' => ['nullable', 'string'], 'url' => ['nullable', 'url', 'max:2048'], 'title' => ['nullable', 'string', 'max:240'], 'transcript' => ['nullable', 'string', 'max:50000']], ['completion_tracking']), + new BlockDefinition('document', 'media', 'سند', 'file-text', 1, + ['assetId' => null, 'title' => 'سند', 'description' => ''], + ['assetId' => ['nullable', 'string'], 'title' => ['required', 'string', 'max:240'], 'description' => ['nullable', 'string', 'max:1000']], ['downloadable']), + new BlockDefinition('embed', 'media', 'محتوای تعبیه‌شده', 'code-xml', 1, + ['url' => null, 'title' => '', 'aspectRatio' => '16/9'], + ['url' => ['nullable', 'url', 'max:2048'], 'title' => ['nullable', 'string', 'max:240'], 'aspectRatio' => ['required', 'in:16/9,4/3,1/1']], [], ['sandboxed_iframe'], ['pdf' => 'link', 'scorm' => 'native', 'mp4' => 'poster']), + new BlockDefinition('accordion', 'learning', 'آکاردئون', 'list-collapse', 1, + ['items' => [['title' => 'عنوان', 'body' => 'توضیحات']]], + ['items' => ['required', 'array', 'min:1', 'max:20'], 'items.*.title' => ['required', 'string', 'max:240'], 'items.*.body' => ['required', 'string', 'max:5000']]), + new BlockDefinition('flashcard', 'learning', 'فلش‌کارت', 'cards', 1, + ['front' => 'پرسش', 'back' => 'پاسخ'], + ['front' => ['required', 'string', 'max:2000'], 'back' => ['required', 'string', 'max:5000']], ['interaction']), + new BlockDefinition('tabs', 'learning', 'زبانه‌ها', 'panel-top', 1, + ['items' => [['title' => 'زبانه اول', 'body' => 'محتوا']]], + ['items' => ['required', 'array', 'min:1', 'max:8'], 'items.*.title' => ['required', 'string', 'max:120'], 'items.*.body' => ['required', 'string', 'max:5000']], ['interaction']), + new BlockDefinition('timeline', 'learning', 'خط زمانی', 'git-commit-horizontal', 1, + ['items' => [['title' => 'مرحله اول', 'body' => 'توضیحات', 'date' => '']]], + ['items' => ['required', 'array', 'min:1', 'max:20'], 'items.*.title' => ['required', 'string', 'max:160'], 'items.*.body' => ['nullable', 'string', 'max:3000'], 'items.*.date' => ['nullable', 'string', 'max:80']]), + new BlockDefinition('steps', 'learning', 'مراحل', 'list-ordered', 1, + ['items' => [['title' => 'مرحله اول', 'body' => 'توضیحات']]], + ['items' => ['required', 'array', 'min:1', 'max:20'], 'items.*.title' => ['required', 'string', 'max:160'], 'items.*.body' => ['nullable', 'string', 'max:3000']], ['progress']), + new BlockDefinition('process', 'learning', 'فرایند', 'workflow', 1, + ['items' => [['title' => 'شروع', 'body' => 'توضیحات']]], + ['items' => ['required', 'array', 'min:1', 'max:12'], 'items.*.title' => ['required', 'string', 'max:160'], 'items.*.body' => ['nullable', 'string', 'max:2000']]), + new BlockDefinition('checklist', 'learning', 'چک‌لیست', 'list-checks', 1, + ['items' => [['text' => 'مورد اول', 'required' => true]]], + ['items' => ['required', 'array', 'min:1', 'max:30'], 'items.*.text' => ['required', 'string', 'max:500'], 'items.*.required' => ['required', 'boolean']], ['interaction', 'completion_tracking']), + new BlockDefinition('single_choice', 'assessment', 'تک‌گزینه‌ای', 'circle-check', 1, + ['prompt' => 'پرسش را وارد کنید', 'options' => ['گزینه اول', 'گزینه دوم'], 'answerIndex' => 0], + ['prompt' => ['required', 'string', 'max:2000'], 'options' => ['required', 'array', 'min:2', 'max:10'], 'options.*' => ['required', 'string', 'max:500'], 'answerIndex' => ['required', 'integer', 'min:0']], ['assessment', 'evidence']), + new BlockDefinition('true_false', 'assessment', 'درست / نادرست', 'toggle-left', 1, + ['prompt' => 'عبارت را وارد کنید', 'answer' => true], + ['prompt' => ['required', 'string', 'max:2000'], 'answer' => ['required', 'boolean']], ['assessment', 'evidence']), + new BlockDefinition('multiple_choice', 'assessment', 'چندگزینه‌ای', 'list-checks', 1, + ['prompt' => 'پرسش را وارد کنید', 'options' => ['گزینه اول', 'گزینه دوم'], 'answerIndexes' => [0]], + ['prompt' => ['required', 'string', 'max:2000'], 'options' => ['required', 'array', 'min:2', 'max:12'], 'options.*' => ['required', 'string', 'max:500'], 'answerIndexes' => ['required', 'array', 'min:1'], 'answerIndexes.*' => ['integer', 'min:0']], ['assessment', 'evidence']), + new BlockDefinition('matching', 'assessment', 'تطبیقی', 'rows-3', 1, + ['prompt' => 'موارد مرتبط را به هم وصل کنید', 'pairs' => [['left' => 'عبارت اول', 'right' => 'پاسخ اول'], ['left' => 'عبارت دوم', 'right' => 'پاسخ دوم']]], + ['prompt' => ['required', 'string', 'max:2000'], 'pairs' => ['required', 'array', 'min:2', 'max:12'], 'pairs.*.left' => ['required', 'string', 'max:500'], 'pairs.*.right' => ['required', 'string', 'max:500']], ['assessment', 'evidence', 'interaction']), + new BlockDefinition('sorting', 'assessment', 'مرتب‌سازی', 'arrow-down-up', 1, + ['prompt' => 'موارد را به ترتیب صحیح بچینید', 'items' => [['text' => 'مرحله اول'], ['text' => 'مرحله دوم']]], + ['prompt' => ['required', 'string', 'max:2000'], 'items' => ['required', 'array', 'min:2', 'max:12'], 'items.*.text' => ['required', 'string', 'max:500']], ['assessment', 'evidence', 'interaction']), + new BlockDefinition('drag_drop', 'assessment', 'کشیدن و رهاکردن', 'move', 1, + ['prompt' => 'هر مورد را در مقصد صحیح قرار دهید', 'items' => [['text' => 'آیتم اول', 'target' => 'گروه اول'], ['text' => 'آیتم دوم', 'target' => 'گروه دوم']]], + ['prompt' => ['required', 'string', 'max:2000'], 'items' => ['required', 'array', 'min:2', 'max:16'], 'items.*.text' => ['required', 'string', 'max:500'], 'items.*.target' => ['required', 'string', 'max:160']], ['assessment', 'evidence', 'interaction']), + new BlockDefinition('hotspot', 'assessment', 'نقطه داغ', 'scan', 1, + ['prompt' => 'نقطه صحیح را روی تصویر انتخاب کنید', 'assetId' => null, 'hotspots' => [['label' => 'نقطه صحیح', 'x' => 50, 'y' => 50, 'radius' => 10, 'correct' => true]]], + ['prompt' => ['required', 'string', 'max:2000'], 'assetId' => ['nullable', 'string'], 'hotspots' => ['required', 'array', 'min:1', 'max:12'], 'hotspots.*.label' => ['required', 'string', 'max:160'], 'hotspots.*.x' => ['required', 'numeric', 'between:0,100'], 'hotspots.*.y' => ['required', 'numeric', 'between:0,100'], 'hotspots.*.radius' => ['required', 'numeric', 'between:1,40'], 'hotspots.*.correct' => ['required', 'boolean']], ['assessment', 'evidence', 'interaction']), + new BlockDefinition('scenario', 'assessment', 'سناریو', 'messages-square', 1, + ['prompt' => 'در این موقعیت چه می‌کنید؟', 'context' => 'موقعیت را شرح دهید.', 'choices' => [['text' => 'انتخاب اول', 'feedback' => 'بازخورد انتخاب اول', 'score' => 1], ['text' => 'انتخاب دوم', 'feedback' => 'بازخورد انتخاب دوم', 'score' => 0]]], + ['prompt' => ['required', 'string', 'max:2000'], 'context' => ['required', 'string', 'max:5000'], 'choices' => ['required', 'array', 'min:2', 'max:8'], 'choices.*.text' => ['required', 'string', 'max:1000'], 'choices.*.feedback' => ['required', 'string', 'max:3000'], 'choices.*.score' => ['required', 'numeric', 'between:0,1']], ['assessment', 'evidence', 'interaction']), + new BlockDefinition('branching_scenario', 'assessment', 'سناریوی شاخه‌ای', 'git-branch', 1, + ['prompt' => 'سناریوی شاخه‌ای', 'startNodeId' => 'start', 'nodes' => [['id' => 'start', 'type' => 'scene', 'title' => 'شروع', 'body' => 'صحنه آغازین', 'targetNodeId' => 'result'], ['id' => 'result', 'type' => 'result', 'title' => 'نتیجه', 'body' => 'پایان سناریو', 'targetNodeId' => '']]], + ['prompt' => ['required', 'string', 'max:2000'], 'startNodeId' => ['required', 'string', 'max:80'], 'nodes' => ['required', 'array', 'min:2', 'max:80'], 'nodes.*.id' => ['required', 'string', 'distinct', 'max:80'], 'nodes.*.type' => ['required', 'in:scene,question,result'], 'nodes.*.title' => ['required', 'string', 'max:240'], 'nodes.*.body' => ['nullable', 'string', 'max:5000'], 'nodes.*.targetNodeId' => ['nullable', 'string', 'max:80']], ['assessment', 'evidence', 'interaction']), + new BlockDefinition('interactive_image', 'assessment', 'تصویر تعاملی', 'image-up', 1, + ['assetId' => null, 'alt' => '', 'markers' => [['label' => 'نقطه ۱', 'body' => 'توضیحات', 'x' => 50, 'y' => 50]]], + ['assetId' => ['nullable', 'string'], 'alt' => ['nullable', 'string', 'max:300'], 'markers' => ['required', 'array', 'min:1', 'max:20'], 'markers.*.label' => ['required', 'string', 'max:160'], 'markers.*.body' => ['nullable', 'string', 'max:2000'], 'markers.*.x' => ['required', 'numeric', 'between:0,100'], 'markers.*.y' => ['required', 'numeric', 'between:0,100']], ['interaction']), + new BlockDefinition('before_after', 'assessment', 'قبل و بعد', 'columns-2', 1, + ['beforeAssetId' => null, 'afterAssetId' => null, 'beforeLabel' => 'قبل', 'afterLabel' => 'بعد', 'alt' => ''], + ['beforeAssetId' => ['nullable', 'string'], 'afterAssetId' => ['nullable', 'string'], 'beforeLabel' => ['required', 'string', 'max:80'], 'afterLabel' => ['required', 'string', 'max:80'], 'alt' => ['nullable', 'string', 'max:300']], ['interaction']), + new BlockDefinition('columns', 'layout', 'ستون‌ها', 'columns-2', 1, + ['preset' => '50/50', 'gap' => 'medium', 'mobileOrder' => 'logical', 'columns' => [['title' => 'ستون ۱', 'content' => ''], ['title' => 'ستون ۲', 'content' => '']]], + ['preset' => ['required', 'in:50/50,33/67,67/33,three'], 'gap' => ['required', 'in:small,medium,large'], 'mobileOrder' => ['required', 'in:logical,reverse'], 'columns' => ['required', 'array', 'min:2', 'max:3'], 'columns.*.title' => ['nullable', 'string', 'max:120'], 'columns.*.content' => ['nullable', 'string', 'max:5000']]), + new BlockDefinition('section', 'layout', 'بخش', 'panel-top', 1, + ['title' => 'بخش جدید', 'description' => ''], + ['title' => ['required', 'string', 'max:240'], 'description' => ['nullable', 'string', 'max:1000']]), + new BlockDefinition('controlled_grid', 'layout', 'شبکه کنترل‌شده', 'layout-grid', 1, + ['columns' => 3, 'items' => [['title' => 'کارت اول', 'body' => 'محتوا']]], + ['columns' => ['required', 'integer', 'between:2,4'], 'items' => ['required', 'array', 'min:1', 'max:12'], 'items.*.title' => ['required', 'string', 'max:160'], 'items.*.body' => ['nullable', 'string', 'max:3000']]), + ]; + } +} diff --git a/backend/app/Modules/Courses/Application/CourseVersionComparison.php b/backend/app/Modules/Courses/Application/CourseVersionComparison.php new file mode 100644 index 0000000..f9826f4 --- /dev/null +++ b/backend/app/Modules/Courses/Application/CourseVersionComparison.php @@ -0,0 +1,106 @@ + */ + public function compare(CourseVersion $before, CourseVersion $after): array + { + $before->loadMissing(['modules.lessons.blocks', 'assessments.questions']); + $after->loadMissing(['modules.lessons.blocks', 'assessments.questions']); + + $categories = [ + $this->category('structure', 'ساختار', $this->structure($before), $this->structure($after), $this->structureLabel($before), $this->structureLabel($after)), + $this->category('content', 'محتوا', $this->content($before), $this->content($after), $this->contentLabel($before), $this->contentLabel($after)), + $this->category('assessments', 'ارزیابی‌ها', $this->assessments($before), $this->assessments($after), $this->assessmentLabel($before), $this->assessmentLabel($after)), + $this->category('settings', 'تنظیمات', $this->settings($before), $this->settings($after), 'مشخصات و قوانین نسخه', 'مشخصات و قوانین نسخه'), + ]; + + return [ + 'compatible' => true, + 'before' => $this->identity($before), + 'after' => $this->identity($after), + 'changedCategories' => collect($categories)->where('changed', true)->count(), + 'categories' => $categories, + ]; + } + + public function unpublishedChangeCount(CourseVersion $version): int + { + if ($version->status->value !== 'draft') { + return 0; + } + if (! $version->source_version_id) { + $version->loadMissing(['modules.lessons.blocks', 'assessments.questions']); + + return $version->modules->count() + $version->lessons->count() + $version->blocks->count() + $version->assessments->count(); + } + $source = CourseVersion::query()->where('organization_id', $version->organization_id)->where('course_id', $version->course_id)->find($version->source_version_id); + if (! $source) { + return 0; + } + + return collect($this->compare($source, $version)['categories'])->where('changed', true)->count(); + } + + /** @return array */ + private function identity(CourseVersion $version): array + { + return ['id' => $version->getKey(), 'number' => $version->version_number, 'status' => $version->status->value]; + } + + /** @return array */ + private function category(string $key, string $label, array $before, array $after, string $beforeLabel, string $afterLabel): array + { + return compact('key', 'label', 'beforeLabel', 'afterLabel') + ['changed' => $before !== $after]; + } + + private function structure(CourseVersion $version): array + { + return $version->modules->sortBy('position')->map(fn ($module) => [ + 'title' => $module->title, + 'position' => $module->position, + 'lessons' => $module->lessons->sortBy('position')->map(fn ($lesson) => ['title' => $lesson->title, 'position' => $lesson->position, 'presentationMode' => $lesson->presentation_mode])->values()->all(), + ])->values()->all(); + } + + private function content(CourseVersion $version): array + { + return $version->modules->sortBy('position')->flatMap(fn ($module) => $module->lessons->sortBy('position')->map(fn ($lesson) => [ + 'lessonPosition' => $lesson->position, + 'blocks' => $lesson->blocks->sortBy('position')->map(fn ($block) => ['type' => $block->type, 'schemaVersion' => $block->schema_version, 'data' => $block->data, 'style' => $block->style, 'behavior' => $block->behavior, 'responsive' => $block->responsive, 'accessibility' => $block->accessibility, 'position' => $block->position])->values()->all(), + ]))->values()->all(); + } + + private function assessments(CourseVersion $version): array + { + return $version->assessments->sortBy('created_at')->map(fn ($assessment) => [ + 'title' => $assessment->title, + 'settings' => $assessment->settings, + 'questions' => $assessment->questions->sortBy('position')->map(fn ($question) => ['type' => $question->type, 'prompt' => $question->prompt, 'configuration' => $question->configuration, 'position' => $question->position])->values()->all(), + ])->values()->all(); + } + + private function settings(CourseVersion $version): array + { + return ['title' => $version->title, 'description' => $version->description, 'settings' => $version->settings, 'completionRules' => $version->completion_rules]; + } + + private function structureLabel(CourseVersion $version): string + { + return $version->modules->count().' ماژول · '.$version->lessons->count().' درس'; + } + + private function contentLabel(CourseVersion $version): string + { + return $version->blocks->count().' بلوک محتوایی'; + } + + private function assessmentLabel(CourseVersion $version): string + { + return $version->assessments->count().' ارزیابی'; + } +} diff --git a/backend/app/Modules/Courses/Application/CourseVersionEditor.php b/backend/app/Modules/Courses/Application/CourseVersionEditor.php new file mode 100644 index 0000000..fe2aa0e --- /dev/null +++ b/backend/app/Modules/Courses/Application/CourseVersionEditor.php @@ -0,0 +1,163 @@ +status !== CourseVersionStatus::Draft) { + throw ValidationException::withMessages(['version' => ['Only draft course versions can be edited.']]); + } + } + + public function forkPublished(Course $course, CourseVersion $source, string $createdBy): CourseVersion + { + if ($source->course_id !== $course->getKey() || $source->status !== CourseVersionStatus::Published) { + throw ValidationException::withMessages(['version' => ['Only a published version can be forked for editing.']]); + } + + return DB::transaction(function () use ($course, $source, $createdBy) { + Course::query()->whereKey($course->getKey())->lockForUpdate()->firstOrFail(); + $existing = CourseVersion::query() + ->where('course_id', $course->getKey()) + ->where('source_version_id', $source->getKey()) + ->where('status', CourseVersionStatus::Draft) + ->first(); + + if ($existing) { + return $existing; + } + + $target = CourseVersion::query()->create([ + 'organization_id' => $course->organization_id, + 'course_id' => $course->getKey(), + 'source_version_id' => $source->getKey(), + 'created_by' => $createdBy, + 'version_number' => ((int) CourseVersion::query()->where('course_id', $course->getKey())->max('version_number')) + 1, + 'status' => CourseVersionStatus::Draft, + 'title' => $source->title, + 'description' => $source->description, + 'settings' => $source->settings, + 'completion_rules' => $source->completion_rules, + ]); + + $idMap = [$source->getKey() => $target->getKey()]; + $source->load(['modules.lessons.blocks', 'assessments.questions']); + foreach ($source->modules->sortBy('position') as $module) { + $newModule = CourseModule::query()->create([ + 'organization_id' => $target->organization_id, + 'course_version_id' => $target->getKey(), + 'title' => $module->title, + 'position' => $module->position, + 'settings' => $module->settings, + 'is_locked' => $module->is_locked, + ]); + $idMap[$module->getKey()] = $newModule->getKey(); + + foreach ($module->lessons->sortBy('position') as $lesson) { + $newLesson = Lesson::query()->create([ + 'organization_id' => $target->organization_id, + 'course_version_id' => $target->getKey(), + 'course_module_id' => $newModule->getKey(), + 'title' => $lesson->title, + 'position' => $lesson->position, + 'presentation_mode' => $lesson->presentation_mode, + 'settings' => $lesson->settings, + 'is_locked' => $lesson->is_locked, + ]); + $idMap[$lesson->getKey()] = $newLesson->getKey(); + + foreach ($lesson->blocks->sortBy('position') as $block) { + $newBlock = Block::query()->create([ + 'organization_id' => $target->organization_id, + 'course_version_id' => $target->getKey(), + 'lesson_id' => $newLesson->getKey(), + 'type' => $block->type, + 'schema_version' => $block->schema_version, + 'data' => $block->data, + 'style' => $block->style, + 'behavior' => $block->behavior, + 'responsive' => $block->responsive, + 'accessibility' => $block->accessibility, + 'position' => $block->position, + 'revision' => 1, + ]); + $idMap[$block->getKey()] = $newBlock->getKey(); + } + } + } + + foreach ($source->assessments as $assessment) { + $newAssessment = Assessment::query()->create([ + 'organization_id' => $target->organization_id, + 'course_version_id' => $target->getKey(), + 'lesson_id' => $idMap[$assessment->lesson_id] ?? null, + 'title' => $assessment->title, + 'settings' => $assessment->settings, + ]); + $idMap[$assessment->getKey()] = $newAssessment->getKey(); + foreach ($assessment->questions as $question) { + $newQuestion = Question::query()->create([ + 'organization_id' => $target->organization_id, + 'course_version_id' => $target->getKey(), + 'assessment_id' => $newAssessment->getKey(), + 'type' => $question->type, + 'prompt' => $question->prompt, + 'configuration' => $question->configuration, + 'difficulty' => $question->difficulty, + 'position' => $question->position, + 'is_bank_item' => false, + 'source_question_id' => $question->source_question_id, + 'schema_version' => $question->schema_version, + 'topic' => $question->topic, + 'tags' => $question->tags, + 'explanation' => $question->explanation, + ]); + $idMap[$question->getKey()] = $newQuestion->getKey(); + } + } + + $this->copyMappings($source, $target, $idMap); + + return $target; + }); + } + + /** @param array $idMap */ + private function copyMappings(CourseVersion $source, CourseVersion $target, array $idMap): void + { + ContentTaxonomyMapping::query()->where('course_version_id', $source->getKey())->each(function (ContentTaxonomyMapping $mapping) use ($target, $idMap) { + if (! isset($idMap[$mapping->mappable_id])) { + return; + } + + ContentTaxonomyMapping::query()->create([ + 'organization_id' => $target->organization_id, + 'course_version_id' => $target->getKey(), + 'mappable_type' => $mapping->mappable_type, + 'mappable_id' => $idMap[$mapping->mappable_id], + 'taxonomy_node_id' => $mapping->taxonomy_node_id, + 'mapping_type' => $mapping->mapping_type, + 'weight' => $mapping->weight, + 'source' => $mapping->source, + 'confidence' => $mapping->confidence, + 'confirmation_status' => $mapping->confirmation_status, + 'confirmed_by' => $mapping->confirmed_by, + 'confirmed_at' => $mapping->confirmed_at, + ]); + }); + } +} diff --git a/backend/app/Modules/Courses/Application/RichTextSanitizer.php b/backend/app/Modules/Courses/Application/RichTextSanitizer.php new file mode 100644 index 0000000..650bcfb --- /dev/null +++ b/backend/app/Modules/Courses/Application/RichTextSanitizer.php @@ -0,0 +1,84 @@ +loadHTML(''.$html.'', LIBXML_HTML_NODEFDTD); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + $xpath = new DOMXPath($document); + $nodes = iterator_to_array($xpath->query('//body//*') ?: []); + + foreach (array_reverse($nodes) as $node) { + if (! $node instanceof DOMElement || ! $node->parentNode) { + continue; + } + $tag = strtolower($node->tagName); + if (in_array($tag, self::DISCARD, true)) { + $node->parentNode->removeChild($node); + + continue; + } + if (! in_array($tag, self::ALLOWED, true)) { + $this->unwrap($node); + + continue; + } + + $href = $tag === 'a' ? $node->getAttribute('href') : ''; + while ($node->attributes->length > 0) { + $node->removeAttributeNode($node->attributes->item(0)); + } + if ($tag === 'a' && $this->safeHref($href)) { + $node->setAttribute('href', $href); + $node->setAttribute('rel', 'noopener noreferrer'); + } + } + + $body = $document->getElementsByTagName('body')->item(0); + if (! $body) { + return ''; + } + + return collect(iterator_to_array($body->childNodes)) + ->map(fn (DOMNode $node): string => $document->saveHTML($node) ?: '') + ->implode(''); + } + + private function unwrap(DOMElement $node): void + { + $parent = $node->parentNode; + while ($node->firstChild) { + $parent->insertBefore($node->firstChild, $node); + } + $parent->removeChild($node); + } + + private function safeHref(string $href): bool + { + $href = trim($href); + if ($href === '') { + return false; + } + if (str_starts_with($href, '/') && ! str_starts_with($href, '//')) { + return true; + } + $scheme = strtolower((string) parse_url($href, PHP_URL_SCHEME)); + + return in_array($scheme, ['http', 'https', 'mailto'], true); + } +} diff --git a/backend/app/Modules/Courses/Domain/Block.php b/backend/app/Modules/Courses/Domain/Block.php new file mode 100644 index 0000000..eb2563a --- /dev/null +++ b/backend/app/Modules/Courses/Domain/Block.php @@ -0,0 +1,24 @@ + 'integer', 'position' => 'integer', 'revision' => 'integer', 'data' => 'array', + 'style' => 'array', 'behavior' => 'array', 'responsive' => 'array', 'accessibility' => 'array', + ]; + } +} diff --git a/backend/app/Modules/Courses/Domain/Course.php b/backend/app/Modules/Courses/Domain/Course.php new file mode 100644 index 0000000..6ae83fa --- /dev/null +++ b/backend/app/Modules/Courses/Domain/Course.php @@ -0,0 +1,38 @@ +hasMany(CourseVersion::class); + } + + public function latestVersion(): HasOne + { + return $this->hasOne(CourseVersion::class)->ofMany('version_number', 'max'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function coverAsset(): BelongsTo + { + return $this->belongsTo(Asset::class, 'cover_asset_id'); + } +} diff --git a/backend/app/Modules/Courses/Domain/CourseModule.php b/backend/app/Modules/Courses/Domain/CourseModule.php new file mode 100644 index 0000000..393272f --- /dev/null +++ b/backend/app/Modules/Courses/Domain/CourseModule.php @@ -0,0 +1,26 @@ + 'integer', 'settings' => 'array', 'is_locked' => 'boolean']; + } + + public function lessons(): HasMany + { + return $this->hasMany(Lesson::class); + } +} diff --git a/backend/app/Modules/Courses/Domain/CourseVersion.php b/backend/app/Modules/Courses/Domain/CourseVersion.php new file mode 100644 index 0000000..f844337 --- /dev/null +++ b/backend/app/Modules/Courses/Domain/CourseVersion.php @@ -0,0 +1,93 @@ + CourseVersionStatus::class, + 'settings' => 'array', + 'completion_rules' => 'array', + 'taxonomy_snapshot' => 'array', + 'published_at' => 'immutable_datetime', + 'review_submitted_at' => 'immutable_datetime', + 'scheduled_publish_at' => 'immutable_datetime', + 'scheduled_unpublish_at' => 'immutable_datetime', + 'unpublished_at' => 'immutable_datetime', + ]; + } + + protected static function booted(): void + { + static::updating(function (self $version) { + if ($version->getOriginal('status') === CourseVersionStatus::Published->value) { + throw new DomainException('Published course versions are immutable.'); + } + }); + + static::deleting(function (self $version) { + if ($version->status === CourseVersionStatus::Published) { + throw new DomainException('Published course versions cannot be deleted.'); + } + }); + } + + public function course(): BelongsTo + { + return $this->belongsTo(Course::class); + } + + public function modules(): HasMany + { + return $this->hasMany(CourseModule::class); + } + + public function lessons(): HasMany + { + return $this->hasMany(Lesson::class); + } + + public function blocks(): HasMany + { + return $this->hasMany(Block::class); + } + + public function assessments(): HasMany + { + return $this->hasMany(Assessment::class); + } + + public function sourceVersion(): BelongsTo + { + return $this->belongsTo(self::class, 'source_version_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function publisher(): BelongsTo + { + return $this->belongsTo(User::class, 'published_by'); + } +} diff --git a/backend/app/Modules/Courses/Domain/Enums/CourseVersionStatus.php b/backend/app/Modules/Courses/Domain/Enums/CourseVersionStatus.php new file mode 100644 index 0000000..68a4327 --- /dev/null +++ b/backend/app/Modules/Courses/Domain/Enums/CourseVersionStatus.php @@ -0,0 +1,10 @@ + 'integer', 'settings' => 'array', 'is_locked' => 'boolean']; + } + + public function blocks(): HasMany + { + return $this->hasMany(Block::class); + } + + public function courseModule(): BelongsTo + { + return $this->belongsTo(CourseModule::class); + } + + public function assessments(): HasMany + { + return $this->hasMany(Assessment::class); + } +} diff --git a/backend/app/Modules/Courses/Http/CourseBuilderController.php b/backend/app/Modules/Courses/Http/CourseBuilderController.php new file mode 100644 index 0000000..c819cc4 --- /dev/null +++ b/backend/app/Modules/Courses/Http/CourseBuilderController.php @@ -0,0 +1,283 @@ +authorizeAuthor($request); + + return response()->json(['data' => $this->registry->metadata()]); + } + + public function show(Request $request, string $course, string $version, string $lesson): JsonResponse + { + $this->authorizeAuthor($request); + [$courseModel, $versionModel, $lessonModel] = $this->scope($course, $version, $lesson); + $modules = $versionModel->modules()->with(['lessons' => fn ($query) => $query->orderBy('position')])->orderBy('position')->get(); + + return response()->json(['data' => [ + 'course' => ['id' => $courseModel->getKey(), 'title' => $courseModel->title], + 'version' => ['id' => $versionModel->getKey(), 'number' => $versionModel->version_number, 'status' => $versionModel->status->value], + 'lesson' => ['id' => $lessonModel->getKey(), 'title' => $lessonModel->title, 'presentationMode' => $lessonModel->presentation_mode], + 'structure' => $modules->map(fn ($module) => [ + 'id' => $module->getKey(), 'title' => $module->title, 'position' => $module->position, 'locked' => $module->is_locked, + 'lessons' => $module->lessons->map(fn (Lesson $item) => ['id' => $item->getKey(), 'title' => $item->title, 'position' => $item->position, 'locked' => $item->is_locked])->values(), + ])->values(), + 'blocks' => $lessonModel->blocks()->orderBy('position')->get()->map(fn (Block $block) => $this->blockPayload($block))->values(), + ]]); + } + + public function store(StoreBlockRequest $request, string $course, string $version, string $lesson): JsonResponse + { + $this->authorizeAuthor($request); + [, $versionModel, $lessonModel] = $this->scope($course, $version, $lesson); + $this->assertDraft($versionModel); + $this->assertStructureUnlocked($lessonModel); + $input = $request->validated(); + $data = $this->registry->validate($input['type'], (int) $input['schemaVersion'], $input['data']); + $contract = $this->contract->validate($input); + $this->assertAssetReferences($data); + + $block = DB::transaction(function () use ($versionModel, $lessonModel, $input, $data, $contract) { + $position = min((int) ($input['insertionPosition'] ?? ($lessonModel->blocks()->count() + 1)), $lessonModel->blocks()->count() + 1); + $lessonModel->blocks()->where('position', '>=', $position)->orderByDesc('position')->get()->each(fn (Block $item) => $item->increment('position')); + + return Block::query()->create([ + 'organization_id' => $this->tenant->id(), + 'course_version_id' => $versionModel->getKey(), + 'lesson_id' => $lessonModel->getKey(), + 'type' => $input['type'], + 'schema_version' => $input['schemaVersion'], + 'data' => $data, + 'style' => $contract['style'] ?? null, + 'behavior' => $contract['behavior'] ?? null, + 'responsive' => $contract['responsive'] ?? null, + 'accessibility' => $contract['accessibility'] ?? null, + 'position' => $position, + 'revision' => 1, + ]); + }); + + return response()->json(['data' => $this->blockPayload($block)], 201); + } + + public function update(UpdateBlockRequest $request, string $block): JsonResponse + { + $this->authorizeAuthor($request); + $model = $this->tenantBlock($block); + $this->softLocks->assertEditable($model, $request->user()); + $version = $this->tenantVersion($model->course_version_id); + $this->assertDraft($version); + $this->assertStructureUnlocked(Lesson::query()->findOrFail($model->lesson_id)); + $input = $request->validated(); + $this->assertBlockUpdateAllowed($model, $input); + $data = $this->registry->validate($model->type, $model->schema_version, $input['data']); + $contract = $this->contract->validate($input); + $this->assertAssetReferences($data); + + $attributes = ['data' => $data, 'revision' => DB::raw('revision + 1'), 'updated_at' => now()]; + foreach (['style', 'behavior', 'responsive', 'accessibility'] as $field) { + if (array_key_exists($field, $contract)) { + $attributes[$field] = $contract[$field]; + } + } + + $updated = Block::query()->whereKey($model->getKey())->where('revision', $input['expectedRevision'])->update($attributes); + if ($updated !== 1) { + return response()->json(['error' => ['code' => 'revision_conflict', 'message' => 'This block was changed elsewhere.', 'current' => $this->blockPayload($model->fresh())]], 409); + } + + return response()->json(['data' => $this->blockPayload($model->fresh())]); + } + + public function reorder(ReorderBlocksRequest $request, string $lesson): JsonResponse + { + $this->authorizeAuthor($request); + $lessonModel = Lesson::query()->where('organization_id', $this->tenant->id())->findOrFail($lesson); + $this->assertDraft($this->tenantVersion($lessonModel->course_version_id)); + $this->assertStructureUnlocked($lessonModel); + if ($lessonModel->blocks()->get()->contains(fn (Block $block) => (bool) data_get($block->behavior, 'locked', false))) { + throw ValidationException::withMessages(['locked' => ['Locked blocks cannot be reordered.']]); + } + $ids = $request->validated('blockIds'); + $actual = $lessonModel->blocks()->pluck('id')->all(); + if (count($ids) !== count($actual) || array_diff($ids, $actual) || array_diff($actual, $ids)) { + throw ValidationException::withMessages(['blockIds' => ['The order must contain every lesson block exactly once.']]); + } + + DB::transaction(function () use ($ids, $lessonModel) { + $lessonModel->blocks()->update(['position' => DB::raw('position + 100000')]); + foreach ($ids as $index => $id) { + Block::query()->whereKey($id)->update(['position' => $index + 1]); + } + }); + + return response()->json(['data' => ['blockIds' => $ids]]); + } + + public function destroy(Request $request, string $block): JsonResponse + { + $this->authorizeAuthor($request); + $model = $this->tenantBlock($block); + $this->softLocks->assertEditable($model, $request->user()); + $this->assertDraft($this->tenantVersion($model->course_version_id)); + $this->assertStructureUnlocked(Lesson::query()->findOrFail($model->lesson_id)); + $this->assertBlockUnlocked($model); + DB::transaction(function () use ($model) { + $lessonId = $model->lesson_id; + $position = $model->position; + $model->delete(); + Block::query()->where('lesson_id', $lessonId)->where('position', '>', $position)->orderBy('position')->each(fn (Block $item) => $item->decrement('position')); + }); + + return response()->json(status: 204); + } + + public function duplicate(Request $request, string $block): JsonResponse + { + $this->authorizeAuthor($request); + $source = $this->tenantBlock($block); + $this->softLocks->assertEditable($source, $request->user()); + $this->assertDraft($this->tenantVersion($source->course_version_id)); + $lesson = Lesson::query()->where('organization_id', $this->tenant->id())->findOrFail($source->lesson_id); + $this->assertStructureUnlocked($lesson); + $this->assertBlockUnlocked($source); + $copy = DB::transaction(function () use ($source, $lesson) { + $position = $source->position + 1; + $lesson->blocks()->where('position', '>=', $position)->orderByDesc('position')->get()->each(fn (Block $item) => $item->increment('position')); + + return Block::query()->create([ + 'organization_id' => $source->organization_id, 'course_version_id' => $source->course_version_id, + 'lesson_id' => $source->lesson_id, 'type' => $source->type, 'schema_version' => $source->schema_version, + 'data' => $source->data, 'style' => $source->style, 'behavior' => $source->behavior, + 'responsive' => $source->responsive, 'accessibility' => $source->accessibility, + 'position' => $position, 'revision' => 1, + ]); + }); + + return response()->json(['data' => $this->blockPayload($copy)], 201); + } + + /** @return array{Course, CourseVersion, Lesson} */ + private function scope(string $course, string $version, string $lesson): array + { + $courseModel = Course::query()->where('organization_id', $this->tenant->id())->findOrFail($course); + $versionModel = CourseVersion::query()->where('organization_id', $this->tenant->id())->where('course_id', $courseModel->getKey())->findOrFail($version); + $lessonModel = Lesson::query()->where('organization_id', $this->tenant->id())->where('course_version_id', $versionModel->getKey())->findOrFail($lesson); + + return [$courseModel, $versionModel, $lessonModel]; + } + + private function tenantBlock(string $id): Block + { + return Block::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function tenantVersion(string $id): CourseVersion + { + return CourseVersion::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function assertDraft(CourseVersion $version): void + { + if ($version->status !== CourseVersionStatus::Draft) { + throw ValidationException::withMessages(['version' => ['Only draft course versions can be edited.']]); + } + } + + private function assertStructureUnlocked(Lesson $lesson): void + { + $module = $lesson->courseModule()->firstOrFail(); + if ($lesson->is_locked || $module->is_locked) { + throw ValidationException::withMessages(['locked' => ['The lesson or its module is locked.']]); + } + } + + private function assertBlockUnlocked(Block $block): void + { + if ((bool) data_get($block->behavior, 'locked', false)) { + throw ValidationException::withMessages(['locked' => ['The block is locked.']]); + } + } + + /** @param array $input */ + private function assertBlockUpdateAllowed(Block $block, array $input): void + { + if (! (bool) data_get($block->behavior, 'locked', false)) { + return; + } + $expectedBehavior = $block->behavior ?? []; + $expectedBehavior['locked'] = false; + $unlockOnly = data_get($input, 'behavior.locked') === false + && ($input['data'] ?? null) == $block->data + && ($input['behavior'] ?? []) == $expectedBehavior + && ($input['style'] ?? $block->style ?? []) == ($block->style ?? []) + && ($input['responsive'] ?? $block->responsive ?? []) == ($block->responsive ?? []) + && ($input['accessibility'] ?? $block->accessibility ?? []) == ($block->accessibility ?? []); + if (! $unlockOnly) { + $this->assertBlockUnlocked($block); + } + } + + /** @param array $data */ + private function assertAssetReferences(array $data): void + { + $ids = $this->assetUsage->assetIds($data); + if ($ids === []) { + return; + } + $count = Asset::query()->where('organization_id', $this->tenant->id())->whereIn('id', $ids)->count(); + if ($count !== count($ids)) { + throw ValidationException::withMessages(['data.assetId' => ['Every referenced asset must belong to the current organization.']]); + } + } + + private function authorizeAuthor(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } + + /** @return array */ + private function blockPayload(Block $block): array + { + return [ + 'id' => $block->getKey(), 'type' => $block->type, 'schemaVersion' => $block->schema_version, + 'data' => $block->data, 'style' => $block->style, 'behavior' => $block->behavior, + 'responsive' => $block->responsive, 'accessibility' => $block->accessibility, + 'position' => $block->position, 'revision' => $block->revision, 'updatedAt' => $block->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Modules/Courses/Http/CourseController.php b/backend/app/Modules/Courses/Http/CourseController.php new file mode 100644 index 0000000..8507b55 --- /dev/null +++ b/backend/app/Modules/Courses/Http/CourseController.php @@ -0,0 +1,290 @@ +authorizeAuthor($request); + $filters = $request->validate([ + 'search' => ['nullable', 'string', 'max:180'], + 'status' => ['nullable', Rule::in(['draft', 'in_review', 'published', 'archived'])], + 'sort' => ['nullable', Rule::in(['updatedAt', 'createdAt', 'title'])], + 'direction' => ['nullable', Rule::in(['asc', 'desc'])], + 'page' => ['nullable', 'integer', 'min:1'], + 'perPage' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + $sort = match ($filters['sort'] ?? 'updatedAt') { + 'createdAt' => 'created_at', 'title' => 'title', default => 'updated_at' + }; + $courses = Course::query() + ->where('organization_id', $this->tenant->id()) + ->when(! isset($filters['status']), fn ($query) => $query->where('status', '!=', 'archived')) + ->when($filters['search'] ?? null, fn ($query, string $search) => $query->where('title', 'like', '%'.$search.'%')) + ->when($filters['status'] ?? null, fn ($query, string $status) => $query->where('status', $status)) + ->with(['creator:id,name', 'coverAsset', 'latestVersion' => fn ($query) => $query->withCount(['modules', 'lessons'])]) + ->orderBy($sort, $filters['direction'] ?? 'desc') + ->paginate($filters['perPage'] ?? 25) + ->through(fn (Course $course) => $this->summary($course, (string) $request->user()->getKey())); + + return response()->json(['data' => $courses->items(), 'meta' => [ + 'currentPage' => $courses->currentPage(), 'lastPage' => $courses->lastPage(), 'total' => $courses->total(), + ]]); + } + + public function store(StoreCourseRequest $request): JsonResponse + { + $this->authorizeAuthor($request); + $data = $request->validated(); + $course = DB::transaction(function () use ($request, $data) { + $course = Course::query()->create([ + 'organization_id' => $this->tenant->id(), + 'title' => $data['title'], + 'slug' => $this->uniqueSlug($data['title']), + 'status' => 'draft', + 'created_by' => $request->user()->getKey(), + ]); + $version = CourseVersion::query()->create([ + 'organization_id' => $this->tenant->id(), + 'course_id' => $course->getKey(), + 'created_by' => $request->user()->getKey(), + 'version_number' => 1, + 'status' => CourseVersionStatus::Draft, + 'title' => $data['title'], + 'description' => $data['description'] ?? null, + 'settings' => ['language' => $data['language'], 'difficulty' => $data['difficulty'] ?? null], + ]); + $module = CourseModule::query()->create([ + 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), + 'title' => 'ماژول اول', 'position' => 1, + ]); + Lesson::query()->create([ + 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), + 'course_module_id' => $module->getKey(), 'title' => 'درس اول', 'position' => 1, + ]); + + return $course; + }); + + return response()->json(['data' => ['id' => $course->getKey(), 'workspaceUrl' => '/app/courses/'.$course->getKey()]], 201); + } + + public function show(Request $request, string $course): JsonResponse + { + $this->authorizeAuthor($request); + $model = $this->tenantCourse($course); + $versionQuery = CourseVersion::query()->where('organization_id', $this->tenant->id())->where('course_id', $model->getKey()); + $version = $request->filled('versionId') + ? (clone $versionQuery)->findOrFail($request->string('versionId')->toString()) + : (clone $versionQuery)->latest('version_number')->firstOrFail(); + $version->load(['modules' => fn ($query) => $query->orderBy('position')->with([ + 'lessons' => fn ($lessons) => $lessons->orderBy('position')->with(['blocks:id,lesson_id,type'])->withCount(['blocks', 'assessments']), + ])]); + $history = $versionQuery->orderByDesc('version_number')->get(); + $model->load(['creator:id,name', 'coverAsset']); + + return response()->json(['data' => [ + 'course' => ['id' => $model->getKey(), 'title' => $model->title, 'slug' => $model->slug, 'status' => $model->status, 'owner' => $model->creator?->name, 'cover' => $this->coverPayload($model), 'updatedAt' => $model->updated_at?->toISOString()], + 'version' => $this->versionPayload($version), + 'versions' => $history->map(fn (CourseVersion $item) => $this->versionPayload($item))->values(), + 'modules' => $version->modules->map(fn (CourseModule $module) => [ + 'id' => $module->getKey(), 'title' => $module->title, 'position' => $module->position, 'locked' => $module->is_locked, + 'lessons' => $module->lessons->map(fn (Lesson $lesson) => $this->lessonPayload($lesson))->values(), + ])->values(), + ]]); + } + + public function updateVersion(UpdateCourseVersionRequest $request, string $course, string $version): JsonResponse + { + $this->authorizeAuthor($request); + $courseModel = $this->tenantCourse($course); + $model = $this->tenantVersion($courseModel, $version); + $this->versions->assertDraft($model); + $data = $request->validated(); + $settings = $model->settings ?? []; + foreach (['language', 'difficulty'] as $field) { + if (array_key_exists($field, $data)) { + $settings[$field] = $data[$field]; + } + } + $model->update([ + ...collect($data)->only(['title', 'description'])->all(), + 'settings' => $settings, + ]); + if (isset($data['title']) && $model->version_number === $courseModel->versions()->max('version_number')) { + $courseModel->update(['title' => $data['title']]); + } + + return response()->json(['data' => $this->versionPayload($model->fresh())]); + } + + public function update(Request $request, string $course): JsonResponse + { + $this->authorizeAuthor($request); + $data = $request->validate(['coverAssetId' => ['nullable', 'string']]); + $model = $this->tenantCourse($course); + if ($data['coverAssetId'] ?? null) { + $asset = Asset::query()->where('organization_id', $this->tenant->id())->findOrFail($data['coverAssetId']); + if ($asset->kind !== 'image') { + throw ValidationException::withMessages(['coverAssetId' => ['Course cover must be an image asset.']]); + } + } + $model->update(['cover_asset_id' => $data['coverAssetId'] ?? null]); + $model->load('coverAsset'); + + return response()->json(['data' => ['id' => $model->getKey(), 'cover' => $this->coverPayload($model)]]); + } + + public function fork(Request $request, string $course, string $version): JsonResponse + { + $this->authorizeAuthor($request); + $courseModel = $this->tenantCourse($course); + $source = $this->tenantVersion($courseModel, $version); + $draft = $this->versions->forkPublished($courseModel, $source, (string) $request->user()->getKey()); + + return response()->json(['data' => $this->versionPayload($draft)], 201); + } + + public function toggleBookmark(Request $request, string $course): JsonResponse + { + $this->authorizeAuthor($request); + $model = $this->tenantCourse($course); + $key = ['course_id' => $model->getKey(), 'user_id' => $request->user()->getKey()]; + $bookmarked = ! DB::table('course_bookmarks')->where($key)->exists(); + if ($bookmarked) { + DB::table('course_bookmarks')->insert([...$key, 'created_at' => now(), 'updated_at' => now()]); + } else { + DB::table('course_bookmarks')->where($key)->delete(); + } + + return response()->json(['data' => ['bookmarked' => $bookmarked]]); + } + + public function archive(Request $request, string $course): JsonResponse + { + $this->authorizeAuthor($request); + $model = $this->tenantCourse($course); + $model->update(['status' => 'archived']); + + return response()->json(['data' => ['id' => $model->getKey(), 'status' => 'archived']]); + } + + private function tenantCourse(string $id): Course + { + return Course::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function tenantVersion(Course $course, string $id): CourseVersion + { + return CourseVersion::query()->where('organization_id', $this->tenant->id())->where('course_id', $course->getKey())->findOrFail($id); + } + + private function uniqueSlug(string $title): string + { + $base = Str::slug($title) ?: 'course-'.Str::lower(Str::random(8)); + $slug = $base; + $suffix = 2; + while (Course::query()->where('organization_id', $this->tenant->id())->where('slug', $slug)->exists()) { + $slug = $base.'-'.$suffix++; + } + + return $slug; + } + + private function summary(Course $course, string $userId): array + { + $version = $course->latestVersion; + + return [ + 'id' => $course->getKey(), 'title' => $course->title, 'slug' => $course->slug, 'status' => $course->status, + 'owner' => $course->creator?->name, 'versionId' => $version?->getKey(), 'versionNumber' => $version?->version_number, + 'versionStatus' => $version?->status->value, 'moduleCount' => $version?->modules_count ?? 0, + 'lessonCount' => $version?->lessons_count ?? 0, 'updatedAt' => $course->updated_at?->toISOString(), + 'cover' => $this->coverPayload($course), 'bookmarked' => DB::table('course_bookmarks')->where('course_id', $course->getKey())->where('user_id', $userId)->exists(), + ]; + } + + private function coverPayload(Course $course): ?array + { + $asset = $course->coverAsset; + if (! $asset) { + return null; + } + + return [ + 'assetId' => $asset->getKey(), + 'altText' => $asset->alt_text, + 'contentUrl' => URL::temporarySignedRoute('assets.content', now()->addMinutes(10), ['asset' => $asset->getKey()], absolute: false), + ]; + } + + private function versionPayload(CourseVersion $version): array + { + return [ + 'id' => $version->getKey(), 'number' => $version->version_number, 'status' => $version->status->value, + 'title' => $version->title, 'description' => $version->description, 'settings' => $version->settings ?? [], + 'sourceVersionId' => $version->source_version_id, 'publishedAt' => $version->published_at?->toISOString(), + 'createdAt' => $version->created_at?->toISOString(), 'updatedAt' => $version->updated_at?->toISOString(), + ]; + } + + private function lessonPayload(Lesson $lesson): array + { + $settings = $lesson->settings ?? []; + $types = $lesson->blocks->pluck('type')->unique()->values(); + $contentType = match (true) { + ($lesson->assessments_count ?? 0) > 0 => 'assessment', + $types->contains(fn (string $type) => str_contains($type, 'scenario') || str_contains($type, 'branch')) => 'scenario', + $types->contains('video') => 'video', + $types->contains('audio') => 'audio', + $types->contains(fn (string $type) => str_contains($type, 'pdf')) => 'pdf', + $types->contains(fn (string $type) => in_array($type, ['file', 'document', 'download'], true)) => 'file', + $types->contains(fn (string $type) => str_contains($type, 'exercise') || str_contains($type, 'practice')) => 'exercise', + default => 'block', + }; + + return [ + 'id' => $lesson->getKey(), 'title' => $lesson->title, 'position' => $lesson->position, + 'presentationMode' => $lesson->presentation_mode, 'blockCount' => $lesson->blocks_count, + 'locked' => $lesson->is_locked, 'contentType' => $settings['contentType'] ?? $contentType, + 'durationMinutes' => isset($settings['durationMinutes']) ? (int) $settings['durationMinutes'] : null, + 'description' => $settings['description'] ?? null, 'status' => $settings['status'] ?? null, + 'assetCount' => $types->filter(fn (string $type) => in_array($type, ['image', 'video', 'audio', 'file', 'document', 'pdf'], true))->count(), + 'assessmentCount' => (int) ($lesson->assessments_count ?? 0), + 'prerequisites' => array_values($settings['prerequisites'] ?? []), 'blockTypes' => $types, + ]; + } + + private function authorizeAuthor(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } +} diff --git a/backend/app/Modules/Courses/Http/CourseStructureController.php b/backend/app/Modules/Courses/Http/CourseStructureController.php new file mode 100644 index 0000000..a907388 --- /dev/null +++ b/backend/app/Modules/Courses/Http/CourseStructureController.php @@ -0,0 +1,304 @@ +authorize($request); + $versionModel = $this->version($version); + $this->versions->assertDraft($versionModel); + $module = CourseModule::query()->create(['organization_id' => $this->tenant->id(), 'course_version_id' => $versionModel->getKey(), 'title' => $request->validated('title'), 'position' => ((int) $versionModel->modules()->max('position')) + 1]); + + return response()->json(['data' => $this->modulePayload($module)], 201); + } + + public function updateModule(UpdateModuleRequest $request, string $module): JsonResponse + { + $this->authorize($request); + $model = $this->module($module); + $this->versions->assertDraft($this->version($model->course_version_id)); + $data = $request->validated(); + if ($model->is_locked && (($data['locked'] ?? null) !== false || count($data) !== 1)) { + $this->locked('module'); + } + $attributes = []; + if (isset($data['title'])) { + $attributes['title'] = $data['title']; + } + if (array_key_exists('locked', $data)) { + $attributes['is_locked'] = $data['locked']; + } + $model->update($attributes); + + return response()->json(['data' => $this->modulePayload($model)]); + } + + public function destroyModule(Request $request, string $module): JsonResponse + { + $this->authorize($request); + $model = $this->module($module); + $this->versions->assertDraft($this->version($model->course_version_id)); + $this->assertUnlocked($model); + if ($model->lessons()->where('is_locked', true)->exists()) { + $this->locked('lesson'); + } + DB::transaction(function () use ($model) { + $versionId = $model->course_version_id; + $position = $model->position; + $model->delete(); + CourseModule::query()->where('course_version_id', $versionId)->where('position', '>', $position)->decrement('position'); + }); + + return response()->json(status: 204); + } + + public function reorderModules(ReorderStructureRequest $request, string $version): JsonResponse + { + $this->authorize($request); + $model = $this->version($version); + $this->versions->assertDraft($model); + $ids = $request->validated('ids'); + $this->assertComplete($ids, $model->modules()->pluck('id')->all()); + if ($model->modules()->where('is_locked', true)->exists()) { + $this->locked('module order'); + } + $this->reorder(CourseModule::class, 'course_version_id', $model->getKey(), $ids); + + return response()->json(['data' => ['ids' => $ids]]); + } + + public function storeLesson(StoreLessonRequest $request, string $module): JsonResponse + { + $this->authorize($request); + $moduleModel = $this->module($module); + $version = $this->version($moduleModel->course_version_id); + $this->versions->assertDraft($version); + $this->assertUnlocked($moduleModel); + $lesson = Lesson::query()->create(['organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), 'course_module_id' => $moduleModel->getKey(), 'title' => $request->validated('title'), 'presentation_mode' => $request->validated('presentationMode', 'flow'), 'position' => ((int) $moduleModel->lessons()->max('position')) + 1]); + + return response()->json(['data' => $this->lessonPayload($lesson)], 201); + } + + public function updateLesson(UpdateLessonRequest $request, string $lesson): JsonResponse + { + $this->authorize($request); + $model = $this->lesson($lesson); + $version = $this->version($model->course_version_id); + $this->versions->assertDraft($version); + $data = $request->validated(); + $currentModule = $this->module($model->course_module_id); + $this->assertUnlocked($currentModule); + if ($model->is_locked && (($data['locked'] ?? null) !== false || count($data) !== 1)) { + $this->locked('lesson'); + } + DB::transaction(function () use ($model, $version, $data) { + $attributes = []; + if (isset($data['title'])) { + $attributes['title'] = $data['title']; + } + if (isset($data['presentationMode'])) { + $attributes['presentation_mode'] = $data['presentationMode']; + } + if (array_key_exists('locked', $data)) { + $attributes['is_locked'] = $data['locked']; + } + if (isset($data['moduleId']) && $data['moduleId'] !== $model->course_module_id) { + $target = CourseModule::query()->where('organization_id', $this->tenant->id())->where('course_version_id', $version->getKey())->findOrFail($data['moduleId']); + $this->assertUnlocked($target); + $oldModule = $model->course_module_id; + $oldPosition = $model->position; + $attributes['course_module_id'] = $target->getKey(); + $attributes['position'] = ((int) $target->lessons()->max('position')) + 1; + $model->update($attributes); + Lesson::query()->where('course_module_id', $oldModule)->where('position', '>', $oldPosition)->decrement('position'); + + return; + } + $model->update($attributes); + }); + + return response()->json(['data' => $this->lessonPayload($model->fresh())]); + } + + public function destroyLesson(Request $request, string $lesson): JsonResponse + { + $this->authorize($request); + $model = $this->lesson($lesson); + $this->versions->assertDraft($this->version($model->course_version_id)); + $this->assertUnlocked($this->module($model->course_module_id)); + $this->assertUnlocked($model); + DB::transaction(function () use ($model) { + $moduleId = $model->course_module_id; + $position = $model->position; + $model->delete(); + Lesson::query()->where('course_module_id', $moduleId)->where('position', '>', $position)->decrement('position'); + }); + + return response()->json(status: 204); + } + + public function reorderLessons(ReorderStructureRequest $request, string $module): JsonResponse + { + $this->authorize($request); + $moduleModel = $this->module($module); + $this->versions->assertDraft($this->version($moduleModel->course_version_id)); + $ids = $request->validated('ids'); + $this->assertComplete($ids, $moduleModel->lessons()->pluck('id')->all()); + if ($moduleModel->is_locked || $moduleModel->lessons()->where('is_locked', true)->exists()) { + $this->locked('lesson order'); + } + $this->reorder(Lesson::class, 'course_module_id', $moduleModel->getKey(), $ids); + + return response()->json(['data' => ['ids' => $ids]]); + } + + private function reorder(string $model, string $parentKey, string $parentId, array $ids): void + { + DB::transaction(function () use ($model, $parentKey, $parentId, $ids) { + $model::query()->where($parentKey, $parentId)->update(['position' => DB::raw('position + 100000')]); + foreach ($ids as $index => $id) { + $model::query()->whereKey($id)->update(['position' => $index + 1]); + } + }); + } + + private function assertComplete(array $ids, array $actual): void + { + if (count($ids) !== count($actual) || array_diff($ids, $actual) || array_diff($actual, $ids)) { + throw ValidationException::withMessages(['ids' => ['The order must contain every item exactly once.']]); + } + } + + public function duplicateModule(Request $request, string $module): JsonResponse + { + $this->authorize($request); + $source = $this->module($module); + $version = $this->version($source->course_version_id); + $this->versions->assertDraft($version); + $this->assertUnlocked($source); + $copy = DB::transaction(function () use ($source, $version) { + $source->load('lessons.blocks'); + $module = CourseModule::query()->create([ + 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), + 'title' => $source->title.' — کپی', 'position' => ((int) $version->modules()->max('position')) + 1, + 'settings' => $source->settings, 'is_locked' => false, + ]); + foreach ($source->lessons->sortBy('position') as $lesson) { + $newLesson = Lesson::query()->create([ + 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), + 'course_module_id' => $module->getKey(), 'title' => $lesson->title, + 'position' => $lesson->position, 'presentation_mode' => $lesson->presentation_mode, + 'settings' => $lesson->settings, 'is_locked' => false, + ]); + $this->copyBlocks($lesson, $newLesson); + } + + return $module; + }); + + return response()->json(['data' => $this->modulePayload($copy)], 201); + } + + public function duplicateLesson(Request $request, string $lesson): JsonResponse + { + $this->authorize($request); + $source = $this->lesson($lesson); + $version = $this->version($source->course_version_id); + $this->versions->assertDraft($version); + $module = $this->module($source->course_module_id); + $this->assertUnlocked($module); + $this->assertUnlocked($source); + $copy = DB::transaction(function () use ($source, $version, $module) { + $lesson = Lesson::query()->create([ + 'organization_id' => $this->tenant->id(), 'course_version_id' => $version->getKey(), + 'course_module_id' => $module->getKey(), 'title' => $source->title.' — کپی', + 'position' => ((int) $module->lessons()->max('position')) + 1, + 'presentation_mode' => $source->presentation_mode, 'settings' => $source->settings, 'is_locked' => false, + ]); + $this->copyBlocks($source, $lesson); + + return $lesson; + }); + + return response()->json(['data' => $this->lessonPayload($copy)], 201); + } + + private function copyBlocks(Lesson $source, Lesson $target): void + { + $source->loadMissing('blocks'); + foreach ($source->blocks->sortBy('position') as $block) { + Block::query()->create([ + 'organization_id' => $this->tenant->id(), 'course_version_id' => $target->course_version_id, + 'lesson_id' => $target->getKey(), 'type' => $block->type, 'schema_version' => $block->schema_version, + 'data' => $block->data, 'style' => $block->style, 'behavior' => $block->behavior, + 'responsive' => $block->responsive, 'accessibility' => $block->accessibility, + 'position' => $block->position, 'revision' => 1, + ]); + } + } + + private function assertUnlocked(CourseModule|Lesson $model): void + { + if ($model->is_locked) { + $this->locked($model instanceof CourseModule ? 'module' : 'lesson'); + } + } + + private function locked(string $target): never + { + throw ValidationException::withMessages(['locked' => ["The {$target} is locked."]]); + } + + private function version(string $id): CourseVersion + { + return CourseVersion::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function module(string $id): CourseModule + { + return CourseModule::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function lesson(string $id): Lesson + { + return Lesson::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function modulePayload(CourseModule $module): array + { + return ['id' => $module->getKey(), 'title' => $module->title, 'position' => $module->position, 'locked' => $module->is_locked, 'lessons' => []]; + } + + private function lessonPayload(Lesson $lesson): array + { + return ['id' => $lesson->getKey(), 'title' => $lesson->title, 'position' => $lesson->position, 'presentationMode' => $lesson->presentation_mode, 'locked' => $lesson->is_locked, 'blockCount' => $lesson->blocks()->count()]; + } + + private function authorize(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } +} diff --git a/backend/app/Modules/Courses/Http/CourseVersionController.php b/backend/app/Modules/Courses/Http/CourseVersionController.php new file mode 100644 index 0000000..cb0594d --- /dev/null +++ b/backend/app/Modules/Courses/Http/CourseVersionController.php @@ -0,0 +1,96 @@ +authorize($request); + $courseModel = $this->course($course); + $filters = $request->validate([ + 'search' => ['nullable', 'string', 'max:100'], + 'status' => ['nullable', Rule::in(['draft', 'in_review', 'published'])], + 'page' => ['nullable', 'integer', 'min:1'], + 'perPage' => ['nullable', 'integer', 'min:1', 'max:50'], + ]); + $base = CourseVersion::query()->where('organization_id', $this->tenant->id())->where('course_id', $courseModel->getKey()); + $summary = [ + 'total' => (clone $base)->count(), + 'published' => (clone $base)->where('status', 'published')->count(), + 'drafts' => (clone $base)->whereIn('status', ['draft', 'in_review'])->count(), + ]; + $search = trim((string) ($filters['search'] ?? '')); + $versions = $base + ->when($search !== '', function ($query) use ($search) { + $query->where(function ($nested) use ($search) { + $nested->where('title', 'like', '%'.$search.'%')->orWhere('description', 'like', '%'.$search.'%'); + if (ctype_digit($search)) { + $nested->orWhere('version_number', (int) $search); + } + }); + }) + ->when($filters['status'] ?? null, fn ($query, string $status) => $query->where('status', $status)) + ->with(['creator:id,name', 'publisher:id,name', 'modules.lessons.blocks', 'assessments.questions']) + ->withCount(['modules', 'lessons', 'blocks', 'assessments']) + ->orderByDesc('version_number') + ->paginate($filters['perPage'] ?? 10); + + return response()->json(['data' => [ + 'summary' => $summary, + 'items' => $versions->getCollection()->map(fn (CourseVersion $version) => $this->payload($version))->values(), + 'meta' => ['currentPage' => $versions->currentPage(), 'lastPage' => $versions->lastPage(), 'total' => $versions->total()], + ]]); + } + + public function compare(Request $request, string $course): JsonResponse + { + $this->authorize($request); + $courseModel = $this->course($course); + $data = $request->validate(['ids' => ['required', 'array', 'size:2'], 'ids.*' => ['required', 'string', 'distinct']]); + $versions = CourseVersion::query()->where('organization_id', $this->tenant->id())->where('course_id', $courseModel->getKey())->whereIn('id', $data['ids'])->get()->keyBy(fn (CourseVersion $version) => (string) $version->getKey()); + abort_unless($versions->count() === 2, 422, 'نسخه‌های انتخاب‌شده قابل مقایسه نیستند.'); + $before = $versions->get($data['ids'][0]); + $after = $versions->get($data['ids'][1]); + + return response()->json(['data' => $this->comparison->compare($before, $after)]); + } + + private function payload(CourseVersion $version): array + { + $actor = $version->status->value === 'published' ? ($version->publisher ?? $version->creator) : $version->creator; + + return [ + 'id' => $version->getKey(), 'number' => $version->version_number, 'status' => $version->status->value, + 'title' => $version->title, 'description' => $version->description, 'sourceVersionId' => $version->source_version_id, + 'createdAt' => $version->created_at?->toISOString(), 'updatedAt' => $version->updated_at?->toISOString(), 'publishedAt' => $version->published_at?->toISOString(), + 'actor' => $actor ? ['id' => $actor->getKey(), 'name' => $actor->name] : null, + 'moduleCount' => (int) $version->modules_count, 'lessonCount' => (int) $version->lessons_count, + 'assessmentCount' => (int) $version->assessments_count, 'blockCount' => (int) $version->blocks_count, + 'unpublishedChanges' => $this->comparison->unpublishedChangeCount($version), + ]; + } + + private function course(string $id): Course + { + return Course::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function authorize(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } +} diff --git a/backend/app/Modules/Courses/Http/Requests/ReorderBlocksRequest.php b/backend/app/Modules/Courses/Http/Requests/ReorderBlocksRequest.php new file mode 100644 index 0000000..21118aa --- /dev/null +++ b/backend/app/Modules/Courses/Http/Requests/ReorderBlocksRequest.php @@ -0,0 +1,18 @@ + ['required', 'array'], 'blockIds.*' => ['required', 'string', 'distinct']]; + } +} diff --git a/backend/app/Modules/Courses/Http/Requests/ReorderStructureRequest.php b/backend/app/Modules/Courses/Http/Requests/ReorderStructureRequest.php new file mode 100644 index 0000000..ee73325 --- /dev/null +++ b/backend/app/Modules/Courses/Http/Requests/ReorderStructureRequest.php @@ -0,0 +1,18 @@ + ['required', 'array'], 'ids.*' => ['required', 'string', 'distinct']]; + } +} diff --git a/backend/app/Modules/Courses/Http/Requests/StoreBlockRequest.php b/backend/app/Modules/Courses/Http/Requests/StoreBlockRequest.php new file mode 100644 index 0000000..26c1e37 --- /dev/null +++ b/backend/app/Modules/Courses/Http/Requests/StoreBlockRequest.php @@ -0,0 +1,27 @@ + ['required', 'string', 'max:80'], + 'schemaVersion' => ['required', 'integer', 'min:1'], + 'data' => ['required', 'array'], + 'style' => ['sometimes', 'array'], + 'behavior' => ['sometimes', 'array'], + 'responsive' => ['sometimes', 'array'], + 'accessibility' => ['sometimes', 'array'], + 'insertionPosition' => ['sometimes', 'integer', 'min:1'], + ]; + } +} diff --git a/backend/app/Modules/Courses/Http/Requests/StoreCourseRequest.php b/backend/app/Modules/Courses/Http/Requests/StoreCourseRequest.php new file mode 100644 index 0000000..429e267 --- /dev/null +++ b/backend/app/Modules/Courses/Http/Requests/StoreCourseRequest.php @@ -0,0 +1,26 @@ +user() && app(RolePermissions::class)->allows($this->user(), Permission::CoursesAuthor); + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:180'], + 'description' => ['nullable', 'string', 'max:5000'], + 'language' => ['required', Rule::in(['fa', 'en'])], + 'difficulty' => ['nullable', Rule::in(['beginner', 'intermediate', 'advanced'])], + ]; + } +} diff --git a/backend/app/Modules/Courses/Http/Requests/StoreLessonRequest.php b/backend/app/Modules/Courses/Http/Requests/StoreLessonRequest.php new file mode 100644 index 0000000..5407ec4 --- /dev/null +++ b/backend/app/Modules/Courses/Http/Requests/StoreLessonRequest.php @@ -0,0 +1,19 @@ + ['required', 'string', 'max:180'], 'presentationMode' => ['sometimes', Rule::in(['flow', 'slides'])]]; + } +} diff --git a/backend/app/Modules/Courses/Http/Requests/StoreModuleRequest.php b/backend/app/Modules/Courses/Http/Requests/StoreModuleRequest.php new file mode 100644 index 0000000..bde6e24 --- /dev/null +++ b/backend/app/Modules/Courses/Http/Requests/StoreModuleRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'max:180']]; + } +} diff --git a/backend/app/Modules/Courses/Http/Requests/UpdateBlockRequest.php b/backend/app/Modules/Courses/Http/Requests/UpdateBlockRequest.php new file mode 100644 index 0000000..afed013 --- /dev/null +++ b/backend/app/Modules/Courses/Http/Requests/UpdateBlockRequest.php @@ -0,0 +1,25 @@ + ['required', 'integer', 'min:1'], + 'data' => ['required', 'array'], + 'style' => ['sometimes', 'array'], + 'behavior' => ['sometimes', 'array'], + 'responsive' => ['sometimes', 'array'], + 'accessibility' => ['sometimes', 'array'], + ]; + } +} diff --git a/backend/app/Modules/Courses/Http/Requests/UpdateCourseVersionRequest.php b/backend/app/Modules/Courses/Http/Requests/UpdateCourseVersionRequest.php new file mode 100644 index 0000000..65e2de6 --- /dev/null +++ b/backend/app/Modules/Courses/Http/Requests/UpdateCourseVersionRequest.php @@ -0,0 +1,24 @@ + ['sometimes', 'required', 'string', 'max:180'], + 'description' => ['sometimes', 'nullable', 'string', 'max:5000'], + 'language' => ['sometimes', Rule::in(['fa', 'en'])], + 'difficulty' => ['sometimes', 'nullable', Rule::in(['beginner', 'intermediate', 'advanced'])], + ]; + } +} diff --git a/backend/app/Modules/Courses/Http/Requests/UpdateLessonRequest.php b/backend/app/Modules/Courses/Http/Requests/UpdateLessonRequest.php new file mode 100644 index 0000000..9941de3 --- /dev/null +++ b/backend/app/Modules/Courses/Http/Requests/UpdateLessonRequest.php @@ -0,0 +1,24 @@ + ['sometimes', 'required', 'string', 'max:180'], + 'presentationMode' => ['sometimes', Rule::in(['flow', 'slides'])], + 'moduleId' => ['sometimes', 'required', 'string'], + 'locked' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/backend/app/Modules/Courses/Http/Requests/UpdateModuleRequest.php b/backend/app/Modules/Courses/Http/Requests/UpdateModuleRequest.php new file mode 100644 index 0000000..5e35ddb --- /dev/null +++ b/backend/app/Modules/Courses/Http/Requests/UpdateModuleRequest.php @@ -0,0 +1,18 @@ + ['sometimes', 'required', 'string', 'max:180'], 'locked' => ['sometimes', 'boolean']]; + } +} diff --git a/backend/app/Modules/Events/Domain/EventTaxonomy.php b/backend/app/Modules/Events/Domain/EventTaxonomy.php new file mode 100644 index 0000000..c2a4894 --- /dev/null +++ b/backend/app/Modules/Events/Domain/EventTaxonomy.php @@ -0,0 +1,27 @@ + */ + public static function learning(): array + { + return [ + 'course.opened', 'course.completed', + 'lesson.started', 'lesson.progressed', 'lesson.completed', + 'block.viewed', 'block.interacted', 'block.completed', + 'video.started', 'video.progressed', 'video.completed', 'video.replayed', 'video.skipped', 'video.exited', + 'assessment.started', 'question.answered', 'assessment.completed', + 'comment.created', 'bookmark.created', 'note.created', + ]; + } + + /** @param array $context @return array */ + public static function deviceContext(array $context): array + { + return collect($context)->only(['platform', 'formFactor', 'online', 'appVersion'])->all(); + } +} diff --git a/backend/app/Modules/Evidence/Application/EvidenceGenerationService.php b/backend/app/Modules/Evidence/Application/EvidenceGenerationService.php new file mode 100644 index 0000000..58ad927 --- /dev/null +++ b/backend/app/Modules/Evidence/Application/EvidenceGenerationService.php @@ -0,0 +1,84 @@ + */ + public function fromQuestionResult(QuestionResult $result, EvidenceType $evidenceType = EvidenceType::Assessment): Collection + { + $result->loadMissing('attempt', 'question'); + $attempt = $result->attempt; + $question = $result->question; + + if (! $attempt || ! $question + || $attempt->organization_id !== $result->organization_id + || $question->organization_id !== $result->organization_id + || $question->course_version_id !== $attempt->course_version_id) { + throw ValidationException::withMessages(['questionResult' => ['The question result context is inconsistent.']]); + } + + $mappings = ContentTaxonomyMapping::query() + ->where('organization_id', $result->organization_id) + ->where('course_version_id', $attempt->course_version_id) + ->where('mappable_type', MappableType::Question) + ->where('mappable_id', $question->getKey()) + ->where('mapping_type', MappingType::Assesses) + ->where('confirmation_status', MappingConfirmationStatus::Confirmed) + ->get(); + + $strength = (float) config('capability.evidence_strength.'.$evidenceType->value); + + return DB::transaction(function () use ($mappings, $result, $attempt, $evidenceType, $strength) { + return $mappings->map(function (ContentTaxonomyMapping $mapping) use ($result, $attempt, $evidenceType, $strength) { + $evidence = EvidenceRecord::query()->firstOrCreate( + [ + 'source_type' => 'question_result', + 'source_id' => $result->getKey(), + 'taxonomy_node_id' => $mapping->taxonomy_node_id, + 'content_mapping_id' => $mapping->getKey(), + ], + [ + 'organization_id' => $result->organization_id, + 'learner_id' => $attempt->learner_id, + 'evidence_type' => $evidenceType, + 'raw_value' => $result->raw_value, + 'normalized_value' => $result->normalized_value, + 'strength' => $strength, + 'mapping_weight' => $mapping->weight, + 'occurred_at' => $result->occurred_at, + 'course_version_id' => $attempt->course_version_id, + 'metadata' => [ + 'assessmentId' => $attempt->assessment_id, + 'questionId' => $result->question_id, + 'attemptNumber' => $attempt->attempt_number, + ], + ], + ); + + if ($evidence->wasRecentlyCreated) { + AssessmentEvidenceCreated::dispatch( + $evidence->organization_id, + $evidence->learner_id, + $evidence->getKey(), + $evidence->taxonomy_node_id, + ); + } + + return $evidence; + }); + }); + } +} diff --git a/backend/app/Modules/Evidence/Domain/Enums/EvidenceType.php b/backend/app/Modules/Evidence/Domain/Enums/EvidenceType.php new file mode 100644 index 0000000..93a9340 --- /dev/null +++ b/backend/app/Modules/Evidence/Domain/Enums/EvidenceType.php @@ -0,0 +1,16 @@ + EvidenceType::class, + 'raw_value' => 'array', + 'normalized_value' => 'decimal:4', + 'strength' => 'decimal:4', + 'mapping_weight' => 'decimal:4', + 'occurred_at' => 'immutable_datetime', + 'metadata' => 'array', + ]; + } + + protected static function booted(): void + { + static::updating(fn () => throw new DomainException('Evidence records are immutable.')); + static::deleting(fn () => throw new DomainException('Evidence records are immutable.')); + } +} diff --git a/backend/app/Modules/Evidence/Infrastructure/EvidenceServiceProvider.php b/backend/app/Modules/Evidence/Infrastructure/EvidenceServiceProvider.php new file mode 100644 index 0000000..6495c03 --- /dev/null +++ b/backend/app/Modules/Evidence/Infrastructure/EvidenceServiceProvider.php @@ -0,0 +1,16 @@ +>, blockCount: int} */ + public function check(CourseVersion $version, string $format): array + { + $version->loadMissing('modules.lessons.blocks'); + $static = in_array($format, ['pdf_workbook', 'mp4', 'pptx'], true); + $unsupported = ['drag_drop', 'hotspot', 'branching_scenario', 'interactive_image', 'before_after']; + $warnings = []; + $count = 0; + foreach ($version->modules as $module) { + foreach ($module->lessons as $lesson) { + foreach ($lesson->blocks as $block) { + $count++; + if ($static && in_array($block->type, $unsupported, true)) { + $warnings[] = ['blockId' => (string) $block->getKey(), 'blockType' => $block->type, 'message' => 'این تعامل در خروجی ایستا با تصویر، متن راهنما و پاسخ پیشنهادی نمایش داده می‌شود.']; + } + } + } + } + + return ['compatible' => true, 'warnings' => $warnings, 'blockCount' => $count]; + } +} diff --git a/backend/app/Modules/Export/Application/ProcessExportJob.php b/backend/app/Modules/Export/Application/ProcessExportJob.php new file mode 100644 index 0000000..769d60c --- /dev/null +++ b/backend/app/Modules/Export/Application/ProcessExportJob.php @@ -0,0 +1,44 @@ +where('id', $this->exportId)->first(); + if (! $job || in_array($job->status, ['cancelled', 'completed'], true)) { + return; + } + DB::table('export_jobs')->where('id', $job->id)->update(['status' => 'processing', 'progress' => 10, 'attempts' => DB::raw('attempts + 1'), 'started_at' => now(), 'error' => null, 'updated_at' => now()]); + try { + $version = CourseVersion::query()->where('organization_id', $job->organization_id)->findOrFail($job->course_version_id); + $artifact = $renderer->render($version, $job->format, $job->id, json_decode($job->filters ?: '{}', true)); + $updated = DB::table('export_jobs')->where('id', $job->id)->where('status', 'processing')->update(['status' => 'completed', 'progress' => 100, 'disk' => $artifact['disk'], 'path' => $artifact['path'], 'size' => $artifact['size'], 'metadata' => json_encode([...$artifact['metadata'], 'mime' => $artifact['mime'], 'filename' => $artifact['filename']]), 'completed_at' => now(), 'updated_at' => now()]); + if ($updated === 0) { + Storage::disk($artifact['disk'])->delete($artifact['path']); + } + } catch (Throwable $error) { + DB::table('export_jobs')->where('id', $job->id)->update(['status' => 'failed', 'progress' => 100, 'error' => mb_substr($error->getMessage(), 0, 2000), 'updated_at' => now()]); + throw $error; + } + } +} diff --git a/backend/app/Modules/Export/Application/RenderCourseExport.php b/backend/app/Modules/Export/Application/RenderCourseExport.php new file mode 100644 index 0000000..f08be33 --- /dev/null +++ b/backend/app/Modules/Export/Application/RenderCourseExport.php @@ -0,0 +1,465 @@ + */ + private array $assets = []; + + public function supports(string $format): bool + { + if ($format !== 'mp4') { + return true; + } + + return $this->ffmpegBinary() !== null; + } + + /** @return array{disk: string, path: string, size: int, mime: string, filename: string, metadata: array} */ + public function render(CourseVersion $version, string $format, string $jobId, array $settings = []): array + { + $version->loadMissing('course', 'modules.lessons.blocks'); + $this->loadAssets($version); + $disk = (string) config('exports.disk', 'local'); + $base = 'exports/'.$version->organization_id.'/'.$jobId; + [$contents, $extension, $mime, $metadata] = match ($format) { + 'pdf_workbook' => [$this->pdf($version, $settings), 'pdf', 'application/pdf', ['adapter' => 'DompdfWorkbookRenderer']], + 'standalone_html' => [$this->zip($version, 'standalone_html', $settings), 'zip', 'application/zip', ['adapter' => 'StandaloneHtmlRenderer']], + 'scorm_12' => [$this->zip($version, 'scorm_12', $settings), 'zip', 'application/zip', ['adapter' => 'Scorm12Renderer']], + 'scorm_2004' => [$this->zip($version, 'scorm_2004', $settings), 'zip', 'application/zip', ['adapter' => 'Scorm2004Renderer']], + 'xapi' => [$this->zip($version, 'xapi', $settings), 'zip', 'application/zip', ['adapter' => 'XapiRenderer']], + 'cmi5' => [$this->zip($version, 'cmi5', $settings), 'zip', 'application/zip', ['adapter' => 'Cmi5Renderer']], + 'pptx' => [$this->pptx($version), 'pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', ['adapter' => 'OpenXmlPresentationRenderer']], + 'docx' => [$this->docx($version), 'docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', ['adapter' => 'OpenXmlDocumentRenderer']], + 'mp4' => [$this->mp4($version), 'mp4', 'video/mp4', ['adapter' => 'FfmpegCourseVideoRenderer']], + default => throw new RuntimeException('Unsupported export adapter.'), + }; + $path = $base.'.'.$extension; + if (! Storage::disk($disk)->put($path, $contents)) { + throw new RuntimeException('Export artifact could not be written to configured storage.'); + } + + return ['disk' => $disk, 'path' => $path, 'size' => strlen($contents), 'mime' => $mime, 'filename' => $this->slug($version->title).'-نسخه-'.$version->version_number.'.'.$extension, 'metadata' => [...$metadata, 'settings' => $settings]]; + } + + private function pdf(CourseVersion $version, array $settings): string + { + $dompdf = new Dompdf(['isRemoteEnabled' => false, 'isHtml5ParserEnabled' => true]); + $dompdf->loadHtml($this->html($version, true, $settings), 'UTF-8'); + $dompdf->setPaper('A4'); + $dompdf->render(); + + return $dompdf->output(); + } + + private function pptx(CourseVersion $version): string + { + $temporary = tempnam(sys_get_temp_dir(), 'microlearn-pptx-'); + if (! $temporary) { + throw new RuntimeException('Temporary presentation storage is unavailable.'); + } + $zip = new ZipArchive; + if ($zip->open($temporary, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Presentation archive could not be created.'); + } + $slides = [['title' => $version->title, 'body' => (string) ($version->description ?? '')]]; + foreach ($version->modules as $module) { + foreach ($module->lessons as $lesson) { + $lines = collect($lesson->blocks)->map(function ($block) { + $data = $block->data ?? []; + $text = $data['text'] ?? $data['body'] ?? $data['title'] ?? $data['prompt'] ?? 'محتوای تعاملی'; + + return '• '.mb_substr(strip_tags(is_scalar($text) ? (string) $text : 'محتوای تعاملی'), 0, 240); + })->implode("\n"); + $slides[] = ['title' => $module->title.' — '.$lesson->title, 'body' => $lines]; + } + } + $overrides = ''; + foreach ($slides as $index => $_) { + $overrides .= ''; + } + $zip->addFromString('[Content_Types].xml', ''.$overrides.''); + $zip->addFromString('_rels/.rels', ''); + $ids = ''; + $rels = ''; + foreach ($slides as $index => $slide) { + $number = $index + 1; + $relationship = $number + 1; + $ids .= ''; + $rels .= ''; + $zip->addFromString('ppt/slides/slide'.$number.'.xml', $this->pptxSlide($slide['title'], $slide['body'])); + $zip->addFromString('ppt/slides/_rels/slide'.$number.'.xml.rels', ''); + } + $zip->addFromString('ppt/presentation.xml', ''.$ids.''); + $zip->addFromString('ppt/_rels/presentation.xml.rels', ''.$rels.''); + $zip->addFromString('ppt/slideMasters/slideMaster1.xml', ''); + $zip->addFromString('ppt/slideMasters/_rels/slideMaster1.xml.rels', ''); + $zip->addFromString('ppt/slideLayouts/slideLayout1.xml', ''); + $zip->addFromString('ppt/slideLayouts/_rels/slideLayout1.xml.rels', ''); + $zip->addFromString('ppt/theme/theme1.xml', ''); + $zip->close(); + $contents = file_get_contents($temporary) ?: ''; + @unlink($temporary); + + return $contents; + } + + private function docx(CourseVersion $version): string + { + $temporary = tempnam(sys_get_temp_dir(), 'microlearn-docx-'); + if (! $temporary) { + throw new RuntimeException('Temporary document storage is unavailable.'); + } + $zip = new ZipArchive; + if ($zip->open($temporary, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Word document archive could not be created.'); + } + $body = $this->docxParagraph($version->title, 32, true); + if ($version->description) { + $body .= $this->docxParagraph((string) $version->description, 22); + } + foreach ($version->modules as $module) { + $body .= $this->docxParagraph($module->title, 28, true, true); + foreach ($module->lessons as $lesson) { + $body .= $this->docxParagraph($lesson->title, 24, true); + foreach ($lesson->blocks as $block) { + $data = $block->data ?? []; + $text = $data['html'] ?? $data['text'] ?? $data['body'] ?? $data['title'] ?? $data['prompt'] ?? 'محتوای تعاملی'; + $plain = trim(preg_replace('/\s+/u', ' ', strip_tags(is_scalar($text) ? (string) $text : 'محتوای تعاملی')) ?? ''); + if ($plain !== '') { + $body .= $this->docxParagraph($plain, 21); + } + } + } + } + $contentTypes = ''; + $zip->addFromString('[Content_Types].xml', $contentTypes); + $zip->addFromString('_rels/.rels', ''); + $zip->addFromString('word/_rels/document.xml.rels', ''); + $zip->addFromString('word/styles.xml', ''); + $zip->addFromString('word/document.xml', ''.$body.''); + $zip->close(); + $contents = file_get_contents($temporary) ?: ''; + @unlink($temporary); + + return $contents; + } + + private function docxParagraph(string $text, int $size, bool $bold = false, bool $pageBreak = false): string + { + $safe = htmlspecialchars($text, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + $break = $pageBreak ? '' : ''; + $weight = $bold ? '' : ''; + + return ''.$break.''.$weight.''.$safe.''; + } + + private function mp4(CourseVersion $version): string + { + $binary = $this->ffmpegBinary(); + if ($binary === null) { + throw new RuntimeException('FFmpeg is not available on the export worker.'); + } + $directory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'microlearn-mp4-'.bin2hex(random_bytes(6)); + if (! mkdir($directory, 0700, true) && ! is_dir($directory)) { + throw new RuntimeException('Temporary video storage is unavailable.'); + } + $titlePath = $directory.DIRECTORY_SEPARATOR.'title.txt'; + $bodyPath = $directory.DIRECTORY_SEPARATOR.'content.txt'; + $outputPath = $directory.DIRECTORY_SEPARATOR.'course.mp4'; + $lines = []; + foreach ($version->modules as $module) { + $lines[] = $module->title; + foreach ($module->lessons as $lesson) { + $lines[] = '• '.$lesson->title; + if (count($lines) >= 18) { + break 2; + } + } + } + file_put_contents($titlePath, $version->title); + file_put_contents($bodyPath, implode("\n", $lines) ?: (string) ($version->description ?? $version->title)); + $font = $this->ffmpegFont(); + $duration = max(10, min(90, 6 + count($lines) * 3)); + $filter = "drawtext=fontfile='".$this->filterPath($font)."':textfile='".$this->filterPath($titlePath)."':fontcolor=0x123235:fontsize=48:x=(w-text_w)/2:y=72:text_shaping=1,". + "drawtext=fontfile='".$this->filterPath($font)."':textfile='".$this->filterPath($bodyPath)."':fontcolor=0x344054:fontsize=30:line_spacing=18:x=(w-text_w)/2:y=h-80-t*32:text_shaping=1"; + $process = new Process([$binary, '-hide_banner', '-loglevel', 'error', '-f', 'lavfi', '-i', "color=c=0xf8fafc:s=1280x720:r=25:d={$duration}", '-vf', $filter, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-metadata', 'title='.$version->title, '-y', $outputPath]); + $process->setTimeout((float) config('exports.ffmpeg_timeout', 600)); + $process->run(); + if (! $process->isSuccessful() || ! is_file($outputPath)) { + $this->removeDirectory($directory); + throw new RuntimeException('FFmpeg could not render the course video. Check the worker codec and font configuration.'); + } + $contents = file_get_contents($outputPath) ?: ''; + $this->removeDirectory($directory); + + return $contents; + } + + private function ffmpegBinary(): ?string + { + $configured = (string) config('exports.ffmpeg_binary', 'ffmpeg'); + + return is_file($configured) ? $configured : (new ExecutableFinder)->find($configured); + } + + private function ffmpegFont(): string + { + $configured = (string) config('exports.ffmpeg_font', ''); + foreach (array_filter([$configured, '/usr/share/fonts/ttf-dejavu/DejaVuSans.ttf', 'C:\\Windows\\Fonts\\tahoma.ttf', 'C:\\Windows\\Fonts\\arial.ttf']) as $candidate) { + if (is_file($candidate)) { + return $candidate; + } + } + + throw new RuntimeException('A Unicode font is required for MP4 rendering.'); + } + + private function filterPath(string $path): string + { + return str_replace(['\\', ':', "'"], ['/', '\\:', "\\'"], $path); + } + + private function removeDirectory(string $directory): void + { + foreach (glob($directory.DIRECTORY_SEPARATOR.'*') ?: [] as $file) { + @unlink($file); + } + @rmdir($directory); + } + + private function pptxSlide(string $title, string $body): string + { + $title = htmlspecialchars($title, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + $body = htmlspecialchars($body, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + $shape = fn (int $id, string $name, string $text, int $x, int $y, int $cx, int $cy, int $size) => ''.$text.''; + + return ''.$shape(2, 'Title', $title, 700000, 500000, 10800000, 1000000, 3000).$shape(3, 'Content', str_replace(["\r", "\n"], ['', ' · '], $body), 700000, 1700000, 10800000, 4300000, 1800).''; + } + + private function zip(CourseVersion $version, string $format, array $settings): string + { + $temporary = tempnam(sys_get_temp_dir(), 'microlearn-export-'); + if (! $temporary) { + throw new RuntimeException('Temporary export storage is unavailable.'); + } + $zip = new ZipArchive; + if ($zip->open($temporary, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Export archive could not be created.'); + } + $zip->addFromString('index.html', $this->html($version, false, $settings, $format)); + $zip->addFromString('course.json', json_encode($this->payload($version), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + if (str_starts_with($format, 'scorm_')) { + $schema = $format === 'scorm_12' ? 'ADL SCORM 1.2' : '2004 4th Edition'; + $namespace = $format === 'scorm_12' + ? 'xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2" xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2"' + : 'xmlns="http://www.imsglobal.org/xsd/imscp_v1p1" xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_v1p3"'; + $scormType = $format === 'scorm_12' ? 'scormtype' : 'scormType'; + $zip->addFromString('imsmanifest.xml', 'ADL SCORM'.$schema.''.htmlspecialchars($version->title, ENT_XML1).''.htmlspecialchars($version->title, ENT_XML1).''); + } + if ($format === 'cmi5') { + $zip->addFromString('cmi5.xml', '<langstring lang="fa">'.htmlspecialchars($version->title, ENT_XML1).'</langstring>index.html'); + } + if ($format === 'xapi') { + $zip->addFromString('tincan.xml', ''.htmlspecialchars($version->title, ENT_XML1).'index.html'); + } + $zip->close(); + $contents = file_get_contents($temporary) ?: ''; + @unlink($temporary); + + return $contents; + } + + private function html(CourseVersion $version, bool $workbook = false, array $settings = [], ?string $format = null): string + { + $title = htmlspecialchars($version->title, ENT_QUOTES, 'UTF-8'); + $language = ($settings['language'] ?? data_get($version->settings, 'language', 'fa')) === 'en' ? 'en' : 'fa'; + $direction = $language === 'fa' ? 'rtl' : 'ltr'; + $brand = $this->brand($version->organization_id); + $brandName = htmlspecialchars($brand['name'], ENT_QUOTES, 'UTF-8'); + $primary = $brand['primary']; + $body = ''; + foreach ($version->modules as $module) { + $body .= '

'.htmlspecialchars($module->title).'

'; + if ($module->lessons->isEmpty()) { + $body .= '

این بخش هنوز محتوایی برای نمایش ندارد.

'; + } + foreach ($module->lessons as $lesson) { + $body .= '

'.htmlspecialchars($lesson->title).'

'; + if ($lesson->blocks->isEmpty()) { + $body .= '

این درس هنوز محتوایی برای نمایش ندارد.

'; + } + foreach ($lesson->blocks as $block) { + $body .= $this->blockHtml($block->type, $block->data ?? []); + } + $body .= '
'; + } + $body .= '
'; + } + $mode = $workbook ? 'Workbook' : 'Standalone course'; + $footer = $workbook ? '
'.$brandName.'صفحه
' : ''; + + $cover = ($settings['includeCover'] ?? true) ? '
'.$brandName.' · '.$mode.'

'.$title.'

'.htmlspecialchars((string) ($version->description ?? '')).'

' : ''; + $toc = ($settings['includeToc'] ?? true) ? '' : ''; + $runtime = $format && str_starts_with($format, 'scorm_') && ($settings['trackProgress'] ?? true) ? $this->scormRuntime($format) : ''; + + return ''.$title.''.$footer.$cover.$toc.$body.$runtime.''; + } + + /** @param array $data */ + private function blockHtml(string $type, array $data): string + { + $e = fn (mixed $value): string => htmlspecialchars(is_scalar($value) ? (string) $value : '', ENT_QUOTES, 'UTF-8'); + $paragraph = fn (mixed $value): string => ($text = trim(strip_tags(is_scalar($value) ? (string) $value : ''))) !== '' ? '

'.$e($text).'

' : ''; + $items = function (mixed $values, string $key = 'text') use ($e): string { + if (! is_array($values)) { + return ''; + } + $rows = collect($values)->map(function ($item) use ($e, $key) { + $value = is_array($item) ? ($item[$key] ?? $item['title'] ?? $item['body'] ?? '') : $item; + + return '
  • '.$e($value).'
  • '; + })->filter(fn (string $row) => $row !== '
  • ')->implode(''); + + return $rows !== '' ? '
      '.$rows.'
    ' : ''; + }; + + if ($type === 'heading') { + return '

    '.$e($data['text'] ?? '').'

    '; + } + if ($type === 'text') { + $html = strip_tags((string) ($data['html'] ?? ''), '


      1. '); + + return $html !== '' ? '
        '.$html.'
        ' : ''; + } + if ($type === 'divider') { + return '
        '.(($data['label'] ?? '') !== '' ? '

        '.$e($data['label']).'

        ' : ''); + } + if ($type === 'quote') { + return '
        '.$paragraph($data['text'] ?? '').(($data['cite'] ?? '') !== '' ? ''.$e($data['cite']).'' : '').'
        '; + } + if ($type === 'key_point') { + return ''; + } + if ($type === 'image') { + $image = $this->assetDataUri($data['assetId'] ?? null); + $caption = $data['caption'] ?? $data['alt'] ?? ''; + + return '
        '.($image ? ''.$e($data['alt'] ?? '').'' : 'تصویر').(($caption ?? '') !== '' ? '
        '.$e($caption).'
        ' : '').'
        '; + } + if ($type === 'gallery') { + $figures = collect($data['items'] ?? [])->map(function ($item) use ($e) { + $image = $this->assetDataUri(is_array($item) ? ($item['assetId'] ?? null) : null); + if (! $image) { + return ''; + } + + return '
        '.$e($item['alt'] ?? '').'
        '.$e($item['caption'] ?? '').'
        '; + })->implode(''); + + return $figures !== '' ? '
        '.$figures.'
        ' : ''; + } + if (in_array($type, ['video', 'audio'], true)) { + $label = $type === 'video' ? 'ویدئو' : 'فایل صوتی'; + + return '
        '.$label.'

        '.$e($data['title'] ?? '').'

        '.$paragraph($data['transcript'] ?? '').'
        '; + } + if (in_array($type, ['document', 'embed', 'button'], true)) { + $title = $data['title'] ?? $data['label'] ?? 'منبع تکمیلی'; + + return '

        '.$e($title).'

        '.$paragraph($data['description'] ?? '').(($data['url'] ?? '') !== '' ? '

        '.$e($data['url']).'

        ' : '').'
        '; + } + if (in_array($type, ['accordion', 'tabs', 'timeline', 'steps', 'process', 'controlled_grid'], true)) { + $content = collect($data['items'] ?? [])->map(fn ($item) => '
      2. '.$e(is_array($item) ? ($item['title'] ?? $item['date'] ?? '') : $item).''.$paragraph(is_array($item) ? ($item['body'] ?? '') : '').'
      3. ')->implode(''); + + return $content !== '' ? '
          '.$content.'
        ' : ''; + } + if ($type === 'flashcard') { + return '
        فلش‌کارت

        '.$e($data['front'] ?? '').'

        '.$paragraph($data['back'] ?? '').'
        '; + } + if ($type === 'checklist') { + return '
        چک‌لیست'.$items($data['items'] ?? []).'
        '; + } + if ($type === 'columns') { + return '
        '.collect($data['columns'] ?? [])->map(fn ($column) => '

        '.$e($column['title'] ?? '').'

        '.$paragraph($column['content'] ?? ''))->implode('').'
        '; + } + if ($type === 'section') { + return '

        '.$e($data['title'] ?? '').'

        '.$paragraph($data['description'] ?? '').'
        '; + } + if (in_array($type, ['single_choice', 'multiple_choice', 'true_false', 'matching', 'sorting', 'drag_drop', 'hotspot', 'scenario', 'branching_scenario', 'interactive_image', 'before_after'], true)) { + $options = $data['options'] ?? $data['items'] ?? $data['choices'] ?? $data['pairs'] ?? $data['markers'] ?? $data['nodes'] ?? []; + + return '
        تمرین و ارزیابی

        '.$e($data['prompt'] ?? $data['context'] ?? $data['alt'] ?? '').'

        '.$items($options).'
        '; + } + + return '
        '.$paragraph($data['title'] ?? $data['text'] ?? $data['body'] ?? $data['prompt'] ?? '').'
        '; + } + + private function loadAssets(CourseVersion $version): void + { + $ids = collect($version->modules)->flatMap(fn ($module) => collect($module->lessons)->flatMap(fn ($lesson) => collect($lesson->blocks)->flatMap(function ($block) { + $found = []; + $data = $block->data ?? []; + array_walk_recursive($data, function ($value, $key) use (&$found): void { + if (is_string($value) && ($key === 'assetId' || str_ends_with((string) $key, 'AssetId'))) { + $found[] = $value; + } + }); + + return $found; + })))->unique()->values(); + $this->assets = Asset::query()->where('organization_id', $version->organization_id)->whereIn('id', $ids)->get()->keyBy(fn (Asset $asset) => (string) $asset->getKey())->all(); + } + + private function assetDataUri(mixed $assetId): ?string + { + if (! is_string($assetId) || ! isset($this->assets[$assetId])) { + return null; + } + $asset = $this->assets[$assetId]; + if (! str_starts_with((string) $asset->mime_type, 'image/') || $asset->size > 10 * 1024 * 1024 || ! Storage::disk($asset->disk)->exists($asset->path)) { + return null; + } + + return 'data:'.$asset->mime_type.';base64,'.base64_encode(Storage::disk($asset->disk)->get($asset->path)); + } + + private function scormRuntime(string $format): string + { + $is12 = $format === 'scorm_12' ? 'true' : 'false'; + + return ''; + } + + /** @return array */ + private function payload(CourseVersion $version): array + { + return ['schemaVersion' => 1, 'course' => ['id' => $version->course_id, 'title' => $version->title], 'version' => ['id' => $version->id, 'number' => $version->version_number], 'modules' => $version->modules->map(fn ($module) => ['id' => $module->id, 'title' => $module->title, 'lessons' => $module->lessons->map(fn ($lesson) => ['id' => $lesson->id, 'title' => $lesson->title, 'blocks' => $lesson->blocks->map(fn ($block) => ['id' => $block->id, 'type' => $block->type, 'data' => $block->data])])])]; + } + + private function slug(string $value): string + { + return trim((string) preg_replace('/[^\pL\pN_-]+/u', '-', $value), '-') ?: 'course-export'; + } + + /** @return array{name: string, primary: string} */ + private function brand(string $organizationId): array + { + $profile = DB::table('organization_profiles')->where('organization_id', $organizationId)->first(); + $name = $profile?->display_name ?: DB::table('organizations')->where('id', $organizationId)->value('name') ?: 'MicroLearn'; + $primary = (string) ($profile?->primary_color ?: '#5338d4'); + + return ['name' => (string) $name, 'primary' => preg_match('/^#[0-9a-f]{6}$/i', $primary) ? $primary : '#5338d4']; + } +} diff --git a/backend/app/Modules/Export/Application/TabularXlsx.php b/backend/app/Modules/Export/Application/TabularXlsx.php new file mode 100644 index 0000000..1f6860c --- /dev/null +++ b/backend/app/Modules/Export/Application/TabularXlsx.php @@ -0,0 +1,60 @@ + $headers @param iterable> $rows */ + public function create(array $headers, iterable $rows, string $sheetName): string + { + $path = tempnam(sys_get_temp_dir(), 'microlearn-xlsx-'); + if ($path === false) { + throw new RuntimeException('Unable to create Excel export.'); + } + $zip = new ZipArchive; + if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Unable to create Excel archive.'); + } + $allRows = [$headers, ...iterator_to_array((function () use ($rows) { + foreach ($rows as $row) { + yield array_values($row); + } + })())]; + $xmlRows = []; + foreach ($allRows as $rowIndex => $row) { + $cells = []; + foreach ($row as $columnIndex => $value) { + $reference = $this->column($columnIndex + 1).($rowIndex + 1); + $escaped = htmlspecialchars((string) ($value ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8'); + $cells[] = ''.$escaped.''; + } + $xmlRows[] = ''.implode('', $cells).''; + } + $sheet = ''.implode('', $xmlRows).''; + $safeName = htmlspecialchars(mb_substr($sheetName, 0, 31), ENT_XML1 | ENT_QUOTES, 'UTF-8'); + $zip->addFromString('[Content_Types].xml', ''); + $zip->addFromString('_rels/.rels', ''); + $zip->addFromString('xl/workbook.xml', ''); + $zip->addFromString('xl/_rels/workbook.xml.rels', ''); + $zip->addFromString('xl/styles.xml', ''); + $zip->addFromString('xl/worksheets/sheet1.xml', $sheet); + $zip->close(); + + return $path; + } + + private function column(int $number): string + { + $column = ''; + while ($number > 0) { + $number--; + $column = chr(65 + ($number % 26)).$column; + $number = intdiv($number, 26); + } + + return $column; + } +} diff --git a/backend/app/Modules/Export/Http/ExportController.php b/backend/app/Modules/Export/Http/ExportController.php new file mode 100644 index 0000000..74e163a --- /dev/null +++ b/backend/app/Modules/Export/Http/ExportController.php @@ -0,0 +1,190 @@ +authorize($request); + $courseId = $request->string('courseId')->trim()->value(); + $itemsQuery = DB::table('export_jobs as e') + ->leftJoin('course_versions as cv', 'cv.id', '=', 'e.course_version_id') + ->leftJoin('users as u', 'u.id', '=', 'e.requested_by') + ->where('e.organization_id', $this->tenant->id()); + $coursesQuery = CourseVersion::query() + ->where('organization_id', $this->tenant->id()) + ->where('status', 'published'); + if ($courseId !== '') { + $itemsQuery->where('cv.course_id', $courseId); + $coursesQuery->where('course_id', $courseId); + } + $items = $itemsQuery->orderByDesc('e.created_at')->limit(100) + ->get(['e.*', 'cv.title as course_title', 'cv.version_number', 'cv.published_at', 'u.name as requested_by_name']) + ->map(fn ($row) => $this->payload($row)); + $courses = $coursesQuery->orderByDesc('version_number')->limit(100) + ->get(['id', 'course_id', 'title', 'version_number', 'published_at']) + ->map(fn ($version) => ['id' => $version->id, 'courseId' => $version->course_id, 'title' => $version->title, 'version' => $version->version_number, 'status' => 'published', 'publishedAt' => $version->published_at]); + + $formats = array_values(array_filter(self::FORMATS, fn (string $format) => $this->renderer->supports($format))); + $unavailable = []; + if (! $this->renderer->supports('mp4')) { + $unavailable[] = ['id' => 'mp4', 'reason' => 'FFmpeg روی Worker خروجی نصب یا قابل اجرا نیست.']; + } + + return response()->json(['data' => [ + 'items' => $items, + 'courses' => $courses, + 'formats' => $formats, + 'unavailableFormats' => $unavailable, + 'retentionDays' => config('exports.retention_days'), + ]]); + } + + public function data(Request $request, string $type, TabularXlsx $xlsx): BinaryFileResponse + { + $this->authorize($request); + abort_unless(in_array($type, ['users', 'courses', 'assignments'], true), 404); + [$headers, $rows, $sheet] = match ($type) { + 'users' => [['نام', 'آدرس ایمیل', 'دپارتمان', 'سمت', 'نقش', 'وضعیت'], DB::table('users')->where('organization_id', $this->tenant->id())->orderBy('name')->get()->map(fn ($row) => [$row->name, $row->email, $row->department, $row->job_level, $row->role, $row->status]), 'کاربران'], + 'courses' => [['عنوان دوره', 'وضعیت', 'آخرین نسخه', 'مالک', 'آخرین تغییر'], DB::table('courses as c')->leftJoin('users as u', 'u.id', '=', 'c.created_by')->leftJoinSub(DB::table('course_versions')->select('course_id', DB::raw('max(version_number) as version_number'))->groupBy('course_id'), 'v', 'v.course_id', '=', 'c.id')->where('c.organization_id', $this->tenant->id())->orderBy('c.title')->get(['c.title', 'c.status', 'v.version_number', 'u.name as owner', 'c.updated_at'])->map(fn ($row) => [$row->title, $row->status, $row->version_number, $row->owner, $row->updated_at]), 'دوره‌ها'], + 'assignments' => [['محتوا', 'نوع محتوا', 'نوع مخاطب', 'وضعیت', 'الزامی', 'شروع', 'مهلت', 'تعداد مخاطب'], DB::table('assignments as a') + ->leftJoin('course_versions as cv', function ($join): void { + $join->on('cv.id', '=', 'a.assignable_id')->where('a.assignable_type', 'course'); + }) + ->leftJoin('learning_path_versions as lpv', function ($join): void { + $join->on('lpv.id', '=', 'a.assignable_id')->where('a.assignable_type', 'learning_path'); + }) + ->leftJoinSub(DB::table('assignment_users')->select('assignment_id', DB::raw('count(*) as recipients'))->groupBy('assignment_id'), 'au', 'au.assignment_id', '=', 'a.id') + ->where('a.organization_id', $this->tenant->id())->orderByDesc('a.created_at') + ->get([DB::raw('coalesce(cv.title, lpv.title, a.assignable_id) as content'), 'a.assignable_type', 'a.target_type', 'a.status', 'a.mandatory', 'a.starts_at', 'a.due_at', 'au.recipients']) + ->map(fn ($row) => [$row->content, $row->assignable_type, $row->target_type, $row->status, $row->mandatory ? 'بله' : 'خیر', $row->starts_at, $row->due_at, $row->recipients ?? 0]), 'تخصیص‌ها'], + }; + $path = $xlsx->create($headers, $rows, $sheet); + + return response()->download($path, $type.'-'.now()->format('Y-m-d').'.xlsx', ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'])->deleteFileAfterSend(true); + } + + public function compatibility(Request $request): JsonResponse + { + $this->authorize($request); + $data = $request->validate(['courseVersionId' => ['required', 'string'], 'format' => ['required', Rule::in(self::FORMATS)]]); + if (! $this->renderer->supports($data['format'])) { + return response()->json(['error' => ['code' => 'export_adapter_unavailable', 'message' => 'زیرساخت لازم برای ساخت این فرمت روی Worker در دسترس نیست.']], 422); + } + $version = CourseVersion::query()->where('organization_id', $this->tenant->id())->findOrFail($data['courseVersionId']); + + return response()->json(['data' => $this->checker->check($version, $data['format'])]); + } + + public function store(Request $request): JsonResponse + { + $this->authorize($request); + $data = $request->validate([ + 'courseVersionId' => ['required', 'string'], + 'format' => ['required', Rule::in(self::FORMATS)], + 'confirmWarnings' => ['nullable', 'boolean'], + 'settings' => ['nullable', 'array'], + 'settings.language' => ['nullable', Rule::in(['fa', 'en'])], + 'settings.trackProgress' => ['nullable', 'boolean'], + 'settings.trackScore' => ['nullable', 'boolean'], + 'settings.freeNavigation' => ['nullable', 'boolean'], + 'settings.includeCover' => ['nullable', 'boolean'], + 'settings.includeToc' => ['nullable', 'boolean'], + ]); + $version = CourseVersion::query()->where('organization_id', $this->tenant->id())->where('status', 'published')->findOrFail($data['courseVersionId']); + if (! $this->renderer->supports($data['format'])) { + return response()->json(['error' => ['code' => 'export_adapter_unavailable', 'message' => 'زیرساخت لازم برای ساخت این فرمت روی Worker در دسترس نیست.']], 422); + } + $settings = array_merge([ + 'language' => data_get($version->settings, 'language', 'fa'), + 'trackProgress' => str_starts_with($data['format'], 'scorm_'), + 'trackScore' => false, + 'freeNavigation' => true, + 'includeCover' => true, + 'includeToc' => true, + ], $data['settings'] ?? []); + if (($settings['trackScore'] ?? false) === true) { + return response()->json(['error' => ['code' => 'unsupported_score_tracking', 'message' => 'آداپتر فعلی SCORM محتوای ارزیابی تعاملی تولید نمی‌کند و ثبت نمره قابل فعال‌سازی نیست.']], 422); + } + if (str_starts_with($data['format'], 'scorm_') && ($settings['freeNavigation'] ?? true) === false) { + return response()->json(['error' => ['code' => 'unsupported_locked_navigation', 'message' => 'آداپتر فعلی SCORM فقط مسیر پیوسته با مرور آزاد تولید می‌کند.']], 422); + } + $check = $this->checker->check($version, $data['format']); + if ($check['warnings'] !== [] && ! ($data['confirmWarnings'] ?? false)) { + return response()->json(['error' => ['code' => 'compatibility_confirmation_required', 'message' => 'Export fallbacks must be reviewed before generation.'], 'data' => $check], 422); + } + $duplicate = DB::table('export_jobs')->where('organization_id', $this->tenant->id())->where('course_version_id', $version->id)->where('format', $data['format'])->whereIn('status', ['queued', 'processing'])->exists(); + if ($duplicate) { + return response()->json(['error' => ['code' => 'duplicate_export_job', 'message' => 'ساخت همین خروجی از قبل در صف یا در حال پردازش است.']], 409); + } + $id = (string) str()->ulid(); + DB::table('export_jobs')->insert(['id' => $id, 'organization_id' => $this->tenant->id(), 'requested_by' => $request->user()->getKey(), 'course_version_id' => $version->id, 'type' => 'course_version', 'format' => $data['format'], 'status' => 'queued', 'progress' => 0, 'filters' => json_encode($settings), 'warnings' => json_encode($check['warnings']), 'metadata' => json_encode(['schemaVersion' => 1]), 'attempts' => 0, 'created_at' => now(), 'updated_at' => now()]); + ProcessExportJob::dispatch($id); + + return response()->json(['data' => ['id' => $id, 'status' => 'queued', 'warnings' => $check['warnings']]], 202); + } + + public function retry(Request $request, string $export): JsonResponse + { + $this->authorize($request); + $updated = DB::table('export_jobs')->where('organization_id', $this->tenant->id())->where('id', $export)->where('status', 'failed')->update(['status' => 'queued', 'progress' => 0, 'error' => null, 'updated_at' => now()]); + abort_unless($updated === 1, 404); + ProcessExportJob::dispatch($export); + + return response()->json(['data' => ['queued' => true]]); + } + + public function cancel(Request $request, string $export): JsonResponse + { + $this->authorize($request); + $updated = DB::table('export_jobs')->where('organization_id', $this->tenant->id())->where('id', $export)->whereIn('status', ['queued', 'processing'])->update(['status' => 'cancelled', 'updated_at' => now()]); + abort_unless($updated === 1, 404); + + return response()->json(['data' => ['cancelled' => true]]); + } + + public function download(Request $request, string $export) + { + $this->authorize($request); + $row = DB::table('export_jobs')->where('organization_id', $this->tenant->id())->where('id', $export)->where('status', 'completed')->where('created_at', '>=', now()->subDays((int) config('exports.retention_days')))->first(); + abort_unless($row && $row->path && $row->disk && Storage::disk($row->disk)->exists($row->path), 404); + $metadata = json_decode($row->metadata ?: '{}', true); + + return Storage::disk($row->disk)->download($row->path, $metadata['filename'] ?? ('export-'.$row->id)); + } + + /** @return array */ + private function payload(object $row): array + { + $expired = $row->status === 'completed' && now()->subDays((int) config('exports.retention_days'))->greaterThan($row->created_at); + + return ['id' => $row->id, 'courseVersionId' => $row->course_version_id, 'courseTitle' => $row->course_title, 'version' => (int) ($row->version_number ?? 0), 'publishedAt' => $row->published_at ?? null, 'requestedBy' => $row->requested_by_name ?? null, 'format' => $row->format, 'status' => $expired ? 'expired' : $row->status, 'progress' => (int) $row->progress, 'warnings' => json_decode($row->warnings ?: '[]', true), 'settings' => json_decode($row->filters ?: '{}', true), 'size' => $row->size, 'error' => $row->error, 'attempts' => (int) $row->attempts, 'createdAt' => $row->created_at, 'completedAt' => $row->completed_at]; + } + + private function authorize(Request $request): void + { + abort_unless($request->user() && $this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } +} diff --git a/backend/app/Modules/Identity/Application/AuthenticatedUserPayload.php b/backend/app/Modules/Identity/Application/AuthenticatedUserPayload.php new file mode 100644 index 0000000..e11f8ce --- /dev/null +++ b/backend/app/Modules/Identity/Application/AuthenticatedUserPayload.php @@ -0,0 +1,43 @@ + */ + public function make(User $user): array + { + $user->loadMissing('organization'); + + return [ + 'id' => $user->getKey(), + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->role->value, + 'status' => $user->status->value, + 'locale' => $user->locale, + 'timezone' => $user->timezone, + 'permissions' => array_map(fn ($permission) => $permission->value, $this->permissions->for($user->role)), + 'organization' => $user->organization ? [ + 'id' => $user->organization->getKey(), + 'name' => $user->organization->name, + 'slug' => $user->organization->slug, + 'defaultLocale' => $user->organization->default_locale, + 'timezone' => $user->organization->timezone, + ] : null, + 'deployment' => [ + 'mode' => $this->deployment->mode()->value, + 'supportsMultipleOrganizations' => $this->deployment->supportsMultipleOrganizations(), + 'exposesSubscriptionManagement' => $this->deployment->exposesSubscriptionManagement(), + ], + ]; + } +} diff --git a/backend/app/Modules/Identity/Application/InvitationService.php b/backend/app/Modules/Identity/Application/InvitationService.php new file mode 100644 index 0000000..7a2dbe6 --- /dev/null +++ b/backend/app/Modules/Identity/Application/InvitationService.php @@ -0,0 +1,100 @@ +where('email', $email)->first(); + + if ($existing) { + throw ValidationException::withMessages(['email' => ['A user with this email already exists.']]); + } + + $previous = UserInvitation::query() + ->where('organization_id', $organization->getKey()) + ->where('email', $email) + ->first(); + + if (! $previous || ! $previous->isUsable()) { + $this->seatQuota->assertAvailable($organization->getKey()); + } + + $token = Str::random(64); + $invitation = UserInvitation::query()->updateOrCreate( + ['organization_id' => $organization->getKey(), 'email' => $email], + [ + 'role' => $role, + 'profile' => $profile, + 'token_hash' => hash('sha256', $token), + 'expires_at' => now()->addHours(72), + 'invited_by' => $actor->getKey(), + 'accepted_by' => null, + 'accepted_at' => null, + 'revoked_at' => null, + ], + ); + + Notification::route('mail', $email)->notify(new UserInvited($organization->name, $token)); + + return $invitation; + } + + public function accept(string $token, string $name, string $password): User + { + return DB::transaction(function () use ($token, $name, $password) { + $invitation = UserInvitation::query() + ->where('token_hash', hash('sha256', $token)) + ->lockForUpdate() + ->first(); + + if (! $invitation || ! $invitation->isUsable()) { + throw ValidationException::withMessages(['token' => ['The invitation is invalid or expired.']]); + } + + $profile = $invitation->profile ?? []; + + $user = User::query()->create([ + 'organization_id' => $invitation->organization_id, + 'name' => trim(implode(' ', array_filter([$profile['firstName'] ?? null, $profile['lastName'] ?? null]))) ?: $name, + 'first_name' => $profile['firstName'] ?? null, + 'last_name' => $profile['lastName'] ?? null, + 'department' => $profile['department'] ?? null, + 'job_level' => $profile['jobLevel'] ?? null, + 'direct_manager_id' => $profile['directManagerId'] ?? null, + 'email' => $invitation->email, + 'email_verified_at' => now(), + 'password' => Hash::make($password), + 'role' => $invitation->role, + 'status' => AccountStatus::Active, + 'locale' => 'fa', + 'timezone' => 'Asia/Tehran', + ]); + + if (($profile['teamIds'] ?? []) !== []) { + $user->teams()->sync($profile['teamIds']); + } + + $invitation->update(['accepted_by' => $user->getKey(), 'accepted_at' => now()]); + + return $user; + }); + } +} diff --git a/backend/app/Modules/Identity/Application/RolePermissions.php b/backend/app/Modules/Identity/Application/RolePermissions.php new file mode 100644 index 0000000..30ec252 --- /dev/null +++ b/backend/app/Modules/Identity/Application/RolePermissions.php @@ -0,0 +1,57 @@ + */ + public function for(UserRole $role): array + { + return match ($role) { + UserRole::SuperAdmin => [ + Permission::PlatformManageOrganizations, + Permission::PlatformViewOperations, + ], + UserRole::CourseDesigner => [ + Permission::UsersView, + Permission::UsersInvite, + Permission::UsersManage, + Permission::TeamsView, + Permission::TeamsManage, + Permission::SubscriptionView, + Permission::TaxonomyView, + Permission::TaxonomyManage, + Permission::CoursesAuthor, + Permission::CoursesReview, + Permission::CoursesPublish, + Permission::AssignmentsManage, + Permission::OrganizationAnalyticsView, + ], + UserRole::Manager => [ + Permission::UsersView, + Permission::TeamsView, + Permission::TaxonomyView, + Permission::TeamAnalyticsView, + Permission::CoursesReview, + Permission::PersonalLearningView, + Permission::ManagerAssignmentsManage, + Permission::ManagerDeadlinesManage, + Permission::ManagerRemindersSend, + ], + UserRole::Learner => [ + Permission::PersonalLearningView, + ], + }; + } + + public function allows(User|UserRole $subject, Permission $permission): bool + { + $role = $subject instanceof User ? $subject->role : $subject; + + return in_array($permission, $this->for($role), true); + } +} diff --git a/backend/app/Modules/Identity/Application/UserDirectory.php b/backend/app/Modules/Identity/Application/UserDirectory.php new file mode 100644 index 0000000..a415889 --- /dev/null +++ b/backend/app/Modules/Identity/Application/UserDirectory.php @@ -0,0 +1,21 @@ +where('organization_id', $organizationId); + + return match ($actor->role) { + UserRole::CourseDesigner => $query, + UserRole::Manager => $query->whereHas('teams.managers', fn (Builder $manager) => $manager->whereKey($actor->getKey())), + default => $query->whereRaw('1 = 0'), + }; + } +} diff --git a/backend/app/Modules/Identity/Application/WorkforceImport.php b/backend/app/Modules/Identity/Application/WorkforceImport.php new file mode 100644 index 0000000..62f550a --- /dev/null +++ b/backend/app/Modules/Identity/Application/WorkforceImport.php @@ -0,0 +1,248 @@ +rows($file); + if (count($rows) > 1000) { + throw ValidationException::withMessages(['file' => ['هر فایل می‌تواند حداکثر ۱۰۰۰ کارمند داشته باشد.']]); + } + + $headers = array_map(fn (mixed $value): string => $this->normalize((string) $value), array_shift($rows) ?? []); + $columns = $this->resolveColumns($headers); + $records = []; + $errors = []; + foreach ($rows as $offset => $row) { + if (collect($row)->every(fn (mixed $value): bool => trim((string) $value) === '')) { + continue; + } + $line = $offset + 2; + $record = [ + 'firstName' => trim((string) ($row[$columns['firstName']] ?? '')), + 'lastName' => trim((string) ($row[$columns['lastName']] ?? '')), + 'department' => trim((string) ($row[$columns['department']] ?? '')), + 'jobLevel' => $this->jobLevel((string) ($row[$columns['jobLevel']] ?? '')), + 'managerName' => trim((string) ($row[$columns['managerName']] ?? '')), + 'email' => Str::lower(trim((string) ($row[$columns['email']] ?? ''))), + 'line' => $line, + ]; + $validator = Validator::make($record, [ + 'firstName' => ['required', 'string', 'max:100'], + 'lastName' => ['required', 'string', 'max:100'], + 'department' => ['required', 'string', 'max:160'], + 'jobLevel' => ['required', 'in:specialist,manager,senior_manager'], + 'managerName' => ['nullable', 'string', 'max:200'], + 'email' => ['required', 'email:rfc', 'max:255'], + ]); + if ($validator->fails()) { + foreach ($validator->errors()->all() as $message) { + $errors["rows.{$line}"][] = $message; + } + } + $records[] = $record; + } + if ($records === []) { + $errors['file'][] = 'فایل هیچ ردیف کارمندی ندارد.'; + } + $duplicates = collect($records)->groupBy('email')->filter(fn ($group): bool => $group->count() > 1)->keys(); + foreach ($duplicates as $email) { + $errors['file'][] = "ایمیل {$email} در فایل تکراری است."; + } + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + + return DB::transaction(function () use ($organizationId, $records): array { + $created = 0; + $updated = 0; + $users = []; + foreach ($records as $record) { + $existing = User::query()->where('email', $record['email'])->first(); + if ($existing && $existing->organization_id !== $organizationId) { + throw ValidationException::withMessages(["rows.{$record['line']}.email" => ['این ایمیل در سازمان دیگری ثبت شده است.']]); + } + if (! $existing) { + $this->seatQuota->assertAvailable($organizationId, "rows.{$record['line']}.email"); + $existing = new User(['email' => $record['email'], 'password' => Hash::make(Str::random(64))]); + $created++; + } else { + $updated++; + } + $role = in_array($record['jobLevel'], ['manager', 'senior_manager'], true) ? UserRole::Manager : UserRole::Learner; + $existing->fill([ + 'organization_id' => $organizationId, + 'name' => trim($record['firstName'].' '.$record['lastName']), + 'first_name' => $record['firstName'], + 'last_name' => $record['lastName'], + 'department' => $record['department'], + 'job_level' => $record['jobLevel'], + 'role' => $role, + 'status' => AccountStatus::Active, + 'locale' => 'fa', + 'timezone' => 'Asia/Tehran', + ])->save(); + $users[$record['email']] = $existing; + } + + $organizationManagers = User::query()->where('organization_id', $organizationId)->where('role', UserRole::Manager)->get(); + $byName = $organizationManagers->groupBy(fn (User $user): string => $this->normalize($user->name)); + $linked = 0; + foreach ($records as $record) { + $user = $users[$record['email']]; + if ($record['managerName'] === '') { + $user->update(['direct_manager_id' => null]); + + continue; + } + $matches = $byName->get($this->normalize($record['managerName']), collect()); + if ($matches->count() !== 1) { + throw ValidationException::withMessages(["rows.{$record['line']}.manager" => [$matches->isEmpty() ? 'مدیر مستقیم با این نام پیدا نشد.' : 'نام مدیر مستقیم مبهم است؛ نام کامل باید یکتا باشد.']]); + } + $manager = $matches->first(); + if ($manager->is($user)) { + throw ValidationException::withMessages(["rows.{$record['line']}.manager" => ['کاربر نمی‌تواند مدیر مستقیم خودش باشد.']]); + } + $user->update(['direct_manager_id' => $manager->getKey()]); + $linked++; + } + + return ['created' => $created, 'updated' => $updated, 'managers' => $organizationManagers->count(), 'reportsLinked' => $linked, 'total' => count($records)]; + }); + } + + /** @return list> */ + private function rows(UploadedFile $file): array + { + return Str::lower($file->getClientOriginalExtension()) === 'xlsx' + ? $this->xlsxRows($file->getRealPath()) + : $this->csvRows($file->getRealPath()); + } + + /** @return list> */ + private function csvRows(string $path): array + { + $handle = fopen($path, 'rb'); + if (! $handle) { + throw ValidationException::withMessages(['file' => ['فایل قابل خواندن نیست.']]); + } + $rows = []; + while (($row = fgetcsv($handle)) !== false) { + $rows[] = array_map(fn (?string $value): string => preg_replace('/^\xEF\xBB\xBF/', '', $value ?? '') ?? '', $row); + } + fclose($handle); + + return $rows; + } + + /** @return list> */ + private function xlsxRows(string $path): array + { + $zip = new ZipArchive; + if ($zip->open($path) !== true) { + throw ValidationException::withMessages(['file' => ['ساختار فایل XLSX معتبر نیست.']]); + } + $shared = []; + if (($xml = $zip->getFromName('xl/sharedStrings.xml')) !== false) { + $document = new \DOMDocument; + $document->loadXML($xml, LIBXML_NONET); + $xpath = new \DOMXPath($document); + $xpath->registerNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + foreach ($xpath->query('//x:si') ?: [] as $node) { + $shared[] = collect(iterator_to_array($xpath->query('.//x:t', $node) ?: []))->map(fn (\DOMNode $text): string => $text->textContent)->implode(''); + } + } + $sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml'); + $zip->close(); + if ($sheetXml === false) { + throw ValidationException::withMessages(['file' => ['اولین worksheet در فایل پیدا نشد.']]); + } + $document = new \DOMDocument; + $document->loadXML($sheetXml, LIBXML_NONET); + $xpath = new \DOMXPath($document); + $xpath->registerNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $rows = []; + foreach ($xpath->query('//x:sheetData/x:row') ?: [] as $rowNode) { + $row = []; + foreach ($xpath->query('./x:c', $rowNode) ?: [] as $cell) { + preg_match('/^[A-Z]+/', $cell->attributes?->getNamedItem('r')?->nodeValue ?? '', $match); + $index = $this->columnIndex($match[0] ?? 'A'); + $type = $cell->attributes?->getNamedItem('t')?->nodeValue; + $valueNode = $xpath->query('./x:v', $cell)?->item(0); + $value = $type === 'inlineStr' ? ($xpath->query('.//x:t', $cell)?->item(0)?->textContent ?? '') : ($valueNode?->textContent ?? ''); + $row[$index] = $type === 's' ? ($shared[(int) $value] ?? '') : $value; + } + if ($row !== []) { + $max = max(array_keys($row)); + $rows[] = array_map(fn (int $index): string => (string) ($row[$index] ?? ''), range(0, $max)); + } + } + + return $rows; + } + + private function columnIndex(string $letters): int + { + $number = 0; + foreach (str_split($letters) as $letter) { + $number = $number * 26 + ord($letter) - 64; + } + + return max(0, $number - 1); + } + + /** @param list $headers @return array */ + private function resolveColumns(array $headers): array + { + $aliases = [ + 'firstName' => ['نام'], + 'lastName' => ['نام خانوادگی', 'نام‌خانوادگی'], + 'department' => ['واحد یا دپارتمان', 'واحد', 'دپارتمان'], + 'jobLevel' => ['سمت', 'سطح شغلی'], + 'managerName' => ['نام مدیر مستقیم', 'مدیر مستقیم'], + 'email' => ['آدرس ایمیل', 'ایمیل'], + ]; + $resolved = []; + foreach ($aliases as $field => $names) { + $index = collect($headers)->search(fn (string $header): bool => in_array($header, array_map(fn (string $name): string => $this->normalize($name), $names), true)); + if ($index === false) { + throw ValidationException::withMessages(['file' => ["ستون الزامی «{$names[0]}» پیدا نشد."]]); + } + $resolved[$field] = $index; + } + + return $resolved; + } + + private function jobLevel(string $value): ?string + { + return match ($this->normalize($value)) { + 'کارشناس' => 'specialist', + 'مدیر' => 'manager', + 'مدیر ارشد' => 'senior_manager', + default => null, + }; + } + + private function normalize(string $value): string + { + return Str::of($value)->replace(['ي', 'ك', "\u{200C}"], ['ی', 'ک', ' '])->squish()->lower()->toString(); + } +} diff --git a/backend/app/Modules/Identity/Application/WorkforceTemplate.php b/backend/app/Modules/Identity/Application/WorkforceTemplate.php new file mode 100644 index 0000000..c03aa67 --- /dev/null +++ b/backend/app/Modules/Identity/Application/WorkforceTemplate.php @@ -0,0 +1,93 @@ +open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Unable to create workforce template archive.'); + } + + $zip->addFromString('[Content_Types].xml', $this->contentTypes()); + $zip->addFromString('_rels/.rels', $this->rootRelationships()); + $zip->addFromString('xl/workbook.xml', $this->workbook()); + $zip->addFromString('xl/_rels/workbook.xml.rels', $this->workbookRelationships()); + $zip->addFromString('xl/styles.xml', $this->styles()); + $zip->addFromString('xl/worksheets/sheet1.xml', $this->worksheet()); + $zip->close(); + + return $path; + } + + private function worksheet(): string + { + $rows = [ + ['نام', 'نام خانوادگی', 'واحد یا دپارتمان', 'سمت', 'نام مدیر مستقیم', 'آدرس ایمیل'], + ['علی', 'رضایی', 'عملیات', 'مدیر', '', 'ali.rezaei@example.com'], + ['مریم', 'احمدی', 'عملیات', 'کارشناس', 'علی رضایی', 'maryam.ahmadi@example.com'], + ]; + $sheetRows = []; + foreach ($rows as $rowIndex => $row) { + $cells = []; + foreach ($row as $columnIndex => $value) { + $reference = $this->column($columnIndex + 1).($rowIndex + 1); + $escaped = htmlspecialchars($value, ENT_XML1 | ENT_QUOTES, 'UTF-8'); + $cells[] = "{$escaped}"; + } + $sheetRows[] = ''.implode('', $cells).''; + } + + return '' + .'' + .'' + .''.implode('', $sheetRows).''; + } + + private function column(int $number): string + { + $column = ''; + while ($number > 0) { + $number--; + $column = chr(65 + ($number % 26)).$column; + $number = intdiv($number, 26); + } + + return $column; + } + + private function contentTypes(): string + { + return ''; + } + + private function rootRelationships(): string + { + return ''; + } + + private function workbook(): string + { + return ''; + } + + private function workbookRelationships(): string + { + return ''; + } + + private function styles(): string + { + return ''; + } +} diff --git a/backend/app/Modules/Identity/Domain/Enums/AccountStatus.php b/backend/app/Modules/Identity/Domain/Enums/AccountStatus.php new file mode 100644 index 0000000..c773034 --- /dev/null +++ b/backend/app/Modules/Identity/Domain/Enums/AccountStatus.php @@ -0,0 +1,10 @@ + UserRole::class, 'profile' => 'array', 'expires_at' => 'immutable_datetime', + 'accepted_at' => 'immutable_datetime', 'revoked_at' => 'immutable_datetime', + ]; + } + + public function isUsable(): bool + { + return $this->accepted_at === null && $this->revoked_at === null && $this->expires_at->isFuture(); + } +} diff --git a/backend/app/Modules/Identity/Http/AuthController.php b/backend/app/Modules/Identity/Http/AuthController.php new file mode 100644 index 0000000..44e2fb0 --- /dev/null +++ b/backend/app/Modules/Identity/Http/AuthController.php @@ -0,0 +1,57 @@ +validated(); + $user = User::query()->where('email', mb_strtolower($credentials['email']))->first(); + + if (! $user || ! Hash::check($credentials['password'], $user->password)) { + throw ValidationException::withMessages(['email' => ['The provided credentials are incorrect.']]); + } + + if ($user->status === AccountStatus::Disabled) { + return response()->json(['error' => ['code' => 'account_disabled', 'message' => 'This account is disabled.']], 403); + } + + $user->loadMissing('organization'); + if ($user->role !== UserRole::SuperAdmin && (! $user->organization || $user->organization->status !== 'active')) { + return response()->json(['error' => ['code' => 'organization_unavailable', 'message' => 'The organization is unavailable.']], 403); + } + + $token = $user->createToken($credentials['device_name'] ?? 'web')->plainTextToken; + + return response()->json(['data' => [ + 'token' => $token, + 'user' => $this->payload->make($user), + ]]); + } + + public function me(Request $request): JsonResponse + { + return response()->json(['data' => $this->payload->make($request->user())]); + } + + public function logout(Request $request): JsonResponse + { + $request->user()->currentAccessToken()?->delete(); + + return response()->json(['data' => ['loggedOut' => true]]); + } +} diff --git a/backend/app/Modules/Identity/Http/InvitationController.php b/backend/app/Modules/Identity/Http/InvitationController.php new file mode 100644 index 0000000..e8114a3 --- /dev/null +++ b/backend/app/Modules/Identity/Http/InvitationController.php @@ -0,0 +1,114 @@ +permissions->allows($request->user(), Permission::UsersInvite), 403); + $data = $request->validated(); + $profile = collect($data)->only(['firstName', 'lastName', 'department', 'jobLevel', 'directManagerId', 'teamIds'])->all(); + if (filled($profile['directManagerId'] ?? null)) { + abort_unless(User::query()->where('organization_id', $this->tenant->id())->where('id', $profile['directManagerId'])->exists(), 422, 'مدیر مستقیم معتبر نیست.'); + } + if (($profile['teamIds'] ?? []) !== []) { + abort_unless(count($profile['teamIds']) === DB::table('teams')->where('organization_id', $this->tenant->id())->whereIn('id', $profile['teamIds'])->count(), 422, 'یک یا چند تیم معتبر نیست.'); + } + $invitation = $this->invitations->invite( + $this->tenant->organization(), + $request->user(), + $data['email'], + UserRole::from($data['role']), + $profile, + ); + + return response()->json(['data' => [ + 'id' => $invitation->getKey(), + 'email' => $invitation->email, + 'role' => $invitation->role->value, + 'expiresAt' => $invitation->expires_at->toISOString(), + 'status' => 'pending', + ]], 201); + } + + public function index(Request $request): JsonResponse + { + abort_unless($this->permissions->allows($request->user(), Permission::UsersInvite), 403); + $items = UserInvitation::query()->where('organization_id', $this->tenant->id())->latest()->get()->map(fn (UserInvitation $item) => [ + 'id' => $item->getKey(), 'email' => $item->email, 'role' => $item->role->value, + 'status' => $item->accepted_at ? 'accepted' : ($item->revoked_at ? 'revoked' : ($item->expires_at->isPast() ? 'expired' : 'pending')), + 'expiresAt' => $item->expires_at->toISOString(), + ]); + + return response()->json(['data' => $items]); + } + + public function revoke(Request $request, string $invitation): JsonResponse + { + abort_unless($this->permissions->allows($request->user(), Permission::UsersInvite), 403); + $item = UserInvitation::query()->where('organization_id', $this->tenant->id())->findOrFail($invitation); + if ($item->accepted_at) { + throw ValidationException::withMessages(['invitation' => ['Accepted invitations cannot be revoked.']]); + } + $item->update(['revoked_at' => now()]); + + return response()->json(['data' => ['revoked' => true]]); + } + + public function resend(Request $request, string $invitation): JsonResponse + { + abort_unless($this->permissions->allows($request->user(), Permission::UsersInvite), 403); + $item = UserInvitation::query()->where('organization_id', $this->tenant->id())->findOrFail($invitation); + + if ($item->accepted_at) { + throw ValidationException::withMessages(['invitation' => ['Accepted invitations cannot be resent.']]); + } + + $refreshed = $this->invitations->invite( + $this->tenant->organization(), + $request->user(), + $item->email, + $item->role, + $item->profile ?? [], + ); + + return response()->json(['data' => [ + 'id' => $refreshed->getKey(), + 'email' => $refreshed->email, + 'role' => $refreshed->role->value, + 'expiresAt' => $refreshed->expires_at->toISOString(), + 'status' => 'pending', + ]]); + } + + public function accept(AcceptInvitationRequest $request): JsonResponse + { + $data = $request->validated(); + $user = $this->invitations->accept($data['token'], $data['name'], $data['password']); + $token = $user->createToken('web')->plainTextToken; + + return response()->json(['data' => ['token' => $token, 'userId' => $user->getKey()]]); + } +} diff --git a/backend/app/Modules/Identity/Http/PasswordController.php b/backend/app/Modules/Identity/Http/PasswordController.php new file mode 100644 index 0000000..7f2fed1 --- /dev/null +++ b/backend/app/Modules/Identity/Http/PasswordController.php @@ -0,0 +1,45 @@ + mb_strtolower($request->validated('email'))]); + + return response()->json(['data' => [ + 'message' => 'If the account exists, a password reset link has been sent.', + ]], 202); + } + + public function reset(ResetPasswordRequest $request): JsonResponse + { + $data = $request->validated(); + $status = Password::reset( + ['email' => mb_strtolower($data['email']), 'password' => $data['password'], 'password_confirmation' => $request->input('password_confirmation'), 'token' => $data['token']], + function (User $user, string $password) { + $user->forceFill(['password' => Hash::make($password), 'remember_token' => Str::random(60)])->save(); + $user->tokens()->delete(); + event(new PasswordReset($user)); + }, + ); + + if ($status !== Password::PASSWORD_RESET) { + throw ValidationException::withMessages(['token' => [__($status)]]); + } + + return response()->json(['data' => ['reset' => true]]); + } +} diff --git a/backend/app/Modules/Identity/Http/Requests/AcceptInvitationRequest.php b/backend/app/Modules/Identity/Http/Requests/AcceptInvitationRequest.php new file mode 100644 index 0000000..2e2e0d8 --- /dev/null +++ b/backend/app/Modules/Identity/Http/Requests/AcceptInvitationRequest.php @@ -0,0 +1,23 @@ + ['required', 'string'], + 'name' => ['required', 'string', 'max:160'], + 'password' => ['required', 'confirmed', Password::defaults()], + ]; + } +} diff --git a/backend/app/Modules/Identity/Http/Requests/ForgotPasswordRequest.php b/backend/app/Modules/Identity/Http/Requests/ForgotPasswordRequest.php new file mode 100644 index 0000000..3a9918e --- /dev/null +++ b/backend/app/Modules/Identity/Http/Requests/ForgotPasswordRequest.php @@ -0,0 +1,18 @@ + ['required', 'email']]; + } +} diff --git a/backend/app/Modules/Identity/Http/Requests/ImportUsersRequest.php b/backend/app/Modules/Identity/Http/Requests/ImportUsersRequest.php new file mode 100644 index 0000000..804035a --- /dev/null +++ b/backend/app/Modules/Identity/Http/Requests/ImportUsersRequest.php @@ -0,0 +1,20 @@ + ['required', 'file', 'max:20480', 'extensions:xlsx,csv', 'mimetypes:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/zip,text/csv,text/plain,application/csv'], + ]; + } +} diff --git a/backend/app/Modules/Identity/Http/Requests/InviteUserRequest.php b/backend/app/Modules/Identity/Http/Requests/InviteUserRequest.php new file mode 100644 index 0000000..4188385 --- /dev/null +++ b/backend/app/Modules/Identity/Http/Requests/InviteUserRequest.php @@ -0,0 +1,32 @@ + ['required', 'email', 'max:255'], + 'role' => ['required', Rule::in([ + UserRole::CourseDesigner->value, UserRole::Manager->value, UserRole::Learner->value, + ])], + 'firstName' => ['nullable', 'string', 'max:100'], + 'lastName' => ['nullable', 'string', 'max:100'], + 'department' => ['nullable', 'string', 'max:160'], + 'jobLevel' => ['nullable', Rule::in(['specialist', 'manager', 'senior_manager'])], + 'directManagerId' => ['nullable', 'string', 'max:26'], + 'teamIds' => ['nullable', 'array', 'max:20'], + 'teamIds.*' => ['string', 'distinct', 'max:26'], + ]; + } +} diff --git a/backend/app/Modules/Identity/Http/Requests/LoginRequest.php b/backend/app/Modules/Identity/Http/Requests/LoginRequest.php new file mode 100644 index 0000000..4afb57c --- /dev/null +++ b/backend/app/Modules/Identity/Http/Requests/LoginRequest.php @@ -0,0 +1,22 @@ + ['required', 'email'], + 'password' => ['required', 'string'], + 'device_name' => ['sometimes', 'string', 'max:120'], + ]; + } +} diff --git a/backend/app/Modules/Identity/Http/Requests/ResetPasswordRequest.php b/backend/app/Modules/Identity/Http/Requests/ResetPasswordRequest.php new file mode 100644 index 0000000..7012aba --- /dev/null +++ b/backend/app/Modules/Identity/Http/Requests/ResetPasswordRequest.php @@ -0,0 +1,23 @@ + ['required', 'email'], + 'token' => ['required', 'string'], + 'password' => ['required', 'confirmed', Password::defaults()], + ]; + } +} diff --git a/backend/app/Modules/Identity/Http/Requests/UpdateUserRequest.php b/backend/app/Modules/Identity/Http/Requests/UpdateUserRequest.php new file mode 100644 index 0000000..0c8086f --- /dev/null +++ b/backend/app/Modules/Identity/Http/Requests/UpdateUserRequest.php @@ -0,0 +1,30 @@ + ['sometimes', 'required', 'string', 'max:100'], + 'lastName' => ['sometimes', 'required', 'string', 'max:100'], + 'email' => ['sometimes', 'required', 'email:rfc', 'max:255', Rule::unique('users', 'email')->ignore($this->route('user'))], + 'department' => ['sometimes', 'nullable', 'string', 'max:160'], + 'jobLevel' => ['sometimes', 'nullable', Rule::in(['specialist', 'manager', 'senior_manager'])], + 'directManagerId' => ['sometimes', 'nullable', 'string'], + 'role' => ['sometimes', Rule::enum(UserRole::class), Rule::notIn([UserRole::SuperAdmin->value])], + 'status' => ['sometimes', Rule::enum(AccountStatus::class)], + ]; + } +} diff --git a/backend/app/Modules/Identity/Http/UserController.php b/backend/app/Modules/Identity/Http/UserController.php new file mode 100644 index 0000000..0b1c7f2 --- /dev/null +++ b/backend/app/Modules/Identity/Http/UserController.php @@ -0,0 +1,161 @@ +permissions->allows($request->user(), Permission::UsersView), 403); + $filters = $request->validate([ + 'search' => ['nullable', 'string', 'max:160'], + 'role' => ['nullable', Rule::enum(UserRole::class)], + 'status' => ['nullable', Rule::enum(AccountStatus::class)], + 'perPage' => ['nullable', 'integer', 'min:1', 'max:100'], + 'page' => ['nullable', 'integer', 'min:1'], + ]); + + $users = $this->directory->visibleTo($request->user(), $this->tenant->id()) + ->with('directManager:id,name') + ->when($filters['search'] ?? null, fn ($query, string $search) => $query->where(fn ($inner) => $inner->where('name', 'like', '%'.$search.'%')->orWhere('email', 'like', '%'.$search.'%')->orWhere('department', 'like', '%'.$search.'%'))) + ->when($filters['role'] ?? null, fn ($query, string $role) => $query->where('role', $role)) + ->when($filters['status'] ?? null, fn ($query, string $status) => $query->where('status', $status)) + ->orderBy('name') + ->paginate($filters['perPage'] ?? 25) + ->through(fn (User $user) => $this->payload($user)); + + return response()->json(['data' => $users->items(), 'meta' => [ + 'currentPage' => $users->currentPage(), 'lastPage' => $users->lastPage(), 'total' => $users->total(), + ]]); + } + + public function import(ImportUsersRequest $request): JsonResponse + { + abort_unless($this->permissions->allows($request->user(), Permission::UsersManage), 403); + + $result = $this->workforceImport->import($this->tenant->id(), $request->file('file')); + $this->assignmentResolver->syncOrganization($this->tenant->id()); + + return response()->json(['data' => $result], 201); + } + + public function template(Request $request): BinaryFileResponse + { + abort_unless($this->permissions->allows($request->user(), Permission::UsersManage), 403); + + return response()->download( + $this->workforceTemplate->create(), + 'workforce-import-template.xlsx', + ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + )->deleteFileAfterSend(true); + } + + public function update(UpdateUserRequest $request, string $user): JsonResponse + { + abort_unless($this->permissions->allows($request->user(), Permission::UsersManage), 403); + $target = User::query()->where('organization_id', $this->tenant->id())->findOrFail($user); + $data = $request->validated(); + $nextRole = isset($data['role']) ? UserRole::from($data['role']) : $target->role; + $nextStatus = isset($data['status']) ? AccountStatus::from($data['status']) : $target->status; + + if ($target->status === AccountStatus::Disabled && $nextStatus !== AccountStatus::Disabled) { + $this->seatQuota->assertAvailable($this->tenant->id(), 'status'); + } + + if ($target->role === UserRole::CourseDesigner && ($nextRole !== UserRole::CourseDesigner || $nextStatus !== AccountStatus::Active)) { + $otherDesigners = User::query()->where('organization_id', $this->tenant->id())->whereKeyNot($target->getKey())->where('role', UserRole::CourseDesigner)->where('status', AccountStatus::Active)->exists(); + if (! $otherDesigners) { + throw ValidationException::withMessages(['role' => ['At least one active Course Designer is required.']]); + } + } + + $manager = null; + if (array_key_exists('directManagerId', $data) && $data['directManagerId'] !== null) { + if ((string) $data['directManagerId'] === (string) $target->getKey()) { + throw ValidationException::withMessages(['directManagerId' => ['کاربر نمی‌تواند مدیر مستقیم خودش باشد.']]); + } + $manager = User::query() + ->where('organization_id', $this->tenant->id()) + ->where(fn ($query) => $query->whereIn('job_level', ['manager', 'senior_manager'])->orWhere('role', UserRole::Manager)) + ->find($data['directManagerId']); + if (! $manager) { + throw ValidationException::withMessages(['directManagerId' => ['مدیر مستقیم انتخاب‌شده معتبر نیست.']]); + } + $ancestor = $manager; + while ($ancestor?->direct_manager_id) { + if ((string) $ancestor->direct_manager_id === (string) $target->getKey()) { + throw ValidationException::withMessages(['directManagerId' => ['این انتخاب در ساختار سازمانی چرخه ایجاد می‌کند.']]); + } + $ancestor = User::query()->where('organization_id', $this->tenant->id())->find($ancestor->direct_manager_id); + } + } + + $firstName = array_key_exists('firstName', $data) ? trim($data['firstName']) : $target->first_name; + $lastName = array_key_exists('lastName', $data) ? trim($data['lastName']) : $target->last_name; + $changes = [ + 'role' => $nextRole, + 'status' => $nextStatus, + ...(array_key_exists('firstName', $data) ? ['first_name' => $firstName] : []), + ...(array_key_exists('lastName', $data) ? ['last_name' => $lastName] : []), + ...(array_key_exists('email', $data) ? ['email' => mb_strtolower(trim($data['email']))] : []), + ...(array_key_exists('department', $data) ? ['department' => $data['department'] ? trim($data['department']) : null] : []), + ...(array_key_exists('jobLevel', $data) ? ['job_level' => $data['jobLevel']] : []), + ...(array_key_exists('directManagerId', $data) ? ['direct_manager_id' => $manager?->getKey()] : []), + ]; + if (array_key_exists('firstName', $data) || array_key_exists('lastName', $data)) { + $changes['name'] = trim(implode(' ', array_filter([$firstName, $lastName]))) ?: $target->name; + } + $target->update($changes); + if ($nextStatus === AccountStatus::Disabled) { + $target->tokens()->delete(); + } + $this->assignmentResolver->syncOrganization($this->tenant->id()); + + return response()->json(['data' => $this->payload($target->fresh('directManager:id,name'))]); + } + + /** @return array */ + private function payload(User $user): array + { + return [ + 'id' => $user->getKey(), 'name' => $user->name, 'email' => $user->email, + 'role' => $user->role->value, 'status' => $user->status->value, + 'locale' => $user->locale, 'timezone' => $user->timezone, + 'firstName' => $user->first_name, 'lastName' => $user->last_name, + 'department' => $user->department, 'jobLevel' => $user->job_level, + 'directManagerId' => $user->direct_manager_id, + 'directManager' => $user->directManager ? ['id' => $user->directManager->getKey(), 'name' => $user->directManager->name] : null, + 'createdAt' => $user->created_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Modules/Identity/Notifications/UserInvited.php b/backend/app/Modules/Identity/Notifications/UserInvited.php new file mode 100644 index 0000000..7ccc448 --- /dev/null +++ b/backend/app/Modules/Identity/Notifications/UserInvited.php @@ -0,0 +1,34 @@ +token); + + return (new MailMessage) + ->subject('You are invited to '.$this->organizationName) + ->line('You have been invited to join '.$this->organizationName.'.') + ->action('Accept invitation', $url) + ->line('This invitation expires in 72 hours.'); + } +} diff --git a/backend/app/Modules/Learner/Application/LearnerProgressReport.php b/backend/app/Modules/Learner/Application/LearnerProgressReport.php new file mode 100644 index 0000000..cece8c3 --- /dev/null +++ b/backend/app/Modules/Learner/Application/LearnerProgressReport.php @@ -0,0 +1,275 @@ + $assignments */ + public function build(User $learner, Collection $assignments): array + { + $now = now(); + $weekStart = $now->copy()->startOfWeek(); + $previousWeekStart = $weekStart->copy()->subWeek(); + $assignmentIds = $assignments->pluck('id'); + $courseVersionIds = $this->courseVersionIds($assignments); + + $attempts = AssessmentAttempt::query() + ->join('assessments', 'assessments.id', '=', 'assessment_attempts.assessment_id') + ->where('assessment_attempts.organization_id', $learner->organization_id) + ->where('assessment_attempts.learner_id', $learner->getKey()) + ->where('assessment_attempts.status', 'completed') + ->orderByDesc('assessment_attempts.completed_at') + ->get([ + 'assessment_attempts.id', 'assessment_attempts.assessment_id as assessmentId', + 'assessment_attempts.course_version_id as courseVersionId', 'assessment_attempts.attempt_number as attemptNumber', + 'assessment_attempts.score', 'assessment_attempts.completed_at as completedAt', + 'assessments.title', 'assessments.settings', + ]); + $events = LearningEvent::query() + ->where('organization_id', $learner->organization_id) + ->where('learner_id', $learner->getKey()) + ->orderBy('occurred_at') + ->get(); + $currentEvents = $events->filter(fn (LearningEvent $event) => $event->occurred_at?->betweenIncluded($weekStart, $now)); + $previousEvents = $events->filter(fn (LearningEvent $event) => $event->occurred_at?->betweenIncluded($previousWeekStart, $weekStart)); + $currentAttempts = $attempts->filter(fn ($attempt) => $attempt->completedAt && Carbon::parse($attempt->completedAt)->betweenIncluded($weekStart, $now)); + $previousAttempts = $attempts->filter(fn ($attempt) => $attempt->completedAt && Carbon::parse($attempt->completedAt)->betweenIncluded($previousWeekStart, $weekStart)); + $averageAssessment = $this->scoreAverage($attempts); + $currentAssessmentAverage = $this->scoreAverage($currentAttempts); + $previousAssessmentAverage = $this->scoreAverage($previousAttempts); + $learningMinutes = $this->learningMinutes($currentEvents); + $previousLearningMinutes = $this->learningMinutes($previousEvents); + $completedCourses = DB::table('assignment_users') + ->where('user_id', $learner->getKey())->whereIn('assignment_id', $assignmentIds) + ->where('status', 'completed')->count(); + $skills = $this->skills($learner); + $recentAssessments = $this->recentAssessments($attempts, $assignments); + $weeklyActivity = $this->weeklyActivity($learner, $events, $currentEvents, $weekStart, $now, $assignmentIds); + $achievements = $this->achievements($learner, $skills, $averageAssessment, $completedCourses, $weeklyActivity['streak']); + $recommendation = $this->recommendation($skills, $assignments, $courseVersionIds); + + return [ + 'generatedAt' => $now->toISOString(), + 'summary' => [ + 'averageAssessment' => $averageAssessment, + 'assessmentDelta' => $this->delta($currentAssessmentAverage, $previousAssessmentAverage), + 'completedCourses' => $completedCourses, + 'learningMinutes' => $learningMinutes, + 'learningMinutesDelta' => $this->percentageDelta($learningMinutes, $previousLearningMinutes), + ], + 'insight' => $this->insight($skills, $currentAssessmentAverage, $previousAssessmentAverage), + 'skills' => $skills, + 'recentAssessments' => $recentAssessments, + 'weeklyActivity' => $weeklyActivity, + 'achievements' => $achievements, + 'recommendation' => $recommendation, + ]; + } + + private function skills(User $learner): array + { + return DB::table('capability_scores as score') + ->join('taxonomy_nodes as node', 'node.id', '=', 'score.taxonomy_node_id') + ->where('score.organization_id', $learner->organization_id) + ->where('score.learner_id', $learner->getKey()) + ->orderByDesc('score.score') + ->limit(8) + ->get(['score.taxonomy_node_id as id', 'node.name', 'score.score', 'score.trend', 'score.confidence', 'score.evidence_count as evidenceCount', 'score.last_evidence_at as lastEvidenceAt']) + ->map(function ($skill) { + $score = $skill->score === null ? null : (int) round((float) $skill->score); + return [ + 'id' => $skill->id, 'name' => $skill->name, 'score' => $score, + 'label' => $this->skillLabel($score), 'tone' => $this->skillTone($score), + 'trend' => $skill->trend === null ? null : round((float) $skill->trend, 1), + 'target' => 80, 'confidence' => round((float) $skill->confidence * 100), + 'evidenceCount' => (int) $skill->evidenceCount, + 'lastEvidenceAt' => $skill->lastEvidenceAt ? Carbon::parse($skill->lastEvidenceAt)->toISOString() : null, + ]; + })->values()->all(); + } + + private function recentAssessments(Collection $attempts, Collection $assignments): array + { + $attemptCounts = $attempts->countBy('assessmentId'); + $assignmentByVersion = $assignments->where('assignable_type', 'course')->keyBy('assignable_id'); + $latest = $attempts->unique('assessmentId')->take(3); + $incorrectByAttempt = DB::table('question_results as result') + ->join('questions as question', 'question.id', '=', 'result.question_id') + ->whereIn('result.assessment_attempt_id', $latest->pluck('id')) + ->where('result.is_correct', false) + ->get(['result.assessment_attempt_id as attemptId', 'question.prompt']) + ->groupBy('attemptId'); + + return $latest->map(function ($attempt) use ($attemptCounts, $assignmentByVersion, $incorrectByAttempt) { + $settings = is_string($attempt->settings) ? json_decode($attempt->settings, true) : ($attempt->settings ?? []); + $score = $attempt->score === null ? null : (int) round((float) $attempt->score * 100); + $passScore = $this->normalizeScore(data_get($settings, 'passScore', data_get($settings, 'passingScore', 70))); + $maxAttempts = (int) data_get($settings, 'maxAttempts', 0); + $attemptCount = (int) ($attemptCounts[$attempt->assessmentId] ?? 1); + $assignment = $assignmentByVersion->get($attempt->courseVersionId); + $lessonId = Lesson::query()->where('course_version_id', $attempt->courseVersionId)->orderBy('position')->value('id'); + return [ + 'id' => $attempt->assessmentId, 'attemptId' => $attempt->id, 'title' => $attempt->title, + 'completedAt' => $attempt->completedAt ? Carbon::parse($attempt->completedAt)->toISOString() : null, + 'score' => $score, 'passScore' => $passScore, 'status' => $score !== null && $score >= $passScore ? 'passed' : 'needs_improvement', + 'attempts' => $attemptCount, 'canRetry' => $maxAttempts === 0 || $attemptCount < $maxAttempts, + 'feedback' => $score === null ? 'نتیجه این ارزیابی هنوز کامل نشده است.' : ($score >= $passScore ? 'عملکرد شما موفق بوده است؛ مرور پاسخ‌های نادرست به تثبیت یادگیری کمک می‌کند.' : 'مرور موضوع‌های نیازمند تمرین و تلاش دوباره می‌تواند نتیجه را بهتر کند.'), + 'weakTopics' => $incorrectByAttempt->get($attempt->id, collect())->pluck('prompt')->take(3)->values()->all(), + 'retryUrl' => $assignment && $lessonId ? '/learn/player/'.$assignment->getKey().'?course='.$attempt->courseVersionId.'&lesson='.$lessonId : null, + ]; + })->values()->all(); + } + + private function weeklyActivity(User $learner, Collection $events, Collection $currentEvents, Carbon $weekStart, Carbon $now, Collection $assignmentIds): array + { + $eventDates = $events->map(fn (LearningEvent $event) => $event->occurred_at?->toDateString())->filter()->unique()->sort()->values(); + $streak = $this->streak($eventDates, $now); + $longest = $this->longestStreak($eventDates); + $sessions = $currentEvents->pluck('session_id')->filter()->unique()->count(); + if ($sessions === 0) { + $sessions = $currentEvents->map(fn (LearningEvent $event) => $event->occurred_at?->toDateString())->filter()->unique()->count(); + } + $lessonsCompleted = LessonProgress::query()->where('organization_id', $learner->organization_id)->where('learner_id', $learner->getKey()) + ->whereIn('assignment_id', $assignmentIds)->whereBetween('completed_at', [$weekStart, $now])->count(); + + return [ + 'sessionsCompleted' => $sessions, 'streak' => $streak, + 'learningMinutes' => $this->learningMinutes($currentEvents), 'lessonsCompleted' => $lessonsCompleted, + 'daysUntilRecord' => max(1, $longest - $streak + 1), + ]; + } + + private function achievements(User $learner, array $skills, ?int $averageAssessment, int $completedCourses, int $streak): array + { + $items = collect(); + foreach (collect($skills)->filter(fn ($skill) => ($skill['score'] ?? 0) >= 75)->take(2) as $skill) { + $items->push(['id' => 'skill-'.$skill['id'], 'type' => 'badge', 'title' => $skill['name'], 'issuedAt' => $skill['lastEvidenceAt'], 'url' => null]); + } + if ($streak >= 3) $items->push(['id' => 'streak', 'type' => 'badge', 'title' => 'یادگیری پیوسته', 'issuedAt' => null, 'url' => null]); + if ($averageAssessment !== null && $averageAssessment >= 75) $items->push(['id' => 'assessment', 'type' => 'badge', 'title' => 'ارزیابی موفق', 'issuedAt' => null, 'url' => null]); + if ($completedCourses > 0) $items->push(['id' => 'completion', 'type' => 'badge', 'title' => 'تکمیل مسیر', 'issuedAt' => null, 'url' => null]); + + $certificates = DB::table('certificates as certificate') + ->join('course_versions as version', 'version.id', '=', 'certificate.course_version_id') + ->join('courses as course', 'course.id', '=', 'version.course_id') + ->where('certificate.organization_id', $learner->organization_id)->where('certificate.user_id', $learner->getKey())->whereNull('certificate.revoked_at') + ->orderByDesc('certificate.issued_at')->get(['certificate.id', 'certificate.issued_at as issuedAt', 'certificate.expires_at as expiresAt', 'certificate.verification_code as verificationCode', 'course.title']) + ->map(fn ($certificate) => ['id' => $certificate->id, 'type' => 'certificate', 'title' => $certificate->title, 'issuedAt' => Carbon::parse($certificate->issuedAt)->toISOString(), 'expiresAt' => $certificate->expiresAt ? Carbon::parse($certificate->expiresAt)->toISOString() : null, 'url' => $certificate->verificationCode ? '/certificate/verify/'.$certificate->verificationCode : null]); + + return $items->concat($certificates)->unique('id')->take(8)->values()->all(); + } + + private function insight(array $skills, ?int $currentAssessmentAverage, ?int $previousAssessmentAverage): ?array + { + if ($skills !== []) { + $scored = collect($skills)->whereNotNull('score')->sortBy('score'); + if ($scored->isNotEmpty()) { + $weakest = $scored->first(); $strongest = $scored->last(); + return ['title' => 'برداشت این هفته', 'body' => 'عملکرد شما در «'.$strongest['name'].'» بهتر است؛ برای رشد متوازن‌تر، روی «'.$weakest['name'].'» تمرکز کنید.', 'source' => 'capability_scores']; + } + } + if ($currentAssessmentAverage !== null && $previousAssessmentAverage !== null) { + $improved = $currentAssessmentAverage >= $previousAssessmentAverage; + return ['title' => 'برداشت این هفته', 'body' => $improved ? 'میانگین ارزیابی‌های این هفته بهتر شده است؛ همین روند مرور و تمرین را ادامه دهید.' : 'برای تثبیت یادگیری، پاسخ‌های ارزیابی‌های اخیر را مرور و یک تمرین کوتاه انجام دهید.', 'source' => 'assessment_attempts']; + } + return null; + } + + private function recommendation(array $skills, Collection $assignments, Collection $courseVersionIds): ?array + { + $weakest = collect($skills)->whereNotNull('score')->sortBy('score')->first(); + $active = $assignments->first(function ($assignment) { + $pivot = $assignment->users->first()?->pivot; + return ($pivot?->status ?? 'assigned') !== 'completed'; + }); + if (! $active) return null; + $courseVersionId = $active->assignable_type === 'course' ? $active->assignable_id : $courseVersionIds->first(); + $lessonId = $courseVersionId ? Lesson::query()->where('course_version_id', $courseVersionId)->orderBy('position')->value('id') : null; + if (! $courseVersionId || ! $lessonId) return null; + $courseTitle = CourseVersion::query()->with('course')->find($courseVersionId)?->course?->title ?? 'یادگیری پیشنهادی'; + return [ + 'title' => 'پیشنهاد بعدی', + 'body' => $weakest ? 'برای تقویت مهارت «'.$weakest['name'].'»، فعالیت بعدی «'.$courseTitle.'» را ادامه دهید.' : 'برای ادامه روند رشد، فعالیت بعدی «'.$courseTitle.'» را شروع کنید.', + 'label' => 'مشاهده پیشنهاد', 'url' => '/learn/player/'.$active->getKey().'?course='.$courseVersionId.'&lesson='.$lessonId, + ]; + } + + private function courseVersionIds(Collection $assignments): Collection + { + $direct = $assignments->where('assignable_type', 'course')->pluck('assignable_id'); + $paths = $assignments->where('assignable_type', 'learning_path')->pluck('assignable_id'); + $pathVersions = $paths->isEmpty() ? collect() : DB::table('learning_path_items')->whereIn('learning_path_version_id', $paths)->pluck('course_version_id'); + return $direct->merge($pathVersions)->filter()->unique()->values(); + } + + private function learningMinutes(Collection $events): int + { + $seconds = $events->sum(fn (LearningEvent $event) => min(21600, max(0, (int) data_get($event->payload, 'durationSeconds', 0)))); + return (int) round($seconds / 60); + } + + private function scoreAverage(Collection $attempts): ?int + { + $scores = $attempts->pluck('score')->filter(fn ($score) => $score !== null); + return $scores->isEmpty() ? null : (int) round((float) $scores->average() * 100); + } + + private function delta(?int $current, ?int $previous): ?int + { + return $current === null || $previous === null ? null : $current - $previous; + } + + private function percentageDelta(int $current, int $previous): ?int + { + if ($previous === 0) return $current === 0 ? 0 : null; + return (int) round(($current - $previous) / $previous * 100); + } + + private function normalizeScore(mixed $score): int + { + $value = is_numeric($score) ? (float) $score : 70; + return (int) round($value <= 1 ? $value * 100 : $value); + } + + private function skillLabel(?int $score): string + { + return $score === null ? 'داده ناکافی' : ($score >= 75 ? 'خوب' : ($score >= 50 ? 'متوسط' : 'نیاز به تمرین')); + } + + private function skillTone(?int $score): string + { + return $score === null ? 'neutral' : ($score >= 75 ? 'success' : ($score >= 50 ? 'warning' : 'practice')); + } + + private function streak(Collection $dates, Carbon $now): int + { + if ($dates->isEmpty()) return 0; + $lookup = $dates->flip(); + $cursor = $now->copy()->startOfDay(); + if (! $lookup->has($cursor->toDateString())) $cursor->subDay(); + $count = 0; + while ($lookup->has($cursor->toDateString())) { $count++; $cursor->subDay(); } + return $count; + } + + private function longestStreak(Collection $dates): int + { + $longest = 0; $current = 0; $previous = null; + foreach ($dates as $date) { + $day = Carbon::parse($date)->startOfDay(); + $current = $previous && $previous->copy()->addDay()->isSameDay($day) ? $current + 1 : 1; + $longest = max($longest, $current); $previous = $day; + } + return $longest; + } +} diff --git a/backend/app/Modules/Learner/Application/LearnerProgressService.php b/backend/app/Modules/Learner/Application/LearnerProgressService.php new file mode 100644 index 0000000..71f1510 --- /dev/null +++ b/backend/app/Modules/Learner/Application/LearnerProgressService.php @@ -0,0 +1,160 @@ +where('organization_id', $learner->organization_id)->where('status', 'active') + ->whereHas('users', fn ($query) => $query->where('users.id', $learner->getKey())->whereNot('assignment_users.status', 'cancelled')) + ->findOrFail($assignmentId); + } + + public function courseVersion(Assignment $assignment, string $courseVersionId): CourseVersion + { + $allowed = $assignment->assignable_type === 'course' && (string) $assignment->assignable_id === $courseVersionId; + if ($assignment->assignable_type === 'learning_path') { + $allowed = LearningPathVersion::query()->whereKey($assignment->assignable_id)->whereHas('items', fn ($query) => $query->where('course_version_id', $courseVersionId))->exists(); + } + if (! $allowed) { + throw ValidationException::withMessages(['courseVersionId' => ['این دوره بخشی از تخصیص شما نیست.']]); + } + + return CourseVersion::query()->where('organization_id', $assignment->organization_id)->whereKey($courseVersionId)->firstOrFail(); + } + + /** @param array $payload */ + public function record(User $learner, Assignment $assignment, CourseVersion $version, string $eventId, string $type, ?Lesson $lesson, ?Block $block, array $payload, \DateTimeInterface $occurredAt, array $context = []): array + { + if ($type === 'block.interacted' && $block) { + $payload['score'] = $this->assessmentScore($block, $payload['response'] ?? null); + } + + return DB::transaction(function () use ($learner, $assignment, $version, $eventId, $type, $lesson, $block, $payload, $occurredAt, $context): array { + $event = LearningEvent::query()->firstOrCreate( + ['learner_id' => $learner->getKey(), 'client_event_id' => $eventId], + ['organization_id' => $learner->organization_id, 'event_type' => $type, 'schema_version' => $context['schemaVersion'] ?? 1, 'session_id' => $context['sessionId'] ?? null, 'correlation_id' => $context['correlationId'] ?? $eventId, 'causation_id' => $context['causationId'] ?? null, 'device_context' => $context['deviceContext'] ?? null, 'assignment_id' => $assignment->getKey(), 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson?->getKey(), 'block_id' => $block?->getKey(), 'payload' => $payload, 'occurred_at' => $occurredAt, 'received_at' => now()], + ); + if (! $event->wasRecentlyCreated) { + return ['duplicate' => true, ...$this->courseProgress($learner, $assignment, $version)]; + } + if ($type === 'note.created' && $lesson && trim((string) ($payload['body'] ?? '')) !== '') { + LearnerNote::query()->create([ + 'organization_id' => $learner->organization_id, + 'learner_id' => $learner->getKey(), + 'course_version_id' => $version->getKey(), + 'lesson_id' => $lesson->getKey(), + 'body' => trim((string) $payload['body']), + ]); + } + if ($lesson) { + $lessonProgress = LessonProgress::query()->firstOrCreate( + ['assignment_id' => $assignment->getKey(), 'learner_id' => $learner->getKey(), 'lesson_id' => $lesson->getKey()], + ['organization_id' => $learner->organization_id, 'course_version_id' => $version->getKey(), 'status' => 'in_progress', 'progress' => 0, 'started_at' => $occurredAt], + ); + $lessonProgress->update(['last_activity_at' => $occurredAt]); + if ($block) { + BlockProgress::query()->updateOrCreate( + ['assignment_id' => $assignment->getKey(), 'learner_id' => $learner->getKey(), 'block_id' => $block->getKey()], + ['organization_id' => $learner->organization_id, 'lesson_id' => $lesson->getKey(), 'status' => in_array($type, ['block.completed', 'block.interacted'], true) ? 'completed' : 'viewed', 'score' => $payload['score'] ?? null, 'response' => $payload['response'] ?? null, 'first_viewed_at' => $occurredAt, 'completed_at' => in_array($type, ['block.completed', 'block.interacted'], true) ? $occurredAt : null], + ); + } + $total = $lesson->blocks()->count(); + $completed = BlockProgress::query()->where('assignment_id', $assignment->getKey())->where('learner_id', $learner->getKey())->where('lesson_id', $lesson->getKey())->where('status', 'completed')->count(); + $progress = $total > 0 ? (int) floor(($completed / $total) * 100) : 0; + if ($type === 'lesson.completed') { + $progress = 100; + } + $lessonProgress->update(['progress' => $progress, 'status' => $progress === 100 ? 'completed' : 'in_progress', 'completed_at' => $progress === 100 ? $occurredAt : null]); + } + $summary = $this->courseProgress($learner, $assignment, $version); + DB::table('assignment_users')->where('assignment_id', $assignment->getKey())->where('user_id', $learner->getKey())->update(['progress' => $summary['progress'], 'status' => $summary['completed'] ? 'completed' : 'in_progress', 'completed_at' => $summary['completed'] ? now() : null, 'updated_at' => now()]); + ProcessLearningEvent::dispatch($event->getKey())->afterCommit(); + + return ['duplicate' => false, ...$summary]; + }); + } + + /** @return array{progress: int, completed: bool, completedLessons: int, totalLessons: int} */ + public function courseProgress(User $learner, Assignment $assignment, CourseVersion $version): array + { + $total = $version->lessons()->count(); + $completed = LessonProgress::query()->where('assignment_id', $assignment->getKey())->where('learner_id', $learner->getKey())->where('course_version_id', $version->getKey())->where('status', 'completed')->count(); + $percentage = $total > 0 ? (int) floor(($completed / $total) * 100) : 0; + $rules = $version->completion_rules ?? ['mode' => 'all', 'rules' => [['type' => 'all_required_lessons']]]; + $results = collect($rules['rules'] ?? [])->map(fn (array $rule) => match ($rule['type'] ?? '') { + 'all_required_lessons' => $total > 0 && $completed === $total, + 'minimum_lesson_percentage' => $percentage >= (int) ($rule['value'] ?? 100), + 'minimum_score', 'assessment_passed' => BlockProgress::query()->where('assignment_id', $assignment->getKey())->where('learner_id', $learner->getKey())->whereNotNull('score')->where('score', '>=', (float) ($rule['value'] ?? 0.7))->exists(), + 'required_interaction' => BlockProgress::query()->where('assignment_id', $assignment->getKey())->where('learner_id', $learner->getKey())->where('status', 'completed')->exists(), + default => false, + }); + $isComplete = $results->isNotEmpty() && (($rules['mode'] ?? 'all') === 'any' ? $results->contains(true) : $results->every(fn ($value) => $value)); + + return ['progress' => $isComplete ? 100 : $percentage, 'completed' => $isComplete, 'completedLessons' => $completed, 'totalLessons' => $total]; + } + + private function assessmentScore(Block $block, mixed $response): ?float + { + $data = $block->data ?? []; + $answers = is_array($response) ? $response : []; + + return match ($block->type) { + 'single_choice' => ((int) ($answers[0] ?? -1)) === (int) ($data['answerIndex'] ?? -2) ? 1.0 : 0.0, + 'multiple_choice' => $this->sameValues($answers, $data['answerIndexes'] ?? []) ? 1.0 : 0.0, + 'true_false' => (((int) ($answers[0] ?? -1)) === 0) === (bool) ($data['answer'] ?? false) ? 1.0 : 0.0, + 'scenario' => max(0.0, min(1.0, (float) ($data['choices'][(int) ($answers[0] ?? -1)]['score'] ?? 0))), + 'matching' => $this->sameSequence($answers, array_column($data['pairs'] ?? [], 'right')) ? 1.0 : 0.0, + 'sorting' => $this->sameSequence($answers, array_column($data['items'] ?? [], 'text')) ? 1.0 : 0.0, + 'drag_drop' => $this->sameSequence($answers, array_column($data['items'] ?? [], 'target')) ? 1.0 : 0.0, + 'hotspot' => $this->hotspotScore($answers, $data['hotspots'] ?? []), + 'branching_scenario' => ($answers['completed'] ?? false) === true ? 1.0 : 0.0, + default => null, + }; + } + + /** @param array $actual @param array $expected */ + private function sameValues(array $actual, array $expected): bool + { + sort($actual); + sort($expected); + + return $actual === $expected; + } + + /** @param array $actual @param array $expected */ + private function sameSequence(array $actual, array $expected): bool + { + return array_values($actual) === array_values($expected); + } + + /** @param array $answer @param array> $hotspots */ + private function hotspotScore(array $answer, array $hotspots): float + { + $x = (float) ($answer['x'] ?? -1000); + $y = (float) ($answer['y'] ?? -1000); + + foreach ($hotspots as $hotspot) { + if (($hotspot['correct'] ?? false) && hypot($x - (float) $hotspot['x'], $y - (float) $hotspot['y']) <= (float) $hotspot['radius']) { + return 1.0; + } + } + + return 0.0; + } +} diff --git a/backend/app/Modules/Learner/Domain/BlockProgress.php b/backend/app/Modules/Learner/Domain/BlockProgress.php new file mode 100644 index 0000000..6460a4a --- /dev/null +++ b/backend/app/Modules/Learner/Domain/BlockProgress.php @@ -0,0 +1,20 @@ + 'decimal:4', 'response' => 'array', 'first_viewed_at' => 'immutable_datetime', 'completed_at' => 'immutable_datetime']; + } +} diff --git a/backend/app/Modules/Learner/Domain/LearnerBookmark.php b/backend/app/Modules/Learner/Domain/LearnerBookmark.php new file mode 100644 index 0000000..0b9b5ba --- /dev/null +++ b/backend/app/Modules/Learner/Domain/LearnerBookmark.php @@ -0,0 +1,13 @@ + 'array', 'device_context' => 'array', 'occurred_at' => 'immutable_datetime', 'received_at' => 'immutable_datetime']; + } + + protected static function booted(): void + { + static::updating(fn () => throw new \DomainException('Raw learning events are immutable.')); + static::deleting(fn () => throw new \DomainException('Raw learning events are immutable.')); + } +} diff --git a/backend/app/Modules/Learner/Domain/LessonProgress.php b/backend/app/Modules/Learner/Domain/LessonProgress.php new file mode 100644 index 0000000..10fb63f --- /dev/null +++ b/backend/app/Modules/Learner/Domain/LessonProgress.php @@ -0,0 +1,20 @@ + 'integer', 'started_at' => 'immutable_datetime', 'completed_at' => 'immutable_datetime', 'last_activity_at' => 'immutable_datetime']; + } +} diff --git a/backend/app/Modules/Learner/Http/LearnerController.php b/backend/app/Modules/Learner/Http/LearnerController.php new file mode 100644 index 0000000..0e0723e --- /dev/null +++ b/backend/app/Modules/Learner/Http/LearnerController.php @@ -0,0 +1,271 @@ +learner($request); + $assignments = $this->assignments($learner->getKey()); + $assignmentIds = $assignments->pluck('id'); + $activities = LessonProgress::query() + ->join('lessons', 'lessons.id', '=', 'lesson_progress.lesson_id') + ->where('lesson_progress.learner_id', $learner->getKey()) + ->whereIn('lesson_progress.assignment_id', $assignmentIds) + ->orderByDesc('lesson_progress.last_activity_at') + ->get(['lesson_progress.assignment_id as assignmentId', 'lesson_progress.last_activity_at as lastActivityAt', 'lessons.title as lessonTitle']) + ->unique('assignmentId')->keyBy('assignmentId'); + $scores = BlockProgress::query()->where('learner_id', $learner->getKey())->whereIn('assignment_id', $assignmentIds)->whereNotNull('score') + ->selectRaw('assignment_id, avg(score) * 100 as final_score')->groupBy('assignment_id')->pluck('final_score', 'assignment_id'); + $favorites = DB::table('learner_favorites')->where('learner_id', $learner->getKey())->whereIn('assignment_id', $assignmentIds)->pluck('assignment_id')->flip(); + $certificates = DB::table('certificates')->where('user_id', $learner->getKey())->get(['course_version_id', 'verification_code'])->keyBy('course_version_id'); + $items = $assignments->map(fn (Assignment $assignment) => $this->assignmentPayload($assignment, $activities->get($assignment->getKey()), $scores->get($assignment->getKey()), $favorites->has($assignment->getKey()), $certificates)); + $completed = $items->where('status', 'completed')->count(); + + return response()->json(['data' => [ + 'continueLearning' => $items->whereIn('status', ['in_progress', 'assigned'])->sortByDesc(fn ($item) => $item['progress'])->first(), + 'assigned' => $items->values(), 'dueSoon' => $items->filter(fn ($item) => $item['dueAt'] && now()->diffInDays($item['dueAt'], false) <= 7 && $item['status'] !== 'completed')->values(), + 'summary' => ['total' => $items->count(), 'completed' => $completed, 'inProgress' => $items->where('status', 'in_progress')->count(), 'averageProgress' => $items->count() ? (int) round($items->avg('progress')) : 0], + ]]); + } + + public function progress(Request $request): JsonResponse + { + $learner = $this->learner($request); + $assignments = $this->assignments($learner->getKey()); + + return response()->json(['data' => $this->progressReport->build($learner, $assignments)]); + } + + public function show(Request $request, string $assignment): JsonResponse + { + $learner = $this->learner($request); + $model = $this->progress->assignment($learner, $assignment); + $courseVersionId = $request->string('courseVersionId')->toString() ?: (string) $model->assignable_id; + $version = $this->progress->courseVersion($model, $courseVersionId); + $version->load(['course.coverAsset', 'modules.lessons.blocks']); + $lessonId = $request->string('lessonId')->toString(); + $lesson = $version->lessons()->when($lessonId, fn ($query) => $query->whereKey($lessonId))->orderBy('position')->firstOrFail(); + $lesson->load('blocks'); + $summary = $this->progress->courseProgress($learner, $model, $version); + $progress = LessonProgress::query()->where('assignment_id', $model->getKey())->where('learner_id', $learner->getKey())->where('course_version_id', $version->getKey())->get()->keyBy('lesson_id'); + $blockProgress = BlockProgress::query()->where('assignment_id', $model->getKey())->where('learner_id', $learner->getKey())->where('lesson_id', $lesson->getKey())->get()->keyBy('block_id'); + + return response()->json(['data' => [ + 'assignment' => ['id' => $model->getKey(), 'dueAt' => $model->due_at?->toISOString(), 'mandatory' => $model->mandatory], + 'course' => ['id' => $version->course_id, 'versionId' => $version->getKey(), 'title' => $version->course->title, 'description' => $version->description, 'progress' => $summary['progress']], + 'lesson' => ['id' => $lesson->getKey(), 'title' => $lesson->title, 'presentationMode' => $lesson->presentation_mode, 'progress' => (int) ($progress->get($lesson->getKey())?->progress ?? 0)], + 'structure' => $version->modules->sortBy('position')->map(fn ($module) => ['id' => $module->getKey(), 'title' => $module->title, 'lessons' => $module->lessons->sortBy('position')->map(fn (Lesson $item) => ['id' => $item->getKey(), 'title' => $item->title, 'position' => $item->position, 'status' => $progress->get($item->getKey())?->status ?? 'not_started', 'progress' => (int) ($progress->get($item->getKey())?->progress ?? 0)])->values()])->values(), + 'blocks' => $lesson->blocks->sortBy('position')->map(fn (Block $block) => ['id' => $block->getKey(), 'type' => $block->type, 'data' => $this->assetUrls($block->data, $block->organization_id), 'style' => $block->style, 'behavior' => $block->behavior, 'accessibility' => $block->accessibility, 'position' => $block->position, 'status' => $blockProgress->get($block->getKey())?->status ?? 'not_started'])->values(), + 'notes' => LearnerNote::query()->where('learner_id', $learner->getKey())->where('course_version_id', $version->getKey())->where('lesson_id', $lesson->getKey())->latest()->get()->map(fn ($note) => ['id' => $note->getKey(), 'body' => $note->body, 'updatedAt' => $note->updated_at?->toISOString()]), + 'bookmarks' => LearnerBookmark::query()->where('learner_id', $learner->getKey())->where('lesson_id', $lesson->getKey())->pluck('block_id')->filter()->values(), + 'highlights' => DB::table('learner_highlights')->where('learner_id', $learner->getKey())->where('lesson_id', $lesson->getKey())->latest()->get(['id', 'block_id as blockId', 'quote', 'color', 'created_at as createdAt']), + 'favorite' => DB::table('learner_favorites')->where('learner_id', $learner->getKey())->where('assignment_id', $model->getKey())->exists(), + 'discussions' => DB::table('lesson_discussions')->join('users', 'users.id', '=', 'lesson_discussions.learner_id')->where('lesson_discussions.organization_id', $learner->organization_id)->where('lesson_id', $lesson->getKey())->orderBy('lesson_discussions.created_at')->get(['lesson_discussions.id', 'lesson_discussions.parent_id as parentId', 'lesson_discussions.body', 'lesson_discussions.created_at as createdAt', 'users.name as author'])->map(function ($comment) use ($learner) { + $comment->reactions = DB::table('discussion_reactions')->where('discussion_id', $comment->id)->select('reaction', DB::raw('count(*) as count'))->groupBy('reaction')->get(); + $comment->myReactions = DB::table('discussion_reactions')->where('discussion_id', $comment->id)->where('learner_id', $learner->getKey())->pluck('reaction'); + + return $comment; + }), + 'offlineManifest' => $this->manifest($model, $version), + ]]); + } + + public function sync(Request $request): JsonResponse + { + $learner = $this->learner($request); + $events = $request->validate(['events' => ['required', 'array', 'min:1', 'max:100'], 'events.*.id' => ['required', 'uuid'], 'events.*.type' => ['required', Rule::in(EventTaxonomy::learning())], 'events.*.schemaVersion' => ['nullable', 'integer', Rule::in([EventTaxonomy::SCHEMA_VERSION])], 'events.*.sessionId' => ['nullable', 'string', 'max:120'], 'events.*.correlationId' => ['nullable', 'uuid'], 'events.*.causationId' => ['nullable', 'uuid'], 'events.*.deviceContext' => ['nullable', 'array'], 'events.*.assignmentId' => ['required', 'string'], 'events.*.courseVersionId' => ['required', 'string'], 'events.*.lessonId' => ['nullable', 'string'], 'events.*.blockId' => ['nullable', 'string'], 'events.*.payload' => ['nullable', 'array'], 'events.*.occurredAt' => ['required', 'date', 'before_or_equal:now']])['events']; + $results = []; + foreach ($events as $event) { + $assignment = $this->progress->assignment($learner, $event['assignmentId']); + $version = $this->progress->courseVersion($assignment, $event['courseVersionId']); + $lesson = isset($event['lessonId']) ? Lesson::query()->where('course_version_id', $version->getKey())->findOrFail($event['lessonId']) : null; + $block = isset($event['blockId']) && $lesson ? Block::query()->where('lesson_id', $lesson->getKey())->findOrFail($event['blockId']) : null; + $context = ['schemaVersion' => $event['schemaVersion'] ?? EventTaxonomy::SCHEMA_VERSION, 'sessionId' => $event['sessionId'] ?? null, 'correlationId' => $event['correlationId'] ?? $event['id'], 'causationId' => $event['causationId'] ?? null, 'deviceContext' => EventTaxonomy::deviceContext($event['deviceContext'] ?? [])]; + $result = $this->progress->record($learner, $assignment, $version, $event['id'], $event['type'], $lesson, $block, $event['payload'] ?? [], new \DateTimeImmutable($event['occurredAt']), $context); + if (($result['completed'] ?? false) && $assignment->assignable_type === 'course') { + $this->certificates->issue($learner, $version); + } + $results[] = ['id' => $event['id'], ...$result]; + } + + return response()->json(['data' => ['events' => $results]]); + } + + public function note(Request $request, string $assignment): JsonResponse + { + $learner = $this->learner($request); + $model = $this->progress->assignment($learner, $assignment); + $data = $request->validate(['courseVersionId' => ['required', 'string'], 'lessonId' => ['required', 'string'], 'body' => ['required', 'string', 'max:10000']]); + $version = $this->progress->courseVersion($model, $data['courseVersionId']); + Lesson::query()->where('course_version_id', $version->getKey())->findOrFail($data['lessonId']); + $note = LearnerNote::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'lesson_id' => $data['lessonId'], 'body' => $data['body']]); + + return response()->json(['data' => ['id' => $note->getKey(), 'body' => $note->body, 'updatedAt' => $note->updated_at?->toISOString()]], 201); + } + + public function bookmark(Request $request, string $assignment): JsonResponse + { + $learner = $this->learner($request); + $model = $this->progress->assignment($learner, $assignment); + $data = $request->validate(['courseVersionId' => ['required', 'string'], 'lessonId' => ['required', 'string'], 'blockId' => ['required', 'string']]); + $version = $this->progress->courseVersion($model, $data['courseVersionId']); + $block = Block::query()->where('course_version_id', $version->getKey())->where('lesson_id', $data['lessonId'])->findOrFail($data['blockId']); + $bookmark = LearnerBookmark::query()->where('learner_id', $learner->getKey())->where('lesson_id', $data['lessonId'])->where('block_id', $block->getKey())->first(); + if ($bookmark) { + $bookmark->delete(); + } else { + LearnerBookmark::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'lesson_id' => $data['lessonId'], 'block_id' => $block->getKey()]); + } + + return response()->json(['data' => ['bookmarked' => ! $bookmark]]); + } + + public function highlight(Request $request, string $assignment): JsonResponse + { + $learner = $this->learner($request); + $model = $this->progress->assignment($learner, $assignment); + $data = $request->validate(['courseVersionId' => ['required', 'string'], 'lessonId' => ['required', 'string'], 'blockId' => ['required', 'string'], 'quote' => ['required', 'string', 'max:2000'], 'color' => ['required', Rule::in(['yellow', 'green', 'blue'])]]); + $version = $this->progress->courseVersion($model, $data['courseVersionId']); + Block::query()->where('course_version_id', $version->getKey())->where('lesson_id', $data['lessonId'])->findOrFail($data['blockId']); + $id = (string) Str::ulid(); + DB::table('learner_highlights')->insert(['id' => $id, 'organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'lesson_id' => $data['lessonId'], 'block_id' => $data['blockId'], 'quote' => $data['quote'], 'color' => $data['color'], 'created_at' => now(), 'updated_at' => now()]); + + return response()->json(['data' => ['id' => $id]], 201); + } + + public function favorite(Request $request, string $assignment): JsonResponse + { + $learner = $this->learner($request); + $model = $this->progress->assignment($learner, $assignment); + $existing = DB::table('learner_favorites')->where('learner_id', $learner->getKey())->where('assignment_id', $model->getKey()); + $favorite = ! $existing->exists(); + if ($favorite) { + DB::table('learner_favorites')->insert(['id' => (string) Str::ulid(), 'organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'assignment_id' => $model->getKey(), 'created_at' => now(), 'updated_at' => now()]); + } else { + $existing->delete(); + } + + return response()->json(['data' => ['favorite' => $favorite]]); + } + + public function discuss(Request $request, string $assignment): JsonResponse + { + $learner = $this->learner($request); + $model = $this->progress->assignment($learner, $assignment); + $data = $request->validate(['courseVersionId' => ['required', 'string'], 'lessonId' => ['required', 'string'], 'body' => ['required', 'string', 'max:5000'], 'parentId' => ['nullable', 'string']]); + $version = $this->progress->courseVersion($model, $data['courseVersionId']); + Lesson::query()->where('course_version_id', $version->getKey())->findOrFail($data['lessonId']); + if (($data['parentId'] ?? null) && ! DB::table('lesson_discussions')->where('organization_id', $learner->organization_id)->where('lesson_id', $data['lessonId'])->where('id', $data['parentId'])->exists()) { + abort(422); + } $id = (string) Str::ulid(); + DB::table('lesson_discussions')->insert(['id' => $id, 'organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'lesson_id' => $data['lessonId'], 'parent_id' => $data['parentId'] ?? null, 'body' => $data['body'], 'created_at' => now(), 'updated_at' => now()]); + + return response()->json(['data' => ['id' => $id]], 201); + } + + public function react(Request $request, string $discussion): JsonResponse + { + $learner = $this->learner($request); + $data = $request->validate(['reaction' => ['required', Rule::in(['helpful', 'like'])]]); + abort_unless(DB::table('lesson_discussions')->where('organization_id', $learner->organization_id)->where('id', $discussion)->exists(), 404); + $query = DB::table('discussion_reactions')->where('learner_id', $learner->getKey())->where('discussion_id', $discussion)->where('reaction', $data['reaction']); + $active = ! $query->exists(); + if ($active) { + DB::table('discussion_reactions')->insert(['id' => (string) Str::ulid(), 'organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'discussion_id' => $discussion, 'reaction' => $data['reaction'], 'created_at' => now(), 'updated_at' => now()]); + } else { + $query->delete(); + } + + return response()->json(['data' => ['active' => $active]]); + } + + private function assignments(string $learnerId) + { + return Assignment::query()->where('organization_id', auth()->user()->organization_id)->where('status', 'active')->whereHas('users', fn ($query) => $query->where('users.id', $learnerId)->whereNot('assignment_users.status', 'cancelled'))->with(['users' => fn ($query) => $query->where('users.id', $learnerId)])->latest()->get(); + } + + private function assignmentPayload(Assignment $assignment, mixed $activity, mixed $score, bool $favorite, mixed $certificates): array + { + $pivot = $assignment->users->first()?->pivot; + $version = $assignment->assignable_type === 'course' ? CourseVersion::query()->with(['course.coverAsset', 'lessons'])->find($assignment->assignable_id) : null; + $path = $assignment->assignable_type === 'learning_path' ? LearningPathVersion::query()->with(['path', 'items.courseVersion.course.coverAsset', 'items.courseVersion.lessons'])->find($assignment->assignable_id) : null; + $firstVersion = $version ?? $path?->items?->first()?->courseVersion; + $firstLesson = $firstVersion?->lessons()->orderBy('position')->first(); + $lessons = $version?->lessons ?? $path?->items?->flatMap(fn ($item) => $item->courseVersion?->lessons ?? collect()) ?? collect(); + $totalMinutes = (int) $lessons->sum(fn (Lesson $lesson) => (int) data_get($lesson->settings, 'durationMinutes', 0)); + $progress = (int) ($pivot?->progress ?? 0); + $taxonomy = $version?->taxonomy_snapshot ?? []; + $skills = collect(data_get($taxonomy, 'skills', []))->map(fn ($skill) => is_array($skill) ? ($skill['name'] ?? $skill['title'] ?? null) : $skill)->filter(fn ($skill) => is_string($skill) && trim($skill) !== '')->values(); + $certificate = $firstVersion ? $certificates->get($firstVersion->getKey()) : null; + + return ['id' => $assignment->getKey(), 'type' => $assignment->assignable_type, 'title' => $version?->course?->title ?? $path?->path?->title ?? 'محتوای یادگیری', 'description' => $version?->description ?? $path?->description, 'courseVersionId' => $firstVersion?->getKey(), 'lessonId' => $firstLesson?->getKey(), 'coverUrl' => $firstVersion?->course?->coverAsset ? URL::temporarySignedRoute('assets.content', now()->addHours(), ['asset' => $firstVersion->course->coverAsset->getKey()], absolute: false) : null, 'progress' => $progress, 'status' => $pivot?->status ?? 'assigned', 'mandatory' => $assignment->mandatory, 'dueAt' => $assignment->due_at?->toISOString(), + 'startsAt' => $assignment->starts_at?->toISOString(), 'assignedAt' => $pivot?->assigned_at ? Carbon::parse($pivot->assigned_at)->toISOString() : null, 'completedAt' => $pivot?->completed_at ? Carbon::parse($pivot->completed_at)->toISOString() : null, + 'lastActivityAt' => $activity?->lastActivityAt ? Carbon::parse($activity->lastActivityAt)->toISOString() : null, 'currentLessonTitle' => $activity?->lessonTitle ?? $firstLesson?->title, + 'lessonCount' => $lessons->count(), 'remainingMinutes' => $totalMinutes > 0 ? (int) ceil($totalMinutes * (100 - $progress) / 100) : null, 'skills' => $skills, + 'finalScore' => $score !== null ? (int) round((float) $score) : null, 'favorite' => $favorite, 'certificateUrl' => $certificate ? '/certificate/verify/'.$certificate->verification_code : null]; + } + + private function manifest(Assignment $assignment, CourseVersion $version): array + { + $version->loadMissing('modules.lessons.blocks'); + $assetIds = $version->blocks->flatMap(fn (Block $block) => $this->assets->assetIds($block->data))->unique(); + $assets = Asset::query()->where('organization_id', $version->organization_id)->whereIn('id', $assetIds)->get()->map(fn (Asset $asset) => ['id' => $asset->getKey(), 'kind' => $asset->kind, 'size' => $asset->size, 'downloadAllowed' => $asset->kind !== 'video' || $asset->size <= 200 * 1024 * 1024, 'url' => URL::temporarySignedRoute('assets.content', now()->addHours(), ['asset' => $asset->getKey()], absolute: false)]); + + return ['version' => 1, 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), 'generatedAt' => now()->toISOString(), 'assets' => $assets]; + } + + private function assetUrls(array $data, string $organizationId): array + { + foreach ($this->assets->assetIds($data) as $id) { + $asset = Asset::query()->where('organization_id', $organizationId)->find($id); + if ($asset) { + $data['_assets'][$id] = URL::temporarySignedRoute('assets.content', now()->addHours(), ['asset' => $asset->getKey()], absolute: false); + } + } + + return $data; + } + + private function learner(Request $request) + { + abort_unless($request->user() && $this->permissions->allows($request->user(), Permission::PersonalLearningView), 403); + + return $request->user(); + } +} diff --git a/backend/app/Modules/LearningPaths/Domain/LearningPath.php b/backend/app/Modules/LearningPaths/Domain/LearningPath.php new file mode 100644 index 0000000..6943530 --- /dev/null +++ b/backend/app/Modules/LearningPaths/Domain/LearningPath.php @@ -0,0 +1,32 @@ +hasMany(LearningPathVersion::class); + } + + public function latestVersion(): HasOne + { + return $this->hasOne(LearningPathVersion::class)->ofMany('version_number', 'max'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/backend/app/Modules/LearningPaths/Domain/LearningPathItem.php b/backend/app/Modules/LearningPaths/Domain/LearningPathItem.php new file mode 100644 index 0000000..d23602b --- /dev/null +++ b/backend/app/Modules/LearningPaths/Domain/LearningPathItem.php @@ -0,0 +1,25 @@ + 'integer', 'completion_rules' => 'array']; + } + + public function courseVersion(): BelongsTo + { + return $this->belongsTo(CourseVersion::class); + } +} diff --git a/backend/app/Modules/LearningPaths/Domain/LearningPathVersion.php b/backend/app/Modules/LearningPaths/Domain/LearningPathVersion.php new file mode 100644 index 0000000..736fee7 --- /dev/null +++ b/backend/app/Modules/LearningPaths/Domain/LearningPathVersion.php @@ -0,0 +1,45 @@ + CourseVersionStatus::class, 'settings' => 'array', + 'review_submitted_at' => 'immutable_datetime', 'published_at' => 'immutable_datetime', + 'scheduled_publish_at' => 'immutable_datetime', 'scheduled_unpublish_at' => 'immutable_datetime', 'unpublished_at' => 'immutable_datetime', + ]; + } + + protected static function booted(): void + { + static::updating(function (self $version) { + if ($version->getOriginal('status') === CourseVersionStatus::Published->value) { + throw new DomainException('Published learning path versions are immutable.'); + } + }); + } + + public function path(): BelongsTo + { + return $this->belongsTo(LearningPath::class, 'learning_path_id'); + } + + public function items(): HasMany + { + return $this->hasMany(LearningPathItem::class)->orderBy('position'); + } +} diff --git a/backend/app/Modules/LearningPaths/Http/LearningPathController.php b/backend/app/Modules/LearningPaths/Http/LearningPathController.php new file mode 100644 index 0000000..ec6bbfd --- /dev/null +++ b/backend/app/Modules/LearningPaths/Http/LearningPathController.php @@ -0,0 +1,251 @@ +authorize($request); + $paths = LearningPath::query()->where('organization_id', $this->tenant->id())->with(['latestVersion' => fn ($query) => $query->withCount('items')])->latest()->get()->map(fn (LearningPath $path) => $this->summary($path)); + + return response()->json(['data' => $paths]); + } + + public function contexts(Request $request): JsonResponse + { + $this->authorize($request); + $courses = CourseVersion::query()->with('course:id,title')->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->orderBy('title')->get()->map(fn (CourseVersion $version) => [ + 'id' => $version->getKey(), 'title' => $version->course->title, 'version' => $version->version_number, + ]); + + return response()->json(['data' => $courses]); + } + + public function store(Request $request): JsonResponse + { + $this->authorize($request); + $data = $request->validate(['title' => ['required', 'string', 'max:240'], 'description' => ['nullable', 'string', 'max:3000'], 'enforceOrder' => ['required', 'boolean']]); + $path = DB::transaction(function () use ($request, $data): LearningPath { + $base = Str::slug($data['title']) ?: 'path-'.Str::lower(Str::random(8)); + $slug = $base; + $i = 2; + while (LearningPath::query()->where('organization_id', $this->tenant->id())->where('slug', $slug)->exists()) { + $slug = $base.'-'.$i++; + } + $path = LearningPath::query()->create(['organization_id' => $this->tenant->id(), 'title' => $data['title'], 'slug' => $slug, 'status' => 'draft', 'created_by' => $request->user()->getKey()]); + LearningPathVersion::query()->create(['organization_id' => $this->tenant->id(), 'learning_path_id' => $path->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Draft, 'title' => $data['title'], 'description' => $data['description'] ?? null, 'settings' => ['enforceOrder' => $data['enforceOrder']]]); + + return $path; + }); + + return response()->json(['data' => $this->summary($path->load(['latestVersion' => fn ($query) => $query->withCount('items')]))], 201); + } + + public function show(Request $request, string $path): JsonResponse + { + $this->authorize($request); + $model = $this->path($path); + $versionId = $request->string('versionId')->toString(); + $version = LearningPathVersion::query()->where('organization_id', $this->tenant->id())->where('learning_path_id', $model->getKey()) + ->when($versionId !== '', fn ($query) => $query->whereKey($versionId))->when($versionId === '', fn ($query) => $query->latest('version_number'))->firstOrFail(); + $version->load(['items.courseVersion.course:id,title']); + $versions = LearningPathVersion::query()->where('learning_path_id', $model->getKey())->orderByDesc('version_number')->get(); + + return response()->json(['data' => ['path' => ['id' => $model->getKey(), 'title' => $model->title, 'status' => $model->status], 'version' => $this->versionPayload($version), 'versions' => $versions->map(fn (LearningPathVersion $item) => $this->versionPayload($item))]]); + } + + public function update(Request $request, string $path, string $version): JsonResponse + { + $this->authorize($request); + $model = $this->version($path, $version); + $this->draft($model); + $data = $request->validate(['title' => ['sometimes', 'string', 'max:240'], 'description' => ['nullable', 'string', 'max:3000'], 'enforceOrder' => ['sometimes', 'boolean']]); + $settings = $model->settings ?? []; + if (array_key_exists('enforceOrder', $data)) { + $settings['enforceOrder'] = $data['enforceOrder']; + } + $model->update([...collect($data)->only(['title', 'description'])->all(), 'settings' => $settings]); + if (isset($data['title'])) { + $model->path()->update(['title' => $data['title']]); + } + + return response()->json(['data' => $this->versionPayload($model->fresh('items.courseVersion.course'))]); + } + + public function addItem(Request $request, string $path, string $version): JsonResponse + { + $this->authorize($request); + $model = $this->version($path, $version); + $this->draft($model); + $data = $request->validate(['courseVersionId' => ['required', 'string'], 'prerequisiteItemId' => ['nullable', 'string'], 'completionType' => ['required', Rule::in(['course_completed', 'minimum_score'])], 'minimumScore' => ['nullable', 'integer', 'between:0,100']]); + $courseVersion = CourseVersion::query()->where('organization_id', $this->tenant->id())->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->findOrFail($data['courseVersionId']); + if ($model->items()->where('course_version_id', $courseVersion->getKey())->exists()) { + throw ValidationException::withMessages(['courseVersionId' => ['این دوره قبلاً در مسیر وجود دارد.']]); + } + $prerequisite = null; + if ($data['prerequisiteItemId'] ?? null) { + $prerequisite = $model->items()->find($data['prerequisiteItemId']); + if (! $prerequisite) { + throw ValidationException::withMessages(['prerequisiteItemId' => ['پیش‌نیاز متعلق به این مسیر نیست.']]); + } + } + $item = LearningPathItem::query()->create(['organization_id' => $this->tenant->id(), 'learning_path_version_id' => $model->getKey(), 'course_version_id' => $courseVersion->getKey(), 'prerequisite_item_id' => $prerequisite?->getKey(), 'position' => $model->items()->count() + 1, 'completion_rules' => ['type' => $data['completionType'], 'value' => $data['minimumScore'] ?? null]]); + + return response()->json(['data' => $this->itemPayload($item->load('courseVersion.course'))], 201); + } + + public function reorder(Request $request, string $path, string $version): JsonResponse + { + $this->authorize($request); + $model = $this->version($path, $version); + $this->draft($model); + $ids = $request->validate(['itemIds' => ['required', 'array'], 'itemIds.*' => ['string']])['itemIds']; + $existing = $model->items()->pluck('id')->map(fn ($id) => (string) $id)->sort()->values()->all(); + $incoming = collect($ids)->unique()->sort()->values()->all(); + if ($existing !== $incoming) { + throw ValidationException::withMessages(['itemIds' => ['فهرست کامل و بدون تکرار مراحل لازم است.']]); + } + DB::transaction(function () use ($model, $ids): void { + foreach ($ids as $index => $id) { + $model->items()->whereKey($id)->update(['position' => $index + 1001]); + } + foreach ($ids as $index => $id) { + $model->items()->whereKey($id)->update(['position' => $index + 1]); + } + }); + + return response()->json(['data' => ['reordered' => true]]); + } + + public function removeItem(Request $request, string $item): JsonResponse + { + $this->authorize($request); + $model = LearningPathItem::query()->where('organization_id', $this->tenant->id())->findOrFail($item); + $version = LearningPathVersion::query()->findOrFail($model->learning_path_version_id); + $this->draft($version); + DB::transaction(function () use ($model, $version): void { + LearningPathItem::query()->where('prerequisite_item_id', $model->getKey())->update(['prerequisite_item_id' => null]); + $model->delete(); + $version->items()->orderBy('position')->get()->each(fn (LearningPathItem $item, int $index) => $item->update(['position' => $index + 1])); + }); + + return response()->json(status: 204); + } + + public function submitReview(Request $request, string $path, string $version): JsonResponse + { + $this->authorize($request); + $model = $this->version($path, $version); + $this->draft($model); + if ($model->items()->count() === 0) { + throw ValidationException::withMessages(['items' => ['مسیر بدون دوره قابل بازبینی نیست.']]); + } + $model->update(['status' => CourseVersionStatus::InReview, 'review_submitted_at' => now()]); + + return response()->json(['data' => $this->versionPayload($model->fresh('items.courseVersion.course'))]); + } + + public function publish(Request $request, string $path, string $version): JsonResponse + { + $this->authorize($request); + $model = $this->version($path, $version); + if ($model->status !== CourseVersionStatus::InReview || $model->items()->count() === 0) { + throw ValidationException::withMessages(['version' => ['مسیر باید در حال بازبینی و دارای حداقل یک دوره باشد.']]); + } + $model->update(['status' => CourseVersionStatus::Published, 'published_at' => now(), 'scheduled_publish_at' => null]); + $model->path()->update(['status' => 'published']); + + return response()->json(['data' => $this->versionPayload($model->fresh('items.courseVersion.course'))]); + } + + public function fork(Request $request, string $path, string $version): JsonResponse + { + $this->authorize($request); + $source = $this->version($path, $version); + if ($source->status !== CourseVersionStatus::Published) { + throw ValidationException::withMessages(['version' => ['فقط نسخه منتشرشده قابل نسخه‌برداری است.']]); + } + $target = DB::transaction(function () use ($source): LearningPathVersion { + $existing = LearningPathVersion::query()->where('source_version_id', $source->getKey())->where('status', CourseVersionStatus::Draft)->first(); + if ($existing) { + return $existing; + } + $target = LearningPathVersion::query()->create(['organization_id' => $source->organization_id, 'learning_path_id' => $source->learning_path_id, 'source_version_id' => $source->getKey(), 'version_number' => LearningPathVersion::query()->where('learning_path_id', $source->learning_path_id)->max('version_number') + 1, 'status' => CourseVersionStatus::Draft, 'title' => $source->title, 'description' => $source->description, 'settings' => $source->settings]); + $map = []; + foreach ($source->items()->orderBy('position')->get() as $item) { + $copy = LearningPathItem::query()->create(['organization_id' => $source->organization_id, 'learning_path_version_id' => $target->getKey(), 'course_version_id' => $item->course_version_id, 'position' => $item->position, 'completion_rules' => $item->completion_rules]); + $map[$item->getKey()] = $copy->getKey(); + } + foreach ($source->items()->whereNotNull('prerequisite_item_id')->get() as $item) { + LearningPathItem::query()->whereKey($map[$item->getKey()])->update(['prerequisite_item_id' => $map[$item->prerequisite_item_id] ?? null]); + } + + return $target; + }); + + return response()->json(['data' => $this->versionPayload($target->load('items.courseVersion.course'))], 201); + } + + private function path(string $id): LearningPath + { + return LearningPath::query()->where('organization_id', $this->tenant->id())->findOrFail($id); + } + + private function version(string $path, string $id): LearningPathVersion + { + $this->path($path); + + return LearningPathVersion::query()->where('organization_id', $this->tenant->id())->where('learning_path_id', $path)->findOrFail($id); + } + + private function draft(LearningPathVersion $version): void + { + if ($version->status !== CourseVersionStatus::Draft) { + throw ValidationException::withMessages(['version' => ['فقط Draft قابل ویرایش است.']]); + } + } + + /** @return array */ + private function summary(LearningPath $path): array + { + $version = $path->latestVersion; + + return ['id' => $path->getKey(), 'title' => $path->title, 'slug' => $path->slug, 'status' => $path->status, 'versionId' => $version?->getKey(), 'versionNumber' => $version?->version_number, 'versionStatus' => $version?->status?->value, 'itemCount' => $version?->items_count ?? 0, 'updatedAt' => $path->updated_at?->toISOString()]; + } + + /** @return array */ + private function versionPayload(LearningPathVersion $version): array + { + return ['id' => $version->getKey(), 'number' => $version->version_number, 'status' => $version->status->value, 'title' => $version->title, 'description' => $version->description, 'settings' => $version->settings ?? [], 'sourceVersionId' => $version->source_version_id, 'publishedAt' => $version->published_at?->toISOString(), 'items' => $version->relationLoaded('items') ? $version->items->map(fn (LearningPathItem $item) => $this->itemPayload($item))->values() : []]; + } + + /** @return array */ + private function itemPayload(LearningPathItem $item): array + { + return ['id' => $item->getKey(), 'courseVersionId' => $item->course_version_id, 'courseTitle' => $item->courseVersion->course->title, 'courseVersion' => $item->courseVersion->version_number, 'prerequisiteItemId' => $item->prerequisite_item_id, 'position' => $item->position, 'completionRules' => $item->completion_rules ?? []]; + } + + private function authorize(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } +} diff --git a/backend/app/Modules/Manager/Application/ManagerScope.php b/backend/app/Modules/Manager/Application/ManagerScope.php new file mode 100644 index 0000000..4ea00f9 --- /dev/null +++ b/backend/app/Modules/Manager/Application/ManagerScope.php @@ -0,0 +1,29 @@ + */ + public function teamIds(User $manager): Collection + { + abort_unless($manager->role === UserRole::Manager, 403); + + return DB::table('team_managers')->join('teams', 'teams.id', '=', 'team_managers.team_id') + ->where('team_managers.user_id', $manager->getKey()) + ->where('teams.organization_id', $manager->organization_id) + ->pluck('teams.id')->map(fn ($id) => (string) $id)->values(); + } + + /** @return Collection */ + public function memberIds(User $manager): Collection + { + return DB::table('team_memberships')->whereIn('team_id', $this->teamIds($manager)) + ->distinct()->pluck('user_id')->map(fn ($id) => (string) $id)->values(); + } +} diff --git a/backend/app/Modules/Manager/Http/ManagerAssignmentController.php b/backend/app/Modules/Manager/Http/ManagerAssignmentController.php new file mode 100644 index 0000000..0c34fd9 --- /dev/null +++ b/backend/app/Modules/Manager/Http/ManagerAssignmentController.php @@ -0,0 +1,273 @@ +user(); + $this->authorize($manager, Permission::ManagerAssignmentsManage); + $teamIds = $this->scope->teamIds($manager); + $memberIds = $this->scope->memberIds($manager); + $courses = CourseVersion::query()->with('course:id,title')->where('organization_id', $manager->organization_id) + ->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->orderByDesc('published_at')->get() + ->map(fn (CourseVersion $version) => ['id' => $version->getKey(), 'type' => 'course', 'title' => $version->course->title, 'version' => $version->version_number]); + $paths = LearningPathVersion::query()->with('path:id,title')->where('organization_id', $manager->organization_id) + ->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->orderByDesc('published_at')->get() + ->map(fn (LearningPathVersion $version) => ['id' => $version->getKey(), 'type' => 'learning_path', 'title' => $version->path->title, 'version' => $version->version_number]); + $members = User::query()->where('organization_id', $manager->organization_id)->whereIn('id', $memberIds)->where('status', AccountStatus::Active) + ->orderBy('name')->get(['id', 'name', 'email', 'department']); + $teams = Team::query()->where('organization_id', $manager->organization_id)->whereIn('id', $teamIds)->where('status', 'active') + ->withCount(['members' => fn ($query) => $query->where('users.status', AccountStatus::Active)])->orderBy('name')->get(['id', 'name']); + + return response()->json(['data' => ['content' => $courses->concat($paths)->values(), 'members' => $members, 'teams' => $teams->map(fn (Team $team) => ['id' => $team->getKey(), 'name' => $team->name, 'memberCount' => $team->members_count])->values()]]); + } + + public function index(Request $request): JsonResponse + { + $manager = $request->user(); + $this->authorize($manager, Permission::ManagerAssignmentsManage); + $memberIds = $this->scope->memberIds($manager); + $assignments = Assignment::query()->where('organization_id', $manager->organization_id)->where('assigned_by', $manager->getKey()) + ->with(['users:id,name'])->latest()->get()->filter(function (Assignment $assignment) use ($memberIds) { + $recipients = $assignment->users->pluck('id')->map(fn ($id) => (string) $id); + + return $recipients->isNotEmpty() && $recipients->diff($memberIds)->isEmpty(); + })->values(); + $courseTitles = CourseVersion::query()->whereIn('id', $assignments->where('assignable_type', 'course')->pluck('assignable_id'))->pluck('title', 'id'); + $pathTitles = LearningPathVersion::query()->whereIn('id', $assignments->where('assignable_type', 'learning_path')->pluck('assignable_id'))->pluck('title', 'id'); + + return response()->json(['data' => $assignments->map(fn (Assignment $assignment) => $this->payload($assignment, $assignment->assignable_type === 'course' ? $courseTitles[$assignment->assignable_id] ?? null : $pathTitles[$assignment->assignable_id] ?? null))->values()]); + } + + public function store(Request $request): JsonResponse + { + $manager = $request->user(); + $this->authorize($manager, Permission::ManagerAssignmentsManage); + $data = $request->validate([ + 'assignableType' => ['required', Rule::in(['course', 'learning_path'])], 'assignableId' => ['required', 'string'], + 'audienceType' => ['required', Rule::in(['team', 'employees'])], 'teamId' => ['nullable', 'string'], + 'userIds' => ['nullable', 'array', 'min:1', 'max:1000'], 'userIds.*' => ['string', 'distinct'], + 'mandatory' => ['required', 'boolean'], 'dueAt' => ['nullable', 'date', 'after:now'], + 'notifyNow' => ['required', 'boolean'], 'reminderDays' => ['nullable', 'integer', 'between:1,365'], + 'reminderOnDeadline' => ['required', 'boolean'], 'onlyIfNotStarted' => ['required', 'boolean'], + ]); + $title = $this->assertAssignable($manager, $data['assignableType'], $data['assignableId']); + $recipients = $this->resolveAudience($manager, $data); + $existingIds = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id') + ->where('a.organization_id', $manager->organization_id)->where('a.status', 'active') + ->where('a.assignable_type', $data['assignableType'])->where('a.assignable_id', $data['assignableId']) + ->whereIn('au.user_id', $recipients->pluck('id'))->whereIn('au.status', ['assigned', 'in_progress'])->distinct()->pluck('au.user_id')->map(fn ($id) => (string) $id); + $eligible = $recipients->reject(fn (User $user) => $existingIds->contains((string) $user->getKey()))->values(); + if ($eligible->isEmpty()) { + throw ValidationException::withMessages(['audience' => ['این آموزش قبلاً برای همه مخاطبان انتخابی فعال است.']]); + } + $notificationConfig = ['notifyNow' => (bool) $data['notifyNow'], 'reminderDays' => $data['reminderDays'] ?? null, 'reminderOnDeadline' => (bool) $data['reminderOnDeadline'], 'onlyIfNotStarted' => (bool) $data['onlyIfNotStarted']]; + $assignment = DB::transaction(function () use ($manager, $data, $eligible, $notificationConfig): Assignment { + $assignment = Assignment::query()->create([ + 'organization_id' => $manager->organization_id, 'assignable_type' => $data['assignableType'], 'assignable_id' => $data['assignableId'], + 'target_type' => 'bulk', 'target_value' => $eligible->pluck('id')->values()->toJson(), 'status' => 'active', 'mandatory' => $data['mandatory'], + 'due_at' => $data['dueAt'] ?? null, 'reminder_days' => ! empty($data['dueAt']) ? ($data['reminderDays'] ?? null) : null, + 'escalation_policy' => ['enabled' => false, 'notifications' => $notificationConfig], 'source' => 'manager', 'assigned_by' => $manager->getKey(), + ]); + $assignment->users()->sync($eligible->mapWithKeys(fn (User $user) => [$user->getKey() => ['status' => 'assigned', 'assigned_at' => now(), 'due_at' => $assignment->due_at, 'progress' => 0, 'created_at' => now(), 'updated_at' => now()]])->all()); + + return $assignment; + })->load(['users:id,name']); + $immediate = ['sent' => 0, 'skipped' => 0]; + if ($data['notifyNow']) { + foreach ($eligible as $recipient) { + $sent = $this->notifications->send($recipient, $manager, $this->message($assignment, $title, $recipient, 'learning.assigned', 'آموزش جدید برای شما تخصیص یافت', 'assigned:'.$assignment->getKey().':'.$recipient->getKey(), (bool) $assignment->mandatory, 'assignmentNotifications')); + $immediate[$sent ? 'sent' : 'skipped']++; + } + } + $scheduled = $this->scheduleReminders($assignment, $eligible, $manager, $title); + $this->audit($request, 'manager.assignment.created', $assignment, ['recipients' => $eligible->count(), 'duplicatesSkipped' => $existingIds->count()]); + + return response()->json(['data' => ['assignment' => $this->payload($assignment, $title), 'result' => ['assigned' => $eligible->count(), 'duplicatesSkipped' => $existingIds->count(), 'notificationsSent' => $immediate['sent'], 'notificationsSkipped' => $immediate['skipped'], 'notificationsScheduled' => $scheduled]]], 201); + } + + public function deadline(Request $request, string $assignment): JsonResponse + { + $manager = $request->user(); + $this->authorize($manager, Permission::ManagerDeadlinesManage); + $model = $this->managedAssignment($manager, $assignment); + abort_if($model->status !== 'active', 422, 'فقط تخصیص فعال قابل ویرایش است.'); + $data = $request->validate(['dueAt' => ['nullable', 'date', 'after:now']]); + DB::transaction(function () use ($model, $data): void { + $model->update(['due_at' => $data['dueAt'] ?? null]); + DB::table('assignment_users')->where('assignment_id', $model->getKey())->whereNotIn('status', ['completed', 'cancelled']) + ->update(['due_at' => $model->due_at, 'updated_at' => now()]); + }); + $this->notifications->cancelPending($manager->organization_id, 'assignment', $model->getKey()); + $fresh = $model->fresh()->load(['users:id,name']); + $scheduled = $fresh->due_at ? $this->scheduleReminders($fresh, $fresh->users, $manager, $this->contentTitle($fresh)) : 0; + $this->audit($request, $fresh->due_at ? 'manager.assignment.deadline_updated' : 'manager.assignment.deadline_removed', $fresh, ['dueAt' => $fresh->due_at?->toISOString()]); + + return response()->json(['data' => ['assignment' => $this->payload($fresh), 'notificationsScheduled' => $scheduled]]); + } + + public function remind(Request $request, string $assignment): JsonResponse + { + $manager = $request->user(); + $this->authorize($manager, Permission::ManagerRemindersSend); + $model = $this->managedAssignment($manager, $assignment); + abort_if($model->status !== 'active', 422, 'فقط برای تخصیص فعال می‌توان یادآوری فرستاد.'); + $data = $request->validate(['userIds' => ['nullable', 'array', 'min:1', 'max:1000'], 'userIds.*' => ['string', 'distinct']]); + $allowedIds = $model->users->filter(fn (User $user) => ! in_array($user->pivot->status, ['completed', 'cancelled'], true))->pluck('id')->map(fn ($id) => (string) $id); + $requested = collect($data['userIds'] ?? $allowedIds)->map(fn ($id) => (string) $id)->unique()->values(); + if ($requested->diff($allowedIds)->isNotEmpty()) { + throw ValidationException::withMessages(['userIds' => ['یادآوری فقط برای مخاطبان فعال و مجاز این تخصیص قابل ارسال است.']]); + } + $recipients = User::query()->where('organization_id', $manager->organization_id)->whereIn('id', $requested)->get(); + $title = $this->contentTitle($model); + $result = ['requested' => $requested->count(), 'sent' => 0, 'skipped' => 0]; + foreach ($recipients as $recipient) { + $sent = $this->notifications->send($recipient, $manager, $this->message($model, $title, $recipient, 'learning.reminder', 'یادآوری یادگیری', 'manual-reminder:'.$model->getKey().':'.$recipient->getKey().':'.now()->toDateString(), false, 'deadlineReminders')); + $result[$sent ? 'sent' : 'skipped']++; + } + $this->audit($request, 'manager.assignment.reminder_sent', $model, $result); + + return response()->json(['data' => $result]); + } + + /** @param array $data @return Collection */ + private function resolveAudience(User $manager, array $data): Collection + { + $memberIds = $this->scope->memberIds($manager); + if ($data['audienceType'] === 'team') { + if (empty($data['teamId']) || ! $this->scope->teamIds($manager)->contains((string) $data['teamId'])) { + throw ValidationException::withMessages(['teamId' => ['این تیم در محدوده مدیریت شما نیست.']]); + } + $requested = DB::table('team_memberships')->where('team_id', $data['teamId'])->pluck('user_id')->map(fn ($id) => (string) $id); + } else { + $requested = collect($data['userIds'] ?? [])->map(fn ($id) => (string) $id)->unique()->values(); + if ($requested->isEmpty() || $requested->diff($memberIds)->isNotEmpty()) { + throw ValidationException::withMessages(['userIds' => ['یک یا چند کاربر خارج از محدوده تیم شما هستند.']]); + } + } + $recipients = User::query()->where('organization_id', $manager->organization_id)->where('status', AccountStatus::Active)->whereIn('id', $requested)->get(); + if ($recipients->isEmpty()) { + throw ValidationException::withMessages(['audience' => ['مخاطب فعال و مجازی برای تخصیص پیدا نشد.']]); + } + + return $recipients; + } + + private function managedAssignment(User $manager, string $id): Assignment + { + $model = Assignment::query()->where('organization_id', $manager->organization_id)->where('assigned_by', $manager->getKey())->with(['users:id,name'])->findOrFail($id); + $recipientIds = $model->users->pluck('id')->map(fn ($value) => (string) $value); + abort_if($recipientIds->isEmpty() || $recipientIds->diff($this->scope->memberIds($manager))->isNotEmpty(), 404); + + return $model; + } + + private function assertAssignable(User $manager, string $type, string $id): string + { + $model = $type === 'course' + ? CourseVersion::query()->where('organization_id', $manager->organization_id)->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->find($id) + : LearningPathVersion::query()->where('organization_id', $manager->organization_id)->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->find($id); + if (! $model) { + throw ValidationException::withMessages(['assignableId' => ['فقط محتوای منتشرشده و فعال قابل تخصیص است.']]); + } + + return $this->contentTitleFromModel($type, $model); + } + + private function contentTitle(Assignment $assignment): string + { + $model = $assignment->assignable_type === 'course' ? CourseVersion::query()->find($assignment->assignable_id) : LearningPathVersion::query()->find($assignment->assignable_id); + + return $model ? $this->contentTitleFromModel($assignment->assignable_type, $model) : 'محتوای یادگیری'; + } + + private function contentTitleFromModel(string $type, object $model): string + { + return $type === 'course' ? ($model->course()->value('title') ?? $model->title) : ($model->path()->value('title') ?? $model->title); + } + + private function scheduleReminders(Assignment $assignment, Collection $recipients, User $manager, string $title): int + { + if (! $assignment->due_at) { + return 0; + } + $config = $assignment->escalation_policy['notifications'] ?? []; + $moments = collect(); + if (! empty($config['reminderDays'])) { + $moments->push(['type' => 'learning.deadline_soon', 'at' => CarbonImmutable::parse($assignment->due_at)->subDays((int) $config['reminderDays'])]); + } + if ($config['reminderOnDeadline'] ?? false) { + $moments->push(['type' => 'learning.due_today', 'at' => CarbonImmutable::parse($assignment->due_at)]); + } + $scheduled = 0; + foreach ($moments->filter(fn (array $moment) => $moment['at']->isFuture()) as $moment) { + foreach ($recipients as $recipient) { + $key = 'scheduled:'.$moment['type'].':'.$assignment->getKey().':'.$recipient->getKey().':'.$moment['at']->timestamp; + $scheduled += (int) $this->notifications->schedule($recipient, $manager, [...$this->message($assignment, $title, $recipient, $moment['type'], 'یادآوری مهلت یادگیری', $key, false, 'deadlineReminders'), 'scheduledAt' => $moment['at'], 'condition' => ($config['onlyIfNotStarted'] ?? false) ? ['status' => 'not_started'] : []]); + } + } + + return $scheduled; + } + + /** @return array */ + private function message(Assignment $assignment, string $title, User $recipient, string $type, string $heading, string $key, bool $mandatory, string $preference): array + { + return ['type' => $type, 'title' => $heading, 'body' => '«'.$title.'» برای پیگیری در بخش یادگیری شما قرار دارد.', 'targetUrl' => '/learn/home', 'entityType' => 'assignment', 'entityId' => $assignment->getKey(), 'preferenceKey' => $preference, 'mandatory' => $mandatory, 'idempotencyKey' => $key.':'.$recipient->getKey()]; + } + + /** @return array */ + private function payload(Assignment $assignment, ?string $title = null): array + { + $assignment->loadMissing(['users:id,name']); + $config = $assignment->escalation_policy['notifications'] ?? []; + + return ['id' => $assignment->getKey(), 'assignableType' => $assignment->assignable_type, 'assignableId' => $assignment->assignable_id, + 'contentTitle' => $title ?? $this->contentTitle($assignment), 'status' => $assignment->status, 'mandatory' => $assignment->mandatory, + 'dueAt' => $assignment->due_at?->toISOString(), 'recipientCount' => $assignment->users->count(), + 'recipientIds' => $assignment->users->pluck('id')->values(), 'recipientNames' => $assignment->users->pluck('name')->take(3)->values(), + 'reminderDays' => $config['reminderDays'] ?? null, 'reminderOnDeadline' => (bool) ($config['reminderOnDeadline'] ?? false), + 'onlyIfNotStarted' => (bool) ($config['onlyIfNotStarted'] ?? false), 'createdAt' => $assignment->created_at?->toISOString()]; + } + + /** @param array $metadata */ + private function audit(Request $request, string $action, Assignment $assignment, array $metadata): void + { + DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'organization_id' => $assignment->organization_id, 'actor_id' => $request->user()->getKey(), + 'action' => $action, 'entity_type' => 'assignment', 'entity_id' => $assignment->getKey(), 'metadata' => json_encode(['schemaVersion' => 1, ...$metadata]), + 'ip_address' => $request->ip(), 'created_at' => now()]); + } + + private function authorize(User $manager, Permission $permission): void + { + abort_unless($this->permissions->allows($manager, $permission), 403); + } +} diff --git a/backend/app/Modules/Manager/Http/ManagerWorkspaceController.php b/backend/app/Modules/Manager/Http/ManagerWorkspaceController.php new file mode 100644 index 0000000..e3cf706 --- /dev/null +++ b/backend/app/Modules/Manager/Http/ManagerWorkspaceController.php @@ -0,0 +1,141 @@ +user(); + abort_unless($manager && $manager->role === UserRole::Manager, 403); + + $teamIds = DB::table('team_managers')->join('teams', 'teams.id', '=', 'team_managers.team_id') + ->where('team_managers.user_id', $manager->getKey())->where('teams.organization_id', $manager->organization_id)->pluck('teams.id'); + $memberIds = DB::table('team_memberships')->whereIn('team_id', $teamIds)->distinct()->pluck('user_id'); + $teams = Team::query()->where('organization_id', $manager->organization_id)->whereIn('id', $teamIds)->with('members:id,name')->withCount('members')->orderBy('name')->get(); + $members = User::query()->where('organization_id', $manager->organization_id)->whereIn('id', $memberIds) + ->get(['id', 'name', 'email', 'department', 'job_level', 'status']); + + $assignments = $memberIds->isEmpty() ? collect() : DB::table('assignment_users as au') + ->join('assignments as a', 'a.id', '=', 'au.assignment_id') + ->where('a.organization_id', $manager->organization_id)->where('a.status', 'active')->whereIn('au.user_id', $memberIds) + ->get(['a.id as assignmentId', 'a.assignable_type as assignableType', 'a.assignable_id as assignableId', 'a.mandatory', 'au.user_id as userId', 'au.status', 'au.progress', 'au.starts_at as startsAt', 'au.due_at as dueAt', 'au.completed_at as completedAt']); + $courseTitles = CourseVersion::query()->where('organization_id', $manager->organization_id)->whereIn('id', $assignments->where('assignableType', 'course')->pluck('assignableId'))->pluck('title', 'id'); + $pathTitles = LearningPathVersion::query()->where('organization_id', $manager->organization_id)->whereIn('id', $assignments->where('assignableType', 'learning_path')->pluck('assignableId'))->pluck('title', 'id'); + $lastActivity = $memberIds->isEmpty() ? collect() : DB::table('learning_events')->where('organization_id', $manager->organization_id)->whereIn('learner_id', $memberIds)->groupBy('learner_id')->pluck(DB::raw('max(occurred_at)'), 'learner_id'); + $activeLearners = $memberIds->isEmpty() ? 0 : DB::table('learning_events')->where('organization_id', $manager->organization_id)->whereIn('learner_id', $memberIds)->where('occurred_at', '>=', now()->subDays(7))->distinct()->count('learner_id'); + $attempts = $memberIds->isEmpty() ? collect() : DB::table('assessment_attempts')->where('organization_id', $manager->organization_id)->whereIn('learner_id', $memberIds)->where('status', 'completed')->get(['id', 'learner_id as learnerId', 'assessment_id as assessmentId', 'score', 'completed_at as completedAt']); + $assessmentTitles = Assessment::query()->where('organization_id', $manager->organization_id)->whereIn('id', $attempts->pluck('assessmentId'))->pluck('title', 'id'); + $recentEvents = $memberIds->isEmpty() ? collect() : DB::table('learning_events') + ->where('organization_id', $manager->organization_id) + ->whereIn('learner_id', $memberIds) + ->latest('occurred_at') + ->limit(8) + ->get(['id', 'learner_id as learnerId', 'event_type as eventType', 'course_version_id as courseVersionId', 'occurred_at as occurredAt']); + $recentCourseTitles = CourseVersion::query() + ->where('organization_id', $manager->organization_id) + ->whereIn('id', $recentEvents->pluck('courseVersionId')->filter()) + ->pluck('title', 'id'); + + $learning = $assignments->map(function ($row) use ($members, $courseTitles, $pathTitles) { + $due = $row->dueAt ? CarbonImmutable::parse($row->dueAt) : null; + $overdue = $due && $due->isPast() && $row->status !== 'completed'; + + return [ + 'assignmentId' => $row->assignmentId, 'userId' => $row->userId, + 'learner' => $members->firstWhere('id', $row->userId)?->name, + 'type' => $row->assignableType, 'title' => $row->assignableType === 'course' ? ($courseTitles[$row->assignableId] ?? 'دوره') : ($pathTitles[$row->assignableId] ?? 'مسیر یادگیری'), + 'mandatory' => (bool) $row->mandatory, 'status' => $row->status, 'progress' => (int) $row->progress, + 'startsAt' => $row->startsAt, 'dueAt' => $row->dueAt, 'completedAt' => $row->completedAt, 'overdue' => (bool) $overdue, + ]; + })->sortByDesc('overdue')->values(); + + $attention = $this->attention($members, $learning, $lastActivity); + $memberPayload = $members->map(function (User $member) use ($learning, $lastActivity, $attention) { + $rows = $learning->where('userId', $member->getKey()); + + return [ + 'id' => $member->getKey(), 'name' => $member->name, 'email' => $member->email, 'department' => $member->department, + 'jobLevel' => $member->job_level, 'status' => $member->status->value, + 'assignmentCount' => $rows->count(), 'completedCount' => $rows->where('status', 'completed')->count(), + 'averageProgress' => $rows->count() ? (int) round($rows->avg('progress')) : 0, + 'overdueCount' => $rows->where('overdue', true)->count(), 'lastActivityAt' => $lastActivity[$member->getKey()] ?? null, + 'needsAttention' => $attention->contains(fn ($item) => $item['userId'] === $member->getKey()), + ]; + })->sortBy('name')->values(); + + $completion = $learning->count() ? (int) round(($learning->where('status', 'completed')->count() / $learning->count()) * 100) : null; + $engagement = $members->count() ? (int) round(($activeLearners / $members->count()) * 100) : null; + $dueSoon = $learning->filter(fn (array $row) => $row['status'] !== 'completed' && $row['dueAt'] && CarbonImmutable::parse($row['dueAt'])->betweenIncluded(now(), now()->addDays(7)))->count(); + $assessmentAverage = $attempts->count() ? (int) round(((float) $attempts->avg('score')) * 100) : null; + $healthInputs = collect([$completion, $engagement, $assessmentAverage])->filter(fn ($value) => $value !== null); + $health = $healthInputs->count() ? (int) round($healthInputs->avg()) : null; + + return response()->json(['data' => [ + 'overview' => [ + 'teamCount' => $teams->count(), 'memberCount' => $members->count(), 'learningHealth' => $health, + 'completion' => $completion, 'engagement' => $engagement, 'assessment' => $assessmentAverage, + 'activeLearners' => $activeLearners, 'dueSoon' => $dueSoon, + 'overdue' => $learning->where('overdue', true)->count(), 'atRisk' => $attention->count(), + 'healthExplanation' => 'میانگین شاخص‌های موجودِ تکمیل، فعالیت هفت‌روزه و نتیجه ارزیابی؛ شاخص‌های فاقد داده از محاسبه حذف می‌شوند.', + ], + 'teams' => $teams->map(fn (Team $team) => ['id' => $team->getKey(), 'name' => $team->name, 'description' => $team->description, 'memberCount' => $team->members_count, 'memberIds' => $team->members->pluck('id')])->values(), + 'members' => $memberPayload, 'learning' => $learning, + 'courses' => $this->courseSummary($learning->where('type', 'course')), + 'assessments' => $attempts->map(fn ($attempt) => ['id' => $attempt->id, 'learnerId' => $attempt->learnerId, 'learner' => $members->firstWhere('id', $attempt->learnerId)?->name, 'assessmentId' => $attempt->assessmentId, 'title' => $assessmentTitles[$attempt->assessmentId] ?? 'ارزیابی', 'score' => (int) round(((float) $attempt->score) * 100), 'completedAt' => $attempt->completedAt])->values(), + 'attention' => $attention->values(), + 'reports' => $teams->map(fn (Team $team) => $this->teamReport($team, $learning))->values(), + 'notifications' => $attention->take(20)->map(fn ($item) => ['id' => $item['id'], 'severity' => $item['severity'], 'title' => $item['learner'], 'reasonCode' => $item['reasonCode'], 'course' => $item['course'], 'progress' => $item['progress'], 'dueAt' => $item['dueAt'], 'createdAt' => now()->toISOString()])->values(), + 'recentActivity' => $recentEvents->map(fn ($event) => [ + 'id' => $event->id, + 'userId' => $event->learnerId, + 'learner' => $members->firstWhere('id', $event->learnerId)?->name, + 'eventType' => $event->eventType, + 'course' => $event->courseVersionId ? ($recentCourseTitles[$event->courseVersionId] ?? null) : null, + 'occurredAt' => $event->occurredAt, + ])->values(), + 'definitions' => ['engagement' => 'درصد اعضایی که در هفت روز اخیر حداقل یک رویداد یادگیری ثبت کرده‌اند.', 'atRisk' => 'موعد گذشته، یا پیشرفت کمتر از ۵۰٪ در هفت روز مانده به موعد.'], + ]]); + } + + private function attention(Collection $members, Collection $learning, Collection $lastActivity): Collection + { + return $members->flatMap(function (User $member) use ($learning) { + $items = collect(); + foreach ($learning->where('userId', $member->getKey()) as $row) { + if ($row['overdue']) { + $items->push(['id' => $member->getKey().'-'.$row['assignmentId'].'-overdue', 'userId' => $member->getKey(), 'learner' => $member->name, 'severity' => 'danger', 'reasonCode' => 'overdue', 'reason' => 'موعد «'.$row['title'].'» گذشته و هنوز تکمیل نشده است.', 'course' => $row['title'], 'progress' => $row['progress'], 'dueAt' => $row['dueAt']]); + } elseif ($row['dueAt'] && CarbonImmutable::parse($row['dueAt'])->between(now(), now()->addDays(7)) && $row['progress'] < 50) { + $items->push(['id' => $member->getKey().'-'.$row['assignmentId'].'-due', 'userId' => $member->getKey(), 'learner' => $member->name, 'severity' => 'warning', 'reasonCode' => 'due_soon_low_progress', 'reason' => 'کمتر از هفت روز تا موعد «'.$row['title'].'» مانده و پیشرفت زیر ۵۰٪ است.', 'course' => $row['title'], 'progress' => $row['progress'], 'dueAt' => $row['dueAt']]); + } + } + + return $items; + })->unique('id')->values(); + } + + private function courseSummary(Collection $rows): Collection + { + return $rows->groupBy('title')->map(fn (Collection $items, string $title) => ['title' => $title, 'learnerCount' => $items->pluck('userId')->unique()->count(), 'completion' => $items->count() ? (int) round(($items->where('status', 'completed')->count() / $items->count()) * 100) : 0, 'averageProgress' => $items->count() ? (int) round($items->avg('progress')) : 0, 'overdue' => $items->where('overdue', true)->count()])->values(); + } + + private function teamReport(Team $team, Collection $learning): array + { + $rows = $learning->whereIn('userId', $team->members->pluck('id')); + + return ['teamId' => $team->getKey(), 'team' => $team->name, 'members' => $team->members_count, 'assignments' => $rows->count(), 'completion' => $rows->count() ? (int) round(($rows->where('status', 'completed')->count() / $rows->count()) * 100) : 0, 'averageProgress' => $rows->count() ? (int) round($rows->avg('progress')) : 0, 'overdue' => $rows->where('overdue', true)->count()]; + } +} diff --git a/backend/app/Modules/Monitoring/Application/MonitoringEngine.php b/backend/app/Modules/Monitoring/Application/MonitoringEngine.php new file mode 100644 index 0000000..2e91786 --- /dev/null +++ b/backend/app/Modules/Monitoring/Application/MonitoringEngine.php @@ -0,0 +1,128 @@ +where('organization_id', $organizationId)->where('role', UserRole::Learner)->pluck('id'); + $assignments = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id') + ->where('a.organization_id', $organizationId)->where('a.status', 'active')->whereIn('au.user_id', $learners) + ->get(['au.user_id as userId', 'au.progress', 'au.status', 'au.due_at as dueAt']); + $lastActivity = DB::table('learning_events')->where('organization_id', $organizationId)->whereIn('learner_id', $learners) + ->groupBy('learner_id')->pluck(DB::raw('max(occurred_at)'), 'learner_id'); + $attempts = DB::table('assessment_attempts')->where('organization_id', $organizationId)->whereIn('learner_id', $learners) + ->where('status', 'completed')->get(['learner_id as learnerId', 'score']); + $now = CarbonImmutable::now(); + + DB::table('monitoring_insights')->where('organization_id', $organizationId)->where('type', 'learner_risk')->delete(); + foreach ($learners as $learnerId) { + $rows = $assignments->where('userId', $learnerId); + $learnerAttempts = $attempts->where('learnerId', $learnerId); + $daysInactive = isset($lastActivity[$learnerId]) ? CarbonImmutable::parse($lastActivity[$learnerId])->diffInDays($now) : ($rows->isEmpty() ? 0 : 30); + $overdue = $rows->filter(fn ($row) => $row->dueAt && CarbonImmutable::parse($row->dueAt)->isPast() && $row->status !== 'completed')->count(); + $dueSoon = $rows->filter(fn ($row) => $row->dueAt && CarbonImmutable::parse($row->dueAt)->between($now, $now->addDays((int) config('monitoring.due_soon_days'))) && $row->status !== 'completed')->count(); + $averageProgress = $rows->isEmpty() ? 100 : (float) $rows->avg('progress'); + $failed = $learnerAttempts->filter(fn ($attempt) => (float) $attempt->score < .7)->count(); + $result = $this->riskProvider->calculate([ + 'daysInactive' => $daysInactive, 'overdue' => $overdue, 'dueSoon' => $dueSoon, + 'averageProgress' => $averageProgress, 'failed' => $failed, 'attemptCount' => $learnerAttempts->count(), + ]); + ['score' => $score, 'level' => $level, 'factors' => $factors] = $result; + + DB::table('monitoring_risks')->updateOrInsert( + ['organization_id' => $organizationId, 'learner_id' => $learnerId], + ['id' => (string) str()->ulid(), 'score' => $score, 'level' => $level, 'provider' => $result['provider'], 'provider_version' => $result['version'], 'factors' => json_encode($factors, JSON_THROW_ON_ERROR), 'calculated_at' => now(), 'created_at' => now(), 'updated_at' => now()], + ); + + if ($level !== 'low') { + $evidence = ['riskScore' => $score, 'factors' => $factors, 'provider' => $result['provider'], 'providerVersion' => $result['version']]; + DB::table('monitoring_insights')->insert([ + 'id' => (string) str()->ulid(), 'organization_id' => $organizationId, 'fingerprint' => 'learner-risk-'.$learnerId, + 'type' => 'learner_risk', 'severity' => $level, 'entity_type' => 'learner', 'entity_id' => $learnerId, + 'reason' => $result['reason'], 'evidence' => json_encode($evidence, JSON_THROW_ON_ERROR), 'trend' => null, + 'suggested_action' => 'Review assignments and contact the learner', 'drilldown' => '/app/monitoring/learners?learner='.$learnerId, + 'status' => 'open', 'detected_at' => now(), 'created_at' => now(), 'updated_at' => now(), + ]); + } + } + + $this->refreshCapabilityInsights($organizationId); + $this->refreshPerformanceInsights($organizationId); + + return $learners->count(); + } + + private function refreshCapabilityInsights(string $organizationId): void + { + DB::table('monitoring_insights')->where('organization_id', $organizationId)->whereIn('type', ['capability_gap', 'low_evidence_confidence', 'content_coverage_gap'])->delete(); + $scores = DB::table('capability_scores as cs')->join('taxonomy_nodes as tn', 'tn.id', '=', 'cs.taxonomy_node_id') + ->where('cs.organization_id', $organizationId)->groupBy('cs.taxonomy_node_id', 'tn.name') + ->get(['cs.taxonomy_node_id as nodeId', 'tn.name', DB::raw('avg(cs.score) as score'), DB::raw('avg(cs.confidence) as confidence'), DB::raw('sum(cs.evidence_count) as evidenceCount'), DB::raw('avg(cs.trend) as trend')]); + foreach ($scores as $score) { + $confidence = (float) $score->confidence; + if ($confidence < .5) { + $this->insertInsight($organizationId, 'confidence-'.$score->nodeId, 'low_evidence_confidence', 'medium', 'taxonomy_node', $score->nodeId, 'Evidence confidence for '.$score->name.' is below 50%; the score should be interpreted cautiously.', ['confidence' => round($confidence, 4), 'evidenceCount' => (int) $score->evidenceCount], $score->trend, 'Collect additional confirmed evidence', '/app/monitoring/skills?skill='.$score->nodeId); + } + if ($score->score !== null && (float) $score->score < 60 && $confidence >= .5) { + $this->insertInsight($organizationId, 'capability-gap-'.$score->nodeId, 'capability_gap', 'medium', 'taxonomy_node', $score->nodeId, 'The evidence-backed capability score for '.$score->name.' is below 60.', ['score' => round((float) $score->score, 2), 'confidence' => round($confidence, 4), 'evidenceCount' => (int) $score->evidenceCount], $score->trend, 'Review learning coverage and assign targeted practice', '/app/monitoring/skills?skill='.$score->nodeId); + } + } + + $uncovered = DB::table('taxonomy_nodes as tn')->where('tn.organization_id', $organizationId)->where('tn.status', 'active') + ->whereNotExists(fn ($query) => $query->selectRaw('1')->from('content_taxonomy_mappings as ctm')->whereColumn('ctm.taxonomy_node_id', 'tn.id')->where('ctm.confirmation_status', 'confirmed')) + ->get(['tn.id', 'tn.name']); + foreach ($uncovered as $node) { + $this->insertInsight($organizationId, 'coverage-'.$node->id, 'content_coverage_gap', 'low', 'taxonomy_node', $node->id, 'No confirmed learning content currently covers '.$node->name.'.', ['confirmedMappings' => 0], null, 'Map and confirm relevant learning content', '/app/monitoring/skills?skill='.$node->id); + } + } + + private function refreshPerformanceInsights(string $organizationId): void + { + DB::table('monitoring_insights')->where('organization_id', $organizationId)->whereIn('type', ['engagement_decline', 'course_drop_off', 'assessment_difficulty'])->delete(); + $currentActive = DB::table('learning_events')->where('organization_id', $organizationId)->whereBetween('occurred_at', [now()->subDays(7), now()])->distinct()->count('learner_id'); + $previousActive = DB::table('learning_events')->where('organization_id', $organizationId)->whereBetween('occurred_at', [now()->subDays(14), now()->subDays(7)])->distinct()->count('learner_id'); + $engagementChange = $previousActive > 0 ? round(($currentActive - $previousActive) / $previousActive * 100) : null; + if ($previousActive >= 3 && $engagementChange <= -30) { + $this->insertInsight($organizationId, 'engagement-decline', 'engagement_decline', 'medium', 'organization', $organizationId, 'Active learner engagement declined by at least 30% versus the preceding seven-day period.', compact('currentActive', 'previousActive', 'engagementChange'), $engagementChange, 'Review recent assignments and learning access barriers', '/app/monitoring/engagement'); + } + $courseEvents = DB::table('learning_events')->where('organization_id', $organizationId)->whereNotNull('course_version_id')->whereIn('event_type', ['course.opened', 'course.completed'])->get()->groupBy('course_version_id'); + foreach ($courseEvents as $courseId => $events) { + $starts = $events->where('event_type', 'course.opened')->pluck('learner_id')->unique()->count(); + $completions = $events->where('event_type', 'course.completed')->pluck('learner_id')->unique()->count(); + $dropOff = $starts > 0 ? round((1 - min($starts, $completions) / $starts) * 100) : null; + if ($starts >= 3 && $dropOff >= 50) { + $this->insertInsight($organizationId, 'course-dropoff-'.$courseId, 'course_drop_off', 'medium', 'course', $courseId, 'At least half of observed course starters have no completion event.', compact('starts', 'completions', 'dropOff'), null, 'Review the course path and completion barriers', '/app/monitoring/courses?course='.$courseId); + } + } + + $attempts = DB::table('assessment_attempts')->where('organization_id', $organizationId)->where('status', 'completed')->get()->groupBy('assessment_id'); + foreach ($attempts as $assessmentId => $rows) { + $attemptCount = $rows->count(); + $passRate = $attemptCount ? round($rows->where('score', '>=', .7)->count() / $attemptCount * 100) : null; + if ($attemptCount >= 3 && $passRate < 50) { + $this->insertInsight($organizationId, 'assessment-difficulty-'.$assessmentId, 'assessment_difficulty', 'medium', 'assessment', $assessmentId, 'Fewer than half of completed attempts meet the configured 70% review threshold.', compact('attemptCount', 'passRate'), null, 'Review difficult questions and learning alignment', '/app/monitoring/assessments?assessment='.$assessmentId); + } + } + } + + /** @param array $evidence */ + private function insertInsight(string $organizationId, string $fingerprint, string $type, string $severity, string $entityType, ?string $entityId, string $reason, array $evidence, mixed $trend, string $action, string $drilldown): void + { + DB::table('monitoring_insights')->insert([ + 'id' => (string) str()->ulid(), 'organization_id' => $organizationId, 'fingerprint' => $fingerprint, + 'type' => $type, 'severity' => $severity, 'entity_type' => $entityType, 'entity_id' => $entityId, + 'reason' => $reason, 'evidence' => json_encode($evidence, JSON_THROW_ON_ERROR), 'trend' => $trend, + 'suggested_action' => $action, 'drilldown' => $drilldown, 'status' => 'open', 'detected_at' => now(), 'created_at' => now(), 'updated_at' => now(), + ]); + } +} diff --git a/backend/app/Modules/Monitoring/Domain/RiskProvider.php b/backend/app/Modules/Monitoring/Domain/RiskProvider.php new file mode 100644 index 0000000..8a7a835 --- /dev/null +++ b/backend/app/Modules/Monitoring/Domain/RiskProvider.php @@ -0,0 +1,11 @@ + $signals + * @return array{score: int, level: string, factors: array, reason: string, provider: string, version: string} + */ + public function calculate(array $signals): array; +} diff --git a/backend/app/Modules/Monitoring/Http/MonitoringController.php b/backend/app/Modules/Monitoring/Http/MonitoringController.php new file mode 100644 index 0000000..b93f83f --- /dev/null +++ b/backend/app/Modules/Monitoring/Http/MonitoringController.php @@ -0,0 +1,307 @@ +user(); + abort_unless($user && $permissions->allows($user, Permission::OrganizationAnalyticsView), 403); + $filters = $request->validate([ + 'from' => ['nullable', 'date'], 'to' => ['nullable', 'date', 'after_or_equal:from'], + 'team' => ['nullable', 'string'], 'course' => ['nullable', 'string'], 'status' => ['nullable', 'in:all,active,inactive,at_risk'], + 'skill' => ['nullable', 'string'], 'evidenceType' => ['nullable', 'string', 'max:80'], + 'confidence' => ['nullable', 'in:insufficient,low,medium,high'], + 'search' => ['nullable', 'string', 'max:120'], 'learnerStatus' => ['nullable', 'in:invited,not_started,learning,completed,overdue,stopped,follow_up'], + 'progress' => ['nullable', 'in:not_started,low,medium,high,completed'], 'sort' => ['nullable', 'in:name,progress,completion,score,last_activity'], + 'direction' => ['nullable', 'in:asc,desc'], 'page' => ['nullable', 'integer', 'min:1'], 'pageSize' => ['nullable', 'integer', 'in:10,25,50,100'], + ]); + $from = CarbonImmutable::parse($filters['from'] ?? now()->subDays(29)->toDateString())->startOfDay(); + $to = CarbonImmutable::parse($filters['to'] ?? now()->toDateString())->endOfDay(); + abort_if($from->diffInDays($to) > 366, 422, 'Date range cannot exceed 366 days.'); + $engine->refresh($user->organization_id); + + $learnerQuery = User::query()->where('users.organization_id', $user->organization_id)->where('users.role', 'learner'); + if (! empty($filters['course'])) { + abort_unless(CourseVersion::query()->where('organization_id', $user->organization_id)->whereKey($filters['course'])->exists(), 404); + $learnerQuery->whereIn('users.id', DB::table('assignment_users as scoped_au') + ->join('assignments as scoped_a', 'scoped_a.id', '=', 'scoped_au.assignment_id') + ->where('scoped_a.organization_id', $user->organization_id) + ->where('scoped_a.assignable_type', 'course') + ->where('scoped_a.assignable_id', $filters['course']) + ->select('scoped_au.user_id')); + } + if (! empty($filters['team'])) { + $teamBelongs = DB::table('teams')->where('organization_id', $user->organization_id)->where('id', $filters['team'])->exists(); + abort_unless($teamBelongs, 404); + $learnerQuery->whereIn('users.id', DB::table('team_memberships')->where('team_id', $filters['team'])->select('user_id')); + } + if (($filters['status'] ?? 'all') === 'at_risk') { + $learnerQuery->whereIn('users.id', DB::table('monitoring_risks')->where('organization_id', $user->organization_id)->whereIn('level', ['high', 'medium'])->select('learner_id')); + } + if (! empty($filters['skill'])) { + abort_unless(DB::table('taxonomy_nodes')->where('organization_id', $user->organization_id)->where('id', $filters['skill'])->exists(), 404); + $learnerQuery->whereIn('users.id', DB::table('capability_scores')->where('organization_id', $user->organization_id)->where('taxonomy_node_id', $filters['skill'])->select('learner_id')); + } + if (! empty($filters['confidence'])) { + $learnerQuery->whereIn('users.id', DB::table('capability_scores')->where('organization_id', $user->organization_id)->where('confidence_level', $filters['confidence'])->select('learner_id')); + } + if (! empty($filters['evidenceType'])) { + $learnerQuery->whereIn('users.id', DB::table('evidence_records')->where('organization_id', $user->organization_id)->where('evidence_type', $filters['evidenceType'])->select('learner_id')); + } + $learners = $learnerQuery->get(['users.id', 'users.name', 'users.email', 'users.department']); + $learnerIds = $learners->pluck('id'); + $learnerTeams = DB::table('team_memberships as tm')->join('teams as t', 't.id', '=', 'tm.team_id') + ->where('t.organization_id', $user->organization_id)->whereIn('tm.user_id', $learnerIds) + ->get(['tm.user_id as userId', 't.id', 't.name'])->groupBy('userId'); + + $eventsQuery = LearningEvent::query()->where('organization_id', $user->organization_id)->whereIn('learner_id', $learnerIds)->whereBetween('occurred_at', [$from, $to]); + if (! empty($filters['course'])) { + $eventsQuery->where('course_version_id', $filters['course']); + } + $events = $eventsQuery->orderBy('occurred_at')->get(); + $assignments = DB::table('assignment_users as au')->join('assignments as a', 'a.id', '=', 'au.assignment_id') + ->where('a.organization_id', $user->organization_id)->whereIn('au.user_id', $learnerIds) + ->when(! empty($filters['course']), fn ($query) => $query->where('a.assignable_type', 'course')->where('a.assignable_id', $filters['course'])) + ->get(['au.user_id as userId', 'au.status', 'au.progress', 'au.assigned_at as assignedAt', 'au.due_at as dueAt', 'au.completed_at as completedAt', 'a.assignable_type as type', 'a.assignable_id as assignableId']); + $risks = DB::table('monitoring_risks')->where('organization_id', $user->organization_id)->whereIn('learner_id', $learnerIds)->get()->map(function ($risk) { + $risk->factors = json_decode($risk->factors, true); + + return $risk; + }); + $attempts = DB::table('assessment_attempts')->where('organization_id', $user->organization_id)->whereIn('learner_id', $learnerIds)->where('status', 'completed')->whereBetween('completed_at', [$from, $to])->get(); + $hasCourseAssessment = ! empty($filters['course']) && DB::table('assessments')->where('organization_id', $user->organization_id)->where('course_version_id', $filters['course'])->exists(); + + $allLearnerRows = collect($this->learners($learners, $assignments, $events, $risks, $attempts, $learnerTeams, $hasCourseAssessment)); + $visibleLearnerRows = $allLearnerRows; + if (! empty($filters['search'])) { + $needle = mb_strtolower(trim($filters['search'])); + $visibleLearnerRows = $visibleLearnerRows->filter(fn (array $row) => str_contains(mb_strtolower($row['name'].' '.$row['email'].' '.($row['department'] ?? '')), $needle)); + } + if (! empty($filters['learnerStatus'])) { + $visibleLearnerRows = $visibleLearnerRows->filter(fn (array $row) => $filters['learnerStatus'] === 'follow_up' ? $row['needsFollowUp'] : $row['status'] === $filters['learnerStatus']); + } + if (! empty($filters['progress'])) { + $visibleLearnerRows = $visibleLearnerRows->filter(fn (array $row) => match ($filters['progress']) { + 'not_started' => $row['progress'] === 0, 'low' => $row['progress'] > 0 && $row['progress'] < 40, + 'medium' => $row['progress'] >= 40 && $row['progress'] < 75, 'high' => $row['progress'] >= 75 && $row['progress'] < 100, + 'completed' => $row['progress'] >= 100, + }); + } + $sortKey = match ($filters['sort'] ?? 'name') { + 'completion' => 'completion', 'score' => 'assessmentScore', 'last_activity' => 'lastActivityAt', 'progress' => 'progress', default => 'name' + }; + $visibleLearnerRows = ($filters['direction'] ?? 'asc') === 'desc' ? $visibleLearnerRows->sortByDesc($sortKey) : $visibleLearnerRows->sortBy($sortKey); + $learnerTotal = $visibleLearnerRows->count(); + $pageSize = (int) ($filters['pageSize'] ?? max(1, $learnerTotal)); + $page = min((int) ($filters['page'] ?? 1), max(1, (int) ceil($learnerTotal / $pageSize))); + if (isset($filters['page'])) { + $visibleLearnerRows = $visibleLearnerRows->forPage($page, $pageSize); + } + + return response()->json(['data' => [ + 'range' => ['from' => $from->toDateString(), 'to' => $to->toDateString()], + 'filters' => $this->filterOptions($user->organization_id), + 'overview' => $this->overview($learners, $events, $assignments, $attempts, $risks), + 'trend' => $this->trend($events, $from, $to), + 'courses' => $this->courses($user->organization_id, $assignments, $events), + 'content' => $this->contentDrilldown($events), + 'teams' => $this->teams($user->organization_id, $assignments, $events, $risks), + 'learners' => $visibleLearnerRows->values()->all(), + 'learnerSummary' => ['total' => $allLearnerRows->count(), 'learning' => $allLearnerRows->where('status', 'learning')->count(), 'completed' => $allLearnerRows->where('status', 'completed')->count(), 'followUp' => $allLearnerRows->where('needsFollowUp', true)->count(), 'invited' => $allLearnerRows->where('status', 'invited')->count()], + 'learnerMeta' => ['page' => $page, 'pageSize' => $pageSize, 'total' => $learnerTotal, 'lastPage' => max(1, (int) ceil($learnerTotal / $pageSize))], + 'assessments' => $this->assessments($attempts), + 'video' => $this->video($events), + 'skills' => $this->skills($user->organization_id, $learnerIds, $filters['skill'] ?? null), + 'attention' => $this->attention($user->organization_id, $learnerIds), + 'definitions' => [ + 'health' => 'Weighted average of available completion, engagement, assessment, on-time completion, and inverse risk factors.', + 'engagement' => 'Learners with at least one meaningful learning event during the selected range.', + 'risk' => 'Explainable heuristic based on inactivity, deadline, progress, and assessment factors; it is not an ML probability.', + ], + ]]); + } + + public function rebuild(Request $request, RolePermissions $permissions, AnalyticsProjectionService $analytics, MonitoringEngine $monitoring): JsonResponse + { + $user = $request->user(); + abort_unless($user && $permissions->allows($user, Permission::OrganizationAnalyticsView), 403); + $events = $analytics->rebuild($user->organization_id); + $learners = $monitoring->refresh($user->organization_id); + + return response()->json(['data' => ['eventsProcessed' => $events, 'learnersRecalculated' => $learners, 'rebuiltAt' => now()->toISOString()]]); + } + + private function overview(Collection $learners, Collection $events, Collection $assignments, Collection $attempts, Collection $risks): array + { + $completion = $assignments->isEmpty() ? null : round($assignments->where('status', 'completed')->count() / $assignments->count() * 100); + $engagement = $learners->isEmpty() ? null : round($events->pluck('learner_id')->unique()->count() / $learners->count() * 100); + $assessment = $attempts->isEmpty() ? null : round((float) $attempts->avg('score') * 100); + $withDue = $assignments->filter(fn ($row) => $row->dueAt && $row->completedAt); + $onTime = $withDue->isEmpty() ? null : round($withDue->filter(fn ($row) => CarbonImmutable::parse($row->completedAt)->lte(CarbonImmutable::parse($row->dueAt)))->count() / $withDue->count() * 100); + $averageRisk = $risks->isEmpty() ? null : round((float) $risks->avg('score')); + $factors = ['completion' => $completion, 'engagement' => $engagement, 'assessment' => $assessment, 'on_time' => $onTime, 'inverse_risk' => $averageRisk === null ? null : 100 - $averageRisk]; + $available = collect($factors)->filter(fn ($value) => $value !== null); + $weights = config('monitoring.health_weights'); + $weightTotal = $available->keys()->sum(fn ($key) => $weights[$key]); + $weightedTotal = $available->reduce(fn (float $sum, $value, $key) => $sum + $value * $weights[$key], 0.0); + $health = $weightTotal > 0 ? round($weightedTotal / $weightTotal) : null; + + return ['learningHealth' => $health, 'factors' => $factors, 'weights' => $weights, 'completion' => $completion, 'engagement' => $engagement, 'assessment' => $assessment, 'onTime' => $onTime, 'averageRisk' => $averageRisk, 'activeLearners' => $events->pluck('learner_id')->unique()->count(), 'learningMinutes' => round($events->sum(fn ($event) => min(21600, max(0, (int) ($event->payload['durationSeconds'] ?? 0)))) / 60, 1), 'atRisk' => $risks->whereIn('level', ['high', 'medium'])->count()]; + } + + private function trend(Collection $events, CarbonImmutable $from, CarbonImmutable $to): array + { + $byDate = $events->groupBy(fn ($event) => $event->occurred_at->toDateString()); + + return collect(range(0, $from->diffInDays($to)))->map(function (int $offset) use ($from, $byDate) { + $date = $from->addDays($offset)->toDateString(); + $rows = $byDate->get($date, collect()); + + return ['date' => $date, 'activeLearners' => $rows->pluck('learner_id')->unique()->count(), 'events' => $rows->count(), 'completions' => $rows->whereIn('event_type', ['lesson.completed', 'course.completed'])->count()]; + })->all(); + } + + private function courses(string $organizationId, Collection $assignments, Collection $events): array + { + $courseIds = $assignments->where('type', 'course')->pluck('assignableId')->merge($events->pluck('course_version_id'))->filter()->unique(); + $titles = CourseVersion::query()->where('organization_id', $organizationId)->whereIn('id', $courseIds)->pluck('title', 'id'); + + return $courseIds->map(function ($id) use ($titles, $assignments, $events) { + $rows = $assignments->where('assignableId', $id); + $courseEvents = $events->where('course_version_id', $id); + $started = $courseEvents->where('event_type', 'course.opened')->count(); + $completedEvents = $courseEvents->where('event_type', 'course.completed')->count(); + + return ['id' => $id, 'title' => $titles[$id] ?? 'Course', 'learners' => $rows->pluck('userId')->unique()->count(), 'completion' => $rows->isEmpty() ? 0 : round($rows->where('status', 'completed')->count() / $rows->count() * 100), 'averageProgress' => $rows->isEmpty() ? 0 : round((float) $rows->avg('progress')), 'starts' => $started, 'dropOff' => $started > 0 ? round((1 - min($started, $completedEvents) / $started) * 100) : null]; + })->values()->all(); + } + + private function learners(Collection $learners, Collection $assignments, Collection $events, Collection $risks, Collection $attempts, Collection $teams, bool $hasCourseAssessment): array + { + return $learners->map(function (User $learner) use ($assignments, $events, $risks, $attempts, $teams, $hasCourseAssessment) { + $rows = $assignments->where('userId', $learner->getKey()); + $activity = $events->where('learner_id', $learner->getKey()); + $learnerAttempts = $attempts->where('learner_id', $learner->getKey()); + $risk = $risks->firstWhere('learner_id', $learner->getKey()); + + $velocity = $rows->isEmpty() ? null : round((float) $rows->avg(fn ($row) => (float) $row->progress / max(1, CarbonImmutable::parse($row->assignedAt)->diffInDays(now()))), 1); + $lastActivityAt = $activity->max('occurred_at'); + $progress = $rows->isEmpty() ? 0 : round((float) $rows->avg('progress')); + $completion = $rows->isEmpty() ? 0 : round($rows->where('status', 'completed')->count() / $rows->count() * 100); + $overdueItems = $rows->filter(fn ($row) => $row->dueAt && CarbonImmutable::parse($row->dueAt)->isPast() && $row->status !== 'completed')->count(); + $status = $rows->isEmpty() ? 'invited' : ($rows->every(fn ($row) => $row->status === 'cancelled') ? 'stopped' : ($overdueItems > 0 ? 'overdue' : ($completion === 100 ? 'completed' : (($progress > 0 || $activity->isNotEmpty()) ? 'learning' : 'not_started')))); + + return ['id' => $learner->getKey(), 'name' => $learner->name, 'email' => $learner->email, 'department' => $learner->department, + 'teams' => $teams->get($learner->getKey(), collect())->map(fn ($team) => ['id' => $team->id, 'name' => $team->name])->values()->all(), + 'status' => $status, 'needsFollowUp' => $risk && in_array($risk->level, ['high', 'medium'], true), 'progress' => $progress, 'completion' => $completion, + 'progressVelocityPerDay' => $velocity, 'inactivityDays' => $lastActivityAt ? CarbonImmutable::parse($lastActivityAt)->diffInDays(now()) : null, + 'completed' => $rows->where('status', 'completed')->count(), 'assignments' => $rows->count(), 'events' => $activity->count(), + 'assignedAt' => $rows->min('assignedAt'), 'startedAt' => $activity->min('occurred_at')?->toISOString(), 'dueAt' => $rows->min('dueAt'), + 'lastActivityAt' => $lastActivityAt?->toISOString(), 'hasAssessment' => $hasCourseAssessment, 'assessmentScore' => $learnerAttempts->isEmpty() ? null : round((float) $learnerAttempts->avg('score') * 100), + 'attempts' => $learnerAttempts->count(), 'totalTimeMinutes' => round($activity->sum(fn ($event) => min(21600, max(0, (int) ($event->payload['durationSeconds'] ?? 0)))) / 60), + 'overdueItems' => $overdueItems, 'risk' => $risk ? ['score' => $risk->score, 'level' => $risk->level, 'factors' => $risk->factors, 'provider' => $risk->provider] : null]; + })->values()->all(); + } + + private function contentDrilldown(Collection $events): array + { + $blockIds = $events->pluck('block_id')->filter()->unique(); + $blocks = DB::table('blocks as b')->join('lessons as l', 'l.id', '=', 'b.lesson_id')->join('course_modules as m', 'm.id', '=', 'l.course_module_id')->join('course_versions as cv', 'cv.id', '=', 'b.course_version_id') + ->whereIn('b.id', $blockIds)->get(['cv.id as courseId', 'cv.title as courseTitle', 'm.id as moduleId', 'm.title as moduleTitle', 'l.id as lessonId', 'l.title as lessonTitle', 'b.id as blockId', 'b.type as blockType']); + + return $blocks->map(function ($block) use ($events) { + $rows = $events->where('block_id', $block->blockId); + + return ['courseId' => $block->courseId, 'courseTitle' => $block->courseTitle, 'moduleId' => $block->moduleId, 'moduleTitle' => $block->moduleTitle, 'lessonId' => $block->lessonId, 'lessonTitle' => $block->lessonTitle, 'blockId' => $block->blockId, 'blockType' => $block->blockType, 'views' => $rows->where('event_type', 'block.viewed')->count(), 'interactions' => $rows->where('event_type', 'block.interacted')->count(), 'completions' => $rows->where('event_type', 'block.completed')->count(), 'drilldown' => '/app/monitoring/courses?course='.$block->courseId.'&module='.$block->moduleId.'&lesson='.$block->lessonId.'&block='.$block->blockId]; + })->values()->all(); + } + + private function teams(string $organizationId, Collection $assignments, Collection $events, Collection $risks): array + { + return DB::table('teams')->where('organization_id', $organizationId)->orderBy('name')->get(['id', 'name'])->map(function ($team) use ($assignments, $events, $risks) { + $members = DB::table('team_memberships')->where('team_id', $team->id)->pluck('user_id'); + $rows = $assignments->whereIn('userId', $members); + $teamEvents = $events->whereIn('learner_id', $members); + $teamRisks = $risks->whereIn('learner_id', $members); + + return ['id' => $team->id, 'name' => $team->name, 'members' => $members->count(), 'completion' => $rows->isEmpty() ? null : round($rows->where('status', 'completed')->count() / $rows->count() * 100), 'engagement' => $members->isEmpty() ? null : round($teamEvents->pluck('learner_id')->unique()->count() / $members->count() * 100), 'averageRisk' => $teamRisks->isEmpty() ? null : round((float) $teamRisks->avg('score'))]; + })->all(); + } + + private function assessments(Collection $attempts): array + { + return $attempts->groupBy('assessment_id')->map(function (Collection $rows, string $id) { + $results = DB::table('question_results as qr')->join('questions as q', 'q.id', '=', 'qr.question_id')->whereIn('qr.assessment_attempt_id', $rows->pluck('id'))->get(['q.id', 'q.prompt', 'qr.raw_value as rawValue', 'qr.is_correct as isCorrect']); + $questions = $results->groupBy('id')->map(function (Collection $questionRows) { + $wrong = $questionRows->where('isCorrect', false); + $commonWrong = $wrong->groupBy('rawValue')->map->count()->sortDesc()->take(3)->map(fn ($count, $answer) => ['answer' => json_decode($answer, true), 'count' => $count])->values(); + + return ['id' => $questionRows->first()->id, 'prompt' => $questionRows->first()->prompt, 'responses' => $questionRows->count(), 'difficulty' => round($wrong->count() / $questionRows->count() * 100), 'commonWrongAnswers' => $commonWrong]; + })->values(); + + $teamComparison = DB::table('team_memberships as tm')->join('teams as t', 't.id', '=', 'tm.team_id')->whereIn('tm.user_id', $rows->pluck('learner_id'))->get(['t.id', 't.name', 'tm.user_id as userId'])->map(function ($team) use ($rows) { + $teamRows = $rows->where('learner_id', $team->userId); + + return ['id' => $team->id, 'name' => $team->name, 'attempts' => $teamRows->count(), 'averageScore' => round((float) $teamRows->avg('score') * 100), 'passRate' => round($teamRows->where('score', '>=', .7)->count() / max(1, $teamRows->count()) * 100)]; + })->groupBy('id')->map(function (Collection $teamRows) { + return ['id' => $teamRows->first()['id'], 'name' => $teamRows->first()['name'], 'attempts' => $teamRows->sum('attempts'), 'averageScore' => round((float) $teamRows->avg('averageScore')), 'passRate' => round((float) $teamRows->avg('passRate'))]; + })->values(); + + return ['id' => $id, 'title' => DB::table('assessments')->where('id', $id)->value('title') ?? 'Assessment', 'attempts' => $rows->count(), 'averageScore' => round((float) $rows->avg('score') * 100), 'passRate' => round($rows->where('score', '>=', .7)->count() / $rows->count() * 100), 'questions' => $questions, 'teamComparison' => $teamComparison]; + })->values()->all(); + } + + private function video(Collection $events): array + { + return $events->whereIn('event_type', ['video.started', 'video.progressed', 'video.completed', 'video.replayed', 'video.skipped', 'video.exited'])->groupBy('block_id')->map(function (Collection $rows, string $blockId) { + $starts = $rows->where('event_type', 'video.started')->count(); + $completed = $rows->where('event_type', 'video.completed')->count(); + $milestones = fn (int $point) => $rows->where('event_type', 'video.progressed')->filter(fn ($event) => (int) ($event->payload['progressPercent'] ?? 0) >= $point)->pluck('learner_id')->unique()->count(); + + return ['blockId' => $blockId, 'starts' => $starts, 'reached25' => $milestones(25), 'reached50' => $milestones(50), 'reached75' => $milestones(75), 'completed' => $completed, 'completionRate' => $starts ? round(min($starts, $completed) / $starts * 100) : null, 'averageWatchSeconds' => round((float) $rows->whereIn('event_type', ['video.exited', 'video.completed'])->avg(fn ($event) => (int) ($event->payload['positionSeconds'] ?? 0))), 'exits' => $rows->where('event_type', 'video.exited')->count(), 'replays' => $rows->where('event_type', 'video.replayed')->count(), 'skips' => $rows->where('event_type', 'video.skipped')->count()]; + })->values()->all(); + } + + private function skills(string $organizationId, Collection $learnerIds, ?string $skillId): array + { + return DB::table('capability_scores as cs')->join('taxonomy_nodes as tn', 'tn.id', '=', 'cs.taxonomy_node_id')->where('cs.organization_id', $organizationId)->whereIn('cs.learner_id', $learnerIds) + ->when($skillId, fn ($query) => $query->where('tn.id', $skillId)) + ->select('tn.id', 'tn.name', DB::raw('avg(cs.score) as score'), DB::raw('avg(cs.confidence) as confidence'), DB::raw('count(*) as learners'))->groupBy('tn.id', 'tn.name')->get()->map(fn ($row) => ['id' => $row->id, 'name' => $row->name, 'score' => $row->score === null ? null : round((float) $row->score), 'confidence' => round((float) $row->confidence * 100), 'learners' => (int) $row->learners])->all(); + } + + private function attention(string $organizationId, Collection $learnerIds): array + { + return DB::table('monitoring_insights')->where('organization_id', $organizationId)->where('status', 'open')->where(function ($query) use ($learnerIds) { + $query->where('entity_type', '!=', 'learner')->orWhereIn('entity_id', $learnerIds); + })->orderByRaw("case severity when 'high' then 1 when 'medium' then 2 else 3 end")->get()->map(function ($row) { + return ['id' => $row->id, 'type' => $row->type, 'severity' => $row->severity, 'entityType' => $row->entity_type, 'entityId' => $row->entity_id, 'reason' => $row->reason, 'evidence' => json_decode($row->evidence, true), 'trend' => $row->trend, 'suggestedAction' => $row->suggested_action, 'drilldown' => $row->drilldown, 'detectedAt' => $row->detected_at]; + })->all(); + } + + private function filterOptions(string $organizationId): array + { + return [ + 'teams' => DB::table('teams')->where('organization_id', $organizationId)->orderBy('name')->get(['id', 'name']), + 'courses' => CourseVersion::query()->where('organization_id', $organizationId)->where('status', 'published')->orderBy('title')->get(['id', 'title']), + 'skills' => DB::table('taxonomy_nodes')->where('organization_id', $organizationId)->where('status', 'active')->orderBy('name')->get(['id', 'name']), + 'evidenceTypes' => DB::table('evidence_records')->where('organization_id', $organizationId)->distinct()->orderBy('evidence_type')->pluck('evidence_type'), + 'confidenceLevels' => ['insufficient', 'low', 'medium', 'high'], + ]; + } +} diff --git a/backend/app/Modules/Monitoring/Infrastructure/ExplainableHeuristicRiskProvider.php b/backend/app/Modules/Monitoring/Infrastructure/ExplainableHeuristicRiskProvider.php new file mode 100644 index 0000000..3e83f07 --- /dev/null +++ b/backend/app/Modules/Monitoring/Infrastructure/ExplainableHeuristicRiskProvider.php @@ -0,0 +1,45 @@ + 0 ? (int) $weights['deadline'] : ((int) $signals['dueSoon'] > 0 ? (int) round($weights['deadline'] * .6) : 0); + $lowProgress = (int) round((1 - min(100, (float) $signals['averageProgress']) / 100) * $weights['low_progress']); + $assessmentFailure = (int) $signals['attemptCount'] === 0 ? 0 : (int) round(((int) $signals['failed'] / (int) $signals['attemptCount']) * $weights['assessment_failure']); + $score = min(100, $inactivity + $deadline + $lowProgress + $assessmentFailure); + $level = $score >= 70 ? 'high' : ($score >= 40 ? 'medium' : 'low'); + $factors = [...$signals, ...compact('inactivity', 'deadline', 'lowProgress', 'assessmentFailure')]; + + return [ + 'score' => $score, + 'level' => $level, + 'factors' => $factors, + 'reason' => $this->reason($factors), + 'provider' => (string) config('monitoring.risk_provider'), + 'version' => (string) config('monitoring.risk_provider_version'), + ]; + } + + /** @param array $factors */ + private function reason(array $factors): string + { + if ($factors['overdue'] > 0) { + return 'Incomplete learning is past its due date.'; + } + if ($factors['daysInactive'] >= config('monitoring.inactivity_days')) { + return 'No recent learning activity was recorded.'; + } + if ($factors['averageProgress'] < 50) { + return 'Average assignment progress is below 50%.'; + } + + return 'Repeated assessment results are below the configured pass threshold.'; + } +} diff --git a/backend/app/Modules/Organizations/Domain/Organization.php b/backend/app/Modules/Organizations/Domain/Organization.php new file mode 100644 index 0000000..3237978 --- /dev/null +++ b/backend/app/Modules/Organizations/Domain/Organization.php @@ -0,0 +1,50 @@ +hasMany(User::class); + } + + public function teams(): HasMany + { + return $this->hasMany(Team::class); + } + + public function subscriptions(): HasMany + { + return $this->hasMany(Subscription::class); + } + + protected function casts(): array + { + return ['settings' => 'array']; + } + + protected static function newFactory(): OrganizationFactory + { + return OrganizationFactory::new(); + } +} diff --git a/backend/app/Modules/Organizations/Http/OrganizationController.php b/backend/app/Modules/Organizations/Http/OrganizationController.php new file mode 100644 index 0000000..0f8355c --- /dev/null +++ b/backend/app/Modules/Organizations/Http/OrganizationController.php @@ -0,0 +1,103 @@ +authorize($request); + $query = Organization::query() + ->withCount('users') + ->with(['subscriptions' => fn ($subscription) => $subscription->latest('starts_at')->limit(1)]) + ->addSelect(['last_activity_at' => DB::table('audit_logs') + ->select('created_at') + ->whereColumn('organization_id', 'organizations.id') + ->latest('created_at') + ->limit(1)]); + if ($search = trim((string) $request->query('search', ''))) { + $query->where(fn ($builder) => $builder->where('name', 'like', "%{$search}%")->orWhere('slug', 'like', "%{$search}%")); + } + if ($status = $request->query('status')) { + $query->where('status', $status); + } + if ($plan = $request->query('plan')) { + $query->whereHas('subscriptions', fn ($subscription) => $subscription->where('plan_key', $plan)); + } + $sort = (string) $request->query('sort', 'name'); + $direction = strtolower((string) $request->query('direction', 'asc')) === 'desc' ? 'desc' : 'asc'; + $column = $sort === 'users' ? 'users_count' : (in_array($sort, ['name', 'created_at'], true) ? $sort : 'name'); + $organizations = $query->orderBy($column, $direction)->paginate(min(max($request->integer('perPage', 20), 1), 100)); + + return response()->json(['data' => $organizations->getCollection()->map($this->payload(...)), 'meta' => ['currentPage' => $organizations->currentPage(), 'lastPage' => $organizations->lastPage(), 'perPage' => $organizations->perPage(), 'total' => $organizations->total()]]); + } + + public function store(StoreOrganizationRequest $request): JsonResponse + { + $this->authorize($request); + $data = $request->validated(); + $organization = Organization::query()->create(['name' => $data['name'], 'slug' => $data['slug'], 'status' => $data['status'] ?? 'active', 'default_locale' => $data['defaultLocale'], 'timezone' => $data['timezone']]); + $this->audit($request, 'organization.created', $organization); + + return response()->json(['data' => $this->payload($organization)], 201); + } + + public function show(Request $request, string $organization): JsonResponse + { + $this->authorize($request); + $model = Organization::query()->withCount('users')->findOrFail($organization); + $subscription = $model->subscriptions()->latest('starts_at')->first(); + $recentUsers = $model->users()->latest('created_at')->limit(5)->get(['id', 'name', 'email', 'role', 'status']); + $recentActivity = DB::table('audit_logs')->where('organization_id', $model->getKey())->latest('created_at')->limit(10)->get(['id', 'action', 'entity_type as entityType', 'entity_id as entityId', 'created_at as createdAt']); + + return response()->json(['data' => array_merge($this->payload($model), ['subscription' => $subscription ? $this->subscriptionPayload($subscription) : null, 'storageUsedBytes' => $subscription?->storage_used_bytes ?? 0, 'aiJobsCount' => DB::table('ai_jobs')->where('organization_id', $model->getKey())->count(), 'failedAiJobsCount' => DB::table('ai_jobs')->where('organization_id', $model->getKey())->where('status', 'failed')->count(), 'recentUsers' => $recentUsers, 'recentActivity' => $recentActivity])]); + } + + public function update(UpdateOrganizationRequest $request, string $organization): JsonResponse + { + $this->authorize($request); + $model = Organization::query()->findOrFail($organization); + $data = $request->validated(); + $model->update(array_filter(['name' => $data['name'] ?? null, 'status' => $data['status'] ?? null, 'default_locale' => $data['defaultLocale'] ?? null, 'timezone' => $data['timezone'] ?? null], fn ($value) => $value !== null)); + $this->audit($request, 'organization.updated', $model); + + return response()->json(['data' => $this->payload($model->refresh())]); + } + + private function authorize(Request $request): void + { + abort_unless($this->deployment->supportsMultipleOrganizations(), 404); + abort_unless($this->permissions->allows($request->user(), Permission::PlatformManageOrganizations), 403); + } + + private function payload(Organization $organization): array + { + $subscription = $organization->relationLoaded('subscriptions') ? $organization->subscriptions->first() : $organization->subscriptions()->latest('starts_at')->first(); + + return ['id' => $organization->getKey(), 'name' => $organization->name, 'slug' => $organization->slug, 'status' => $organization->status, 'defaultLocale' => $organization->default_locale, 'timezone' => $organization->timezone, 'usersCount' => $organization->users_count ?? $organization->users()->count(), 'createdAt' => $organization->created_at?->toISOString(), 'lastActivityAt' => $organization->last_activity_at ? Carbon::parse($organization->last_activity_at)->toISOString() : null, 'planKey' => $subscription?->plan_key, 'subscriptionStatus' => $subscription?->status, 'subscriptionExpiresAt' => $subscription?->expires_at?->toISOString(), 'storageUsedBytes' => $subscription?->storage_used_bytes ?? 0, 'storageQuotaBytes' => $subscription?->storage_quota_bytes, 'aiCreditsUsed' => $subscription?->ai_credits_used ?? 0]; + } + + private function subscriptionPayload($subscription): array + { + return ['id' => $subscription->getKey(), 'planKey' => $subscription->plan_key, 'status' => $subscription->status, 'startsAt' => $subscription->starts_at?->toISOString(), 'expiresAt' => $subscription->expires_at?->toISOString(), 'seatLimit' => $subscription->seat_limit, 'storageQuotaBytes' => $subscription->storage_quota_bytes, 'storageUsedBytes' => $subscription->storage_used_bytes, 'aiCreditQuota' => $subscription->ai_credit_quota, 'aiCreditsUsed' => $subscription->ai_credits_used, 'enabledFeatures' => $subscription->enabled_features ?? []]; + } + + private function audit(Request $request, string $action, Organization $organization): void + { + DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'organization_id' => $organization->getKey(), 'actor_id' => $request->user()->getKey(), 'action' => $action, 'entity_type' => 'organization', 'entity_id' => $organization->getKey(), 'metadata' => json_encode(['schemaVersion' => 1]), 'ip_address' => $request->ip(), 'created_at' => now()]); + } +} diff --git a/backend/app/Modules/Organizations/Http/Requests/StoreOrganizationRequest.php b/backend/app/Modules/Organizations/Http/Requests/StoreOrganizationRequest.php new file mode 100644 index 0000000..80b4da3 --- /dev/null +++ b/backend/app/Modules/Organizations/Http/Requests/StoreOrganizationRequest.php @@ -0,0 +1,25 @@ + ['required', 'string', 'max:160'], + 'slug' => ['required', 'alpha_dash:ascii', 'max:100', 'unique:organizations,slug'], + 'status' => ['sometimes', Rule::in(['active', 'disabled'])], + 'defaultLocale' => ['required', Rule::in(['fa', 'en'])], + 'timezone' => ['required', 'timezone:all'], + ]; + } +} diff --git a/backend/app/Modules/Organizations/Http/Requests/UpdateOrganizationRequest.php b/backend/app/Modules/Organizations/Http/Requests/UpdateOrganizationRequest.php new file mode 100644 index 0000000..b11fd8f --- /dev/null +++ b/backend/app/Modules/Organizations/Http/Requests/UpdateOrganizationRequest.php @@ -0,0 +1,24 @@ + ['sometimes', 'required', 'string', 'max:160'], + 'status' => ['sometimes', Rule::in(['active', 'disabled'])], + 'defaultLocale' => ['sometimes', Rule::in(['fa', 'en'])], + 'timezone' => ['sometimes', 'timezone:all'], + ]; + } +} diff --git a/backend/app/Modules/Product/Http/PlatformGovernanceController.php b/backend/app/Modules/Product/Http/PlatformGovernanceController.php new file mode 100644 index 0000000..f315dab --- /dev/null +++ b/backend/app/Modules/Product/Http/PlatformGovernanceController.php @@ -0,0 +1,90 @@ +authorizePlatform($request); + if (! Schema::hasTable('audit_logs')) { + return response()->json(['data' => ['items' => [], 'meta' => ['currentPage' => 1, 'lastPage' => 1, 'perPage' => 25, 'total' => 0]]]); + } + + $query = DB::table('audit_logs')->latest('created_at'); + if ($request->filled('action') && Schema::hasColumn('audit_logs', 'action')) { + $query->where('action', 'like', '%'.$request->string('action').'%'); + } + if ($request->filled('organizationId') && Schema::hasColumn('audit_logs', 'organization_id')) { + $query->where('organization_id', $request->string('organizationId')); + } + if ($request->filled('actorId') && Schema::hasColumn('audit_logs', 'actor_id')) { + $query->where('actor_id', $request->string('actorId')); + } + $page = $query->paginate(min($request->integer('perPage', 25), 100)); + + return response()->json(['data' => [ + 'items' => collect($page->items())->map(fn ($item) => $this->safeAudit((array) $item))->values(), + 'meta' => ['currentPage' => $page->currentPage(), 'lastPage' => $page->lastPage(), 'perPage' => $page->perPage(), 'total' => $page->total()], + ]]); + } + + public function administrators(Request $request): JsonResponse + { + $this->authorizePlatform($request); + $items = DB::table('users')->where('role', UserRole::SuperAdmin->value)->orderBy('name')->get()->map(function ($user): array { + return ['id' => $user->id, 'name' => $user->name, 'email' => $user->email, 'status' => $user->status ?? 'active', 'lastLoginAt' => $user->last_login_at ?? null, 'createdAt' => $user->created_at]; + })->values(); + + return response()->json(['data' => ['items' => $items]]); + } + + private function authorizePlatform(Request $request): void + { + abort_unless($request->user()?->role === UserRole::SuperAdmin, 403); + } + + private function safeAudit(array $item): array + { + $metadata = $item['metadata'] ?? null; + if (is_string($metadata)) { + $metadata = json_decode($metadata, true); + } + if (! is_array($metadata)) { + $metadata = null; + } + + return [ + 'id' => $item['id'] ?? null, + 'actorId' => $item['actor_id'] ?? null, + 'action' => $item['action'] ?? null, + 'organizationId' => $item['organization_id'] ?? null, + 'entityType' => $item['entity_type'] ?? null, + 'entityId' => $item['entity_id'] ?? null, + 'ip' => $item['ip_address'] ?? ($item['ip'] ?? null), + 'metadata' => $this->maskMetadata($metadata), + 'createdAt' => $item['created_at'] ?? null, + ]; + } + + private function maskMetadata(?array $metadata): ?array + { + if ($metadata === null) { + return null; + } + foreach (['password', 'token', 'api_key', 'apiKey', 'secret', 'credential'] as $key) { + if (array_key_exists($key, $metadata)) { + $metadata[$key] = '[REDACTED]'; + } + } + + return $metadata; + } +} diff --git a/backend/app/Modules/Product/Http/PlatformOperationsController.php b/backend/app/Modules/Product/Http/PlatformOperationsController.php new file mode 100644 index 0000000..45b043d --- /dev/null +++ b/backend/app/Modules/Product/Http/PlatformOperationsController.php @@ -0,0 +1,182 @@ +authorizePlatform($request); + + return response()->json(['data' => [ + 'organizations' => $this->count('organizations'), + 'users' => $this->count('users'), + 'courses' => $this->count('courses'), + 'assets' => $this->count('assets'), + 'storageBytes' => $this->sum('assets', 'size'), + 'ai' => ['requests' => $this->count('ai_jobs'), 'failed' => $this->countWhere('ai_jobs', 'status', 'failed')], + 'jobs' => ['queued' => $this->count('jobs'), 'failed' => $this->count('failed_jobs')], + 'generatedAt' => now()->toISOString(), + ]]); + } + + public function storage(Request $request): JsonResponse + { + $this->authorizePlatform($request); + $used = $this->sum('assets', 'size'); + $capacity = (int) config('filesystems.platform_capacity_bytes', 0); + $byOrganization = $this->groupSum('assets', 'organization_id', 'size'); + + return response()->json(['data' => [ + 'usedBytes' => $used, + 'capacityBytes' => $capacity, + 'usagePercent' => $capacity > 0 ? round(($used / $capacity) * 100, 2) : null, + 'capacityConfigured' => $capacity > 0, + 'breakdown' => $this->assetBreakdown(), + 'organizations' => $byOrganization, + 'generatedAt' => now()->toISOString(), + ]]); + } + + public function ai(Request $request): JsonResponse + { + $this->authorizePlatform($request); + $total = $this->count('ai_jobs'); + $failed = $this->countWhere('ai_jobs', 'status', 'failed'); + $queued = $this->countWhere('ai_jobs', 'status', 'queued'); + + return response()->json(['data' => [ + 'requests' => $total, + 'successful' => max(0, $total - $failed - $queued), + 'failed' => $failed, + 'queued' => $queued, + 'averageLatencyMs' => null, + 'providers' => $this->groupCount('ai_jobs', 'provider'), + 'organizations' => $this->groupCount('ai_jobs', 'organization_id'), + 'failures' => $this->aiFailures(), + 'generatedAt' => now()->toISOString(), + ]]); + } + + public function health(Request $request): JsonResponse + { + $this->authorizePlatform($request); + $services = [ + 'application' => ['status' => 'healthy', 'latencyMs' => null], + 'database' => $this->probe(fn () => DB::select('select 1')), + 'cache' => $this->probe(fn () => Cache::put('platform-health-probe', true, 5)), + 'queue' => config('queue.default') === 'sync' ? ['status' => 'healthy', 'detail' => 'sync'] : ['status' => Cache::has('system:queue-heartbeat') ? 'healthy' : 'unknown', 'latencyMs' => null], + 'scheduler' => ['status' => Cache::has('system:scheduler-heartbeat') ? 'healthy' : 'unknown', 'latencyMs' => null], + 'storage' => $this->probe(function (): void { + $path = 'health/platform-'.uniqid().'.probe'; + Storage::put($path, 'ok'); + Storage::delete($path); + }), + 'aiProvider' => ['status' => config('ai.provider') ? 'configured' : 'unknown', 'latencyMs' => null], + ]; + $statuses = array_map(fn (array|string $service) => is_array($service) ? ($service['status'] ?? 'unknown') : $service, $services); + $overall = in_array('failed', $statuses, true) ? 'critical' : (in_array('unknown', $statuses, true) ? 'warning' : 'healthy'); + + return response()->json(['data' => ['status' => $overall, 'services' => $services, 'checkedAt' => now()->toISOString()]]); + } + + public function jobs(Request $request): JsonResponse + { + $this->authorizePlatform($request); + $failed = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->latest('failed_at')->limit(50)->get(['id', 'uuid', 'queue', 'failed_at'])->map(fn ($job) => (array) $job)->all() : []; + + return response()->json(['data' => [ + 'queued' => $this->count('jobs'), 'processing' => null, 'completed' => null, + 'failed' => $this->count('failed_jobs'), 'failedItems' => $failed, 'retrySupported' => false, + 'generatedAt' => now()->toISOString(), + ]]); + } + + public function backups(Request $request): JsonResponse + { + $this->authorizePlatform($request); + $configured = (bool) config('backup.enabled', false); + + return response()->json(['data' => ['configured' => $configured, 'status' => $configured ? 'unknown' : 'not_configured', 'lastSuccessfulAt' => null, 'lastFailedAt' => null, 'destination' => null, 'schedule' => null]]); + } + + private function authorizePlatform(Request $request): void + { + abort_unless($request->user()?->role === UserRole::SuperAdmin, 403); + } + + private function count(string $table): int + { + return Schema::hasTable($table) ? (int) DB::table($table)->count() : 0; + } + + private function countWhere(string $table, string $column, string $value): int + { + return Schema::hasTable($table) && Schema::hasColumn($table, $column) ? (int) DB::table($table)->where($column, $value)->count() : 0; + } + + private function sum(string $table, string $column): int + { + return Schema::hasTable($table) && Schema::hasColumn($table, $column) ? (int) DB::table($table)->sum($column) : 0; + } + + private function groupCount(string $table, string $column): array + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) { + return []; + } + + return DB::table($table)->select($column)->selectRaw('count(*) as count')->groupBy($column)->get()->mapWithKeys(fn ($row) => [(string) ($row->{$column} ?? 'unknown') => (int) $row->count])->all(); + } + + private function groupSum(string $table, string $group, string $sum): array + { + if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $group) || ! Schema::hasColumn($table, $sum)) { + return []; + } + + return DB::table($table)->select($group)->selectRaw("sum($sum) as used_bytes")->groupBy($group)->get()->map(fn ($row) => ['organizationId' => $row->{$group}, 'usedBytes' => (int) $row->used_bytes])->all(); + } + + private function assetBreakdown(): array + { + if (! Schema::hasTable('assets') || ! Schema::hasColumn('assets', 'size') || ! Schema::hasColumn('assets', 'mime_type')) { + return []; + } + + $category = "CASE WHEN mime_type LIKE 'image/%' THEN 'images' WHEN mime_type LIKE 'video/%' THEN 'videos' WHEN mime_type LIKE 'application/%' THEN 'documents' ELSE 'other' END"; + + return DB::table('assets')->selectRaw("{$category} as category")->selectRaw('sum(size) as bytes')->groupByRaw($category)->get()->mapWithKeys(fn ($row) => [$row->category => (int) $row->bytes])->all(); + } + + private function aiFailures(): array + { + if (! Schema::hasTable('ai_jobs')) { + return []; + } + + return DB::table('ai_jobs')->where('status', 'failed')->latest()->limit(25)->get(['id', 'organization_id', 'provider', 'operation', 'status', 'created_at'])->map(fn ($job) => (array) $job)->all(); + } + + private function probe(callable $callback): array + { + $started = microtime(true); + try { + $callback(); + + return ['status' => 'healthy', 'latencyMs' => round((microtime(true) - $started) * 1000, 2)]; + } catch (Throwable $exception) { + return ['status' => 'failed', 'latencyMs' => round((microtime(true) - $started) * 1000, 2), 'detail' => 'probe failed']; + } + } +} diff --git a/backend/app/Modules/Product/Http/ProductSurfaceController.php b/backend/app/Modules/Product/Http/ProductSurfaceController.php new file mode 100644 index 0000000..efb1283 --- /dev/null +++ b/backend/app/Modules/Product/Http/ProductSurfaceController.php @@ -0,0 +1,411 @@ +where('user_id', $request->user()->getKey())->first(); + + return response()->json(['data' => ['role' => $request->user()->role->value, 'preferences' => $row ? json_decode($row->preferences, true) : $this->preferenceDefaults($request->user()->role)]]); + } + + public function updatePreferences(Request $request): JsonResponse + { + $allowed = array_keys($this->preferenceDefaults($request->user()->role)); + $data = $request->validate(['preferences' => ['required', 'array'], 'preferences.*' => []]); + $preferences = collect($data['preferences'])->only($allowed)->all(); + DB::table('user_preferences')->updateOrInsert(['user_id' => $request->user()->getKey()], ['id' => (string) str()->ulid(), 'preferences' => json_encode([...$this->preferenceDefaults($request->user()->role), ...$preferences]), 'created_at' => now(), 'updated_at' => now()]); + + return $this->preferences($request); + } + + public function organizationSettings(Request $request): JsonResponse + { + $this->designer($request); + $row = DB::table('organization_profiles')->where('organization_id', $this->tenant->id())->first(); + $stored = json_decode($row?->settings ?: '{}', true); + + return response()->json(['data' => ['settings' => array_replace_recursive($this->organizationSettingDefaults(), $stored['system'] ?? []), 'updatedAt' => $row?->updated_at]]); + } + + public function updateOrganizationSettings(Request $request): JsonResponse + { + $this->designer($request); + $data = $request->validate([ + 'settings' => ['required', 'array'], + 'settings.general.locale' => ['required', Rule::in(['fa', 'en'])], + 'settings.general.timezone' => ['required', Rule::in(['Asia/Tehran', 'UTC', 'Asia/Dubai'])], + 'settings.general.calendar' => ['required', Rule::in(['jalali', 'gregorian'])], + 'settings.general.firstDayOfWeek' => ['required', Rule::in(['saturday', 'monday'])], + 'settings.general.dateFormat' => ['required', Rule::in(['short', 'long'])], + 'settings.general.numberFormat' => ['required', Rule::in(['persian', 'latin'])], + 'settings.general.hourCycle' => ['required', Rule::in(['24', '12'])], + 'settings.general.theme' => ['required', Rule::in(['system', 'light', 'dark'])], + 'settings.general.density' => ['required', Rule::in(['comfortable', 'standard', 'compact'])], + 'settings.general.fontScale' => ['required', Rule::in(['standard', 'large'])], + 'settings.general.highContrast' => ['required', 'boolean'], + 'settings.general.focusGuide' => ['required', 'boolean'], + 'settings.general.reduceMotion' => ['required', 'boolean'], + 'settings.courseDefaults' => ['required', 'array'], 'settings.assignments' => ['required', 'array'], 'settings.learner' => ['required', 'array'], 'settings.certificates' => ['required', 'array'], 'settings.notifications' => ['required', 'array'], 'settings.analytics' => ['required', 'array'], 'settings.security' => ['required', 'array'], 'settings.media' => ['required', 'array'], + ]); + $row = DB::table('organization_profiles')->where('organization_id', $this->tenant->id())->first(); + $stored = json_decode($row?->settings ?: '{}', true); + $stored['system'] = array_replace_recursive($this->organizationSettingDefaults(), $data['settings']); + DB::table('organization_profiles')->updateOrInsert(['organization_id' => $this->tenant->id()], [ + 'id' => $row?->id ?? (string) str()->ulid(), 'primary_color' => $row?->primary_color ?? '#0078D4', 'accent_color' => $row?->accent_color ?? '#0F7B0F', + 'settings' => json_encode($stored), 'created_at' => $row?->created_at ?? now(), 'updated_at' => now(), + ]); + $this->audit($request, 'organization.settings.updated', 'organization', $this->tenant->id()); + + return $this->organizationSettings($request); + } + + public function brand(Request $request): JsonResponse + { + $organization = $this->tenant->organization(); + $row = DB::table('organization_profiles')->where('organization_id', $this->tenant->id())->first(); + + return response()->json(['data' => $this->brandPayload($organization->name, $row)]); + } + + public function updateBrand(Request $request): JsonResponse + { + $this->designer($request); + $data = $request->validate([ + 'displayName' => ['nullable', 'string', 'max:120'], 'primaryColor' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'], 'accentColor' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'], + 'learnerWelcome' => ['nullable', 'string', 'max:500'], 'certificateSignatory' => ['nullable', 'string', 'max:120'], 'logoAssetId' => ['nullable', 'string'], 'darkLogoAssetId' => ['nullable', 'string'], 'faviconAssetId' => ['nullable', 'string'], + 'palette' => ['required', Rule::in(['fluent', 'teal', 'royal', 'custom'])], 'fontPair' => ['required', Rule::in(['vazirmatn', 'estedad', 'system'])], 'radius' => ['required', Rule::in(['soft', 'rounded', 'pill'])], + 'defaultTheme' => ['required', Rule::in(['system', 'light', 'dark'])], 'surfaceStyle' => ['required', Rule::in(['clean', 'soft', 'contrast'])], 'playerStyle' => ['required', Rule::in(['focused', 'immersive', 'classic'])], 'certificateStyle' => ['required', Rule::in(['formal', 'modern', 'minimal'])], 'emailStyle' => ['required', Rule::in(['branded', 'simple'])], + ]); + foreach (['logoAssetId', 'darkLogoAssetId', 'faviconAssetId'] as $assetField) { + if (! empty($data[$assetField])) { + abort_unless(DB::table('assets')->where('organization_id', $this->tenant->id())->where('kind', 'image')->where('id', $data[$assetField])->exists(), 422, 'Brand asset is not available in this organization.'); + } + } + $current = DB::table('organization_profiles')->where('organization_id', $this->tenant->id())->first(); + $settings = json_decode($current?->settings ?: '{}', true); + $settings['brand'] = collect($data)->only(['darkLogoAssetId', 'faviconAssetId', 'palette', 'fontPair', 'radius', 'defaultTheme', 'surfaceStyle', 'playerStyle', 'certificateStyle', 'emailStyle'])->all(); + DB::table('organization_profiles')->updateOrInsert(['organization_id' => $this->tenant->id()], [ + 'id' => $current?->id ?? (string) str()->ulid(), 'logo_asset_id' => $data['logoAssetId'] ?? null, 'display_name' => $data['displayName'] ?? null, 'primary_color' => $data['primaryColor'], 'accent_color' => $data['accentColor'], + 'learner_welcome' => $data['learnerWelcome'] ?? null, 'certificate_signatory' => $data['certificateSignatory'] ?? null, 'settings' => json_encode($settings), 'created_at' => $current?->created_at ?? now(), 'updated_at' => now(), + ]); + $this->audit($request, 'brand.updated', 'organization', $this->tenant->id()); + + return $this->brand($request); + } + + public function templates(Request $request): JsonResponse + { + $this->designer($request); + $organizationId = $this->tenant->id(); + $usage = []; + DB::table('course_versions')->where('organization_id', $organizationId)->get(['settings'])->each(function ($version) use (&$usage): void { + $settings = json_decode((string) $version->settings, true); + $templateId = is_array($settings) ? ($settings['templateId'] ?? null) : null; + if (is_string($templateId) && $templateId !== '') { + $usage[$templateId] = ($usage[$templateId] ?? 0) + 1; + } + }); + $items = DB::table('course_templates as templates') + ->leftJoin('users as creators', 'creators.id', '=', 'templates.created_by') + ->where('templates.organization_id', $organizationId) + ->where('templates.is_active', true) + ->orderByDesc('templates.updated_at') + ->get(['templates.*', 'creators.name as creator_name']) + ->map(fn ($row) => [ + 'id' => $row->id, + 'title' => $row->title, + 'description' => $row->description, + 'category' => $row->category, + 'status' => $row->status, + 'level' => $row->level, + 'estimatedMinutes' => $row->estimated_minutes === null ? null : (int) $row->estimated_minutes, + 'usageCount' => $usage[$row->id] ?? 0, + 'creatorName' => $row->creator_name, + 'blueprint' => json_decode($row->blueprint, true), + 'updatedAt' => $row->updated_at, + ]); + + return response()->json(['data' => $items]); + } + + public function storeTemplate(Request $request): JsonResponse + { + $this->designer($request); + $data = $request->validate(['title' => ['required', 'string', 'max:160'], 'description' => ['nullable', 'string', 'max:1000'], 'category' => ['required', 'string', 'max:60'], 'status' => ['sometimes', Rule::in(['active', 'draft'])], 'level' => ['sometimes', Rule::in(['introductory', 'intermediate', 'advanced'])], 'estimatedMinutes' => ['nullable', 'integer', 'min:1', 'max:10080'], 'modules' => ['required', 'array', 'min:1', 'max:30'], 'modules.*.title' => ['required', 'string', 'max:160'], 'modules.*.lessons' => ['required', 'array', 'min:1', 'max:30'], 'modules.*.lessons.*' => ['required', 'string', 'max:160']]); + $id = (string) str()->ulid(); + DB::table('course_templates')->insert(['id' => $id, 'organization_id' => $this->tenant->id(), 'created_by' => $request->user()->getKey(), 'title' => $data['title'], 'description' => $data['description'] ?? null, 'category' => $data['category'], 'status' => $data['status'] ?? 'active', 'level' => $data['level'] ?? 'intermediate', 'estimated_minutes' => $data['estimatedMinutes'] ?? null, 'blueprint' => json_encode(['schemaVersion' => 1, 'modules' => $data['modules']]), 'is_active' => true, 'created_at' => now(), 'updated_at' => now()]); + $this->audit($request, 'template.created', 'course_template', $id); + + return response()->json(['data' => ['id' => $id]], 201); + } + + public function destroyTemplate(Request $request, string $template): JsonResponse + { + $this->designer($request); + $updated = DB::table('course_templates')->where('organization_id', $this->tenant->id())->where('id', $template)->update(['is_active' => false, 'updated_at' => now()]); + abort_unless($updated === 1, 404); + $this->audit($request, 'template.archived', 'course_template', $template); + + return response()->json(['data' => ['archived' => true]]); + } + + public function updateTemplate(Request $request, string $template): JsonResponse + { + $this->designer($request); + $data = $request->validate(['title' => ['required', 'string', 'max:160'], 'description' => ['nullable', 'string', 'max:1000'], 'category' => ['required', 'string', 'max:60'], 'status' => ['sometimes', Rule::in(['active', 'draft'])], 'level' => ['sometimes', Rule::in(['introductory', 'intermediate', 'advanced'])], 'estimatedMinutes' => ['nullable', 'integer', 'min:1', 'max:10080'], 'modules' => ['required', 'array', 'min:1', 'max:30'], 'modules.*.title' => ['required', 'string', 'max:160'], 'modules.*.lessons' => ['required', 'array', 'min:1', 'max:30'], 'modules.*.lessons.*' => ['required', 'string', 'max:160']]); + $templateQuery = DB::table('course_templates')->where('organization_id', $this->tenant->id())->where('id', $template)->where('is_active', true); + $current = $templateQuery->first(); + abort_unless($current, 404); + $templateQuery->update([ + 'title' => $data['title'], 'description' => $data['description'] ?? null, 'category' => $data['category'], 'status' => $data['status'] ?? $current->status, 'level' => $data['level'] ?? $current->level, 'estimated_minutes' => array_key_exists('estimatedMinutes', $data) ? $data['estimatedMinutes'] : $current->estimated_minutes, 'blueprint' => json_encode(['schemaVersion' => 1, 'modules' => $data['modules']]), 'updated_at' => now(), + ]); + $this->audit($request, 'template.updated', 'course_template', $template); + + return response()->json(['data' => ['id' => $template]]); + } + + public function instantiateTemplate(Request $request, string $template): JsonResponse + { + $this->designer($request); + $data = $request->validate(['title' => ['nullable', 'string', 'max:160']]); + $row = DB::table('course_templates')->where('organization_id', $this->tenant->id())->where('id', $template)->where('is_active', true)->first(); + abort_unless($row, 404); + $blueprint = json_decode($row->blueprint, true); + $title = trim((string) ($data['title'] ?? '')) ?: $row->title; + $course = DB::transaction(function () use ($request, $row, $blueprint, $title): Course { + $course = Course::create(['organization_id' => $this->tenant->id(), 'title' => $title, 'slug' => (Str::slug($title) ?: 'template-course').'-'.Str::lower(Str::random(6)), 'status' => 'draft', 'created_by' => $request->user()->getKey()]); + $version = CourseVersion::create(['organization_id' => $this->tenant->id(), 'course_id' => $course->id, 'version_number' => 1, 'status' => 'draft', 'title' => $title, 'description' => $row->description, 'settings' => ['templateId' => $row->id]]); + foreach ($blueprint['modules'] ?? [] as $modulePosition => $moduleData) { + $module = CourseModule::create(['organization_id' => $this->tenant->id(), 'course_version_id' => $version->id, 'title' => $moduleData['title'], 'position' => $modulePosition + 1]); + foreach ($moduleData['lessons'] ?? [] as $lessonPosition => $lessonTitle) { + Lesson::create(['organization_id' => $this->tenant->id(), 'course_version_id' => $version->id, 'course_module_id' => $module->id, 'title' => $lessonTitle, 'position' => $lessonPosition + 1]); + } + } + + return $course; + }); + $this->audit($request, 'template.instantiated', 'course', $course->id); + + return response()->json(['data' => ['courseId' => $course->id, 'workspaceUrl' => '/app/courses/'.$course->id]], 201); + } + + public function surface(Request $request, string $surface): JsonResponse + { + $this->designer($request); + abort_unless(in_array($surface, ['reports', 'exports', 'certificates', 'settings'], true), 404); + $organizationId = $this->tenant->id(); + $payload = match ($surface) { + 'reports' => [ + 'courses' => DB::table('courses')->where('organization_id', $organizationId)->count(), + 'learners' => DB::table('users')->where('organization_id', $organizationId)->where('role', UserRole::Learner->value)->count(), + 'assignments' => DB::table('assignments')->where('organization_id', $organizationId)->count(), + 'completed' => DB::table('assignment_users')->join('assignments', 'assignments.id', '=', 'assignment_users.assignment_id')->where('assignments.organization_id', $organizationId)->where('assignment_users.status', 'completed')->count(), + 'recentMetrics' => DB::table('analytics_daily_metrics')->where('organization_id', $organizationId)->orderByDesc('metric_date')->limit(30)->get(), + ], + 'exports' => DB::table('export_jobs')->where('organization_id', $organizationId)->orderByDesc('created_at')->limit(100)->get()->map(fn ($row) => ['id' => $row->id, 'type' => $row->type, 'format' => $row->format, 'status' => $row->status, 'size' => $row->size, 'createdAt' => $row->created_at, 'completedAt' => $row->completed_at]), + 'certificates' => DB::table('certificates as c')->join('users as u', 'u.id', '=', 'c.user_id')->join('course_versions as cv', 'cv.id', '=', 'c.course_version_id')->where('c.organization_id', $organizationId)->orderByDesc('c.issued_at')->limit(100)->get(['c.id', 'c.serial', 'c.issued_at as issuedAt', 'c.revoked_at as revokedAt', 'u.name as learnerName', 'cv.title as courseTitle']), + 'settings' => ['brand' => $this->brandPayload($this->tenant->organization()->name, DB::table('organization_profiles')->where('organization_id', $organizationId)->first()), 'retentionDays' => 365, 'defaultLocale' => $this->tenant->organization()->default_locale, 'timezone' => $this->tenant->organization()->timezone], + }; + + return response()->json(['data' => $payload]); + } + + public function createExport(Request $request): JsonResponse + { + $this->designer($request); + $data = $request->validate(['type' => ['required', Rule::in(['users', 'courses', 'assignments'])], 'format' => ['required', Rule::in(['csv'])]]); + $id = (string) str()->ulid(); + $rows = match ($data['type']) { + 'users' => DB::table('users')->where('organization_id', $this->tenant->id())->get(['name', 'email', 'department', 'job_level', 'status']), + 'courses' => DB::table('courses')->where('organization_id', $this->tenant->id())->get(['title', 'slug', 'status', 'updated_at']), + default => DB::table('assignments')->where('organization_id', $this->tenant->id())->get(['target_type', 'status', 'due_at', 'created_at']), + }; + $columns = $rows->isNotEmpty() ? array_keys((array) $rows->first()) : ['empty']; + $stream = fopen('php://temp', 'r+'); + fputcsv($stream, $columns); + foreach ($rows as $row) { + fputcsv($stream, array_values((array) $row)); + } + rewind($stream); + $contents = stream_get_contents($stream) ?: ''; + fclose($stream); + $path = 'exports/'.$this->tenant->id().'/'.$id.'.csv'; + Storage::disk('local')->put($path, $contents); + DB::table('export_jobs')->insert(['id' => $id, 'organization_id' => $this->tenant->id(), 'requested_by' => $request->user()->getKey(), 'type' => $data['type'], 'format' => 'csv', 'status' => 'completed', 'filters' => json_encode([]), 'disk' => 'local', 'path' => $path, 'size' => strlen($contents), 'error' => null, 'completed_at' => now(), 'created_at' => now(), 'updated_at' => now()]); + $this->audit($request, 'export.created', 'export_job', $id); + + return response()->json(['data' => ['id' => $id, 'status' => 'completed']], 201); + } + + public function downloadExport(Request $request, string $export) + { + $this->designer($request); + $row = DB::table('export_jobs')->where('organization_id', $this->tenant->id())->where('id', $export)->where('status', 'completed')->first(); + abort_unless($row && $row->path && Storage::disk($row->disk)->exists($row->path), 404); + + return Storage::disk($row->disk)->download($row->path, $row->type.'-'.$row->id.'.csv'); + } + + public function platform(Request $request): JsonResponse + { + $this->platformAdmin($request); + $organizations = DB::table('organizations')->count(); + $storage = (int) DB::table('assets')->sum('size'); + $ai = SchemaSafe::count('ai_jobs'); + $failedJobs = DB::table('failed_jobs')->count(); + $queuedJobs = DB::table('jobs')->count(); + $expiringSubscriptions = DB::table('subscriptions')->where('status', 'active')->whereBetween('expires_at', [now(), now()->addDays(7)])->count(); + $storageWarnings = DB::table('subscriptions')->where('storage_quota_bytes', '>', 0)->whereRaw('storage_used_bytes >= storage_quota_bytes * 0.9')->count(); + $attention = []; + if ($failedJobs > 0) { + $attention[] = ['id' => 'failed-jobs', 'severity' => 'critical', 'title' => 'کارهای ناموفق نیازمند بررسی هستند', 'count' => $failedJobs, 'action' => '/admin/system-health']; + } + if ($storageWarnings > 0) { + $attention[] = ['id' => 'storage-warnings', 'severity' => 'warning', 'title' => 'سازمان‌ها از ۹۰٪ سهمیه فضا عبور کرده‌اند', 'count' => $storageWarnings, 'action' => '/admin/storage']; + } + if ($expiringSubscriptions > 0) { + $attention[] = ['id' => 'expiring-subscriptions', 'severity' => 'warning', 'title' => 'اشتراک‌ها در هفت روز آینده منقضی می‌شوند', 'count' => $expiringSubscriptions, 'action' => '/admin/subscriptions']; + } + $system = ['status' => 'healthy', 'php' => PHP_VERSION, 'laravel' => app()->version(), 'queue' => config('queue.default'), 'storageDisk' => config('filesystems.default'), 'deploymentMode' => config('deployment.mode', 'saas')]; + + return response()->json(['data' => [ + 'organizations' => $organizations, 'activeOrganizations' => DB::table('organizations')->where('status', 'active')->count(), 'users' => DB::table('users')->count(), 'storageBytes' => $storage, + 'aiJobs' => $ai, 'failedJobs' => $failedJobs, 'queuedJobs' => $queuedJobs, 'subscriptions' => DB::table('subscriptions')->select('status', DB::raw('count(*) as count'))->groupBy('status')->pluck('count', 'status'), + 'recentAudit' => DB::table('audit_logs as a')->leftJoin('users as u', 'u.id', '=', 'a.actor_id')->orderByDesc('a.created_at')->limit(20)->get(['a.id', 'a.action', 'a.entity_type as entityType', 'a.created_at as createdAt', 'u.name as actorName']), + 'system' => $system, + 'dashboard' => ['kpis' => ['organizations' => $organizations, 'activeOrganizations' => DB::table('organizations')->where('status', 'active')->count(), 'users' => DB::table('users')->count(), 'storageBytes' => $storage, 'aiJobs' => $ai, 'failedJobs' => $failedJobs, 'activeSubscriptions' => DB::table('subscriptions')->where('status', 'active')->count()], 'attention' => $attention, 'health' => $system, 'activity' => DB::table('audit_logs as a')->leftJoin('users as u', 'u.id', '=', 'a.actor_id')->orderByDesc('a.created_at')->limit(8)->get(['a.id', 'a.action', 'a.created_at as createdAt', 'u.name as actorName']), 'lastUpdatedAt' => now()->toISOString()], + ]]); + } + + public function platformSettings(Request $request): JsonResponse + { + $this->platformAdmin($request); + if ($request->isMethod('patch')) { + $data = $request->validate(['settings' => ['required', 'array'], 'settings.externalAiEnabled' => ['boolean'], 'settings.maintenanceBanner' => ['nullable', 'string', 'max:500'], 'settings.defaultStorageQuotaGb' => ['integer', 'min:1', 'max:100000']]); + DB::table('platform_settings')->updateOrInsert(['key' => 'platform'], ['value' => json_encode($data['settings']), 'updated_by' => $request->user()->getKey(), 'created_at' => now(), 'updated_at' => now()]); + $this->audit($request, 'platform.settings.updated', 'platform', 'platform'); + } + $row = DB::table('platform_settings')->where('key', 'platform')->first(); + + return response()->json(['data' => $row ? json_decode($row->value, true) : ['externalAiEnabled' => false, 'maintenanceBanner' => null, 'defaultStorageQuotaGb' => 10]]); + } + + /** @return array */ + private function preferenceDefaults(UserRole $role): array + { + $common = ['compactMode' => false, 'emailNotifications' => true, 'pushNotifications' => true, 'inAppNotifications' => true, 'reduceMotion' => false]; + + return match ($role) { + UserRole::SuperAdmin => [...$common, 'operationsDigest' => true, 'defaultLanding' => '/admin/dashboard'], + UserRole::CourseDesigner => [...$common, 'reviewMentions' => true, 'defaultLanding' => '/app/dashboard'], + UserRole::Manager => [...$common, 'teamDigest' => true, 'riskAlerts' => true, 'assignmentNotifications' => true, 'deadlineReminders' => true], + UserRole::Learner => [...$common, 'dailyReminder' => true, 'assignmentNotifications' => true, 'deadlineReminders' => true, 'offlineAutoDownload' => false], + }; + } + + /** @return array */ + private function organizationSettingDefaults(): array + { + return [ + 'general' => [ + 'locale' => 'fa', 'timezone' => 'Asia/Tehran', 'calendar' => 'jalali', 'firstDayOfWeek' => 'saturday', + 'dateFormat' => 'short', 'numberFormat' => 'persian', 'hourCycle' => '24', + 'theme' => 'system', 'density' => 'standard', 'fontScale' => 'standard', + 'highContrast' => false, 'focusGuide' => true, 'reduceMotion' => false, + ], + 'courseDefaults' => ['language' => 'fa', 'difficulty' => 'beginner', 'presentationMode' => 'flow', 'completionMode' => 'all_required', 'passingScore' => 70, 'resumeLearning' => true, 'showProgress' => true], + 'assignments' => ['mandatory' => true, 'dueDays' => 14, 'reminderDays' => 3, 'managerEscalation' => true, 'overduePolicy' => 'keep_active', 'reassignmentPolicy' => 'preserve_progress'], + 'learner' => ['notes' => true, 'bookmarks' => true, 'favorites' => true, 'discussions' => true, 'downloads' => false, 'offlineLearning' => false, 'videoAutoplay' => false], + 'certificates' => ['issuance' => 'approval', 'validityMonths' => 0, 'renewal' => true, 'verificationQr' => true, 'notifyLearner' => true], + 'notifications' => ['email' => true, 'push' => true, 'inApp' => true, 'digest' => 'weekly', 'quietHours' => '22-07', 'overdue' => true, 'risk' => true], + 'analytics' => ['defaultRange' => 30, 'riskThreshold' => 60, 'rebuildFrequency' => 'daily', 'exportRetentionDays' => 90, 'managerAccess' => 'team_only'], + 'security' => ['sessionMinutes' => 480, 'twoFactor' => 'optional', 'defaultRole' => 'learner', 'downloadPolicy' => 'designers', 'auditRetentionDays' => 365], + 'media' => ['maxFileMb' => 200, 'videoQuality' => 'adaptive', 'allowedDocuments' => 'standard', 'retentionPolicy' => 'archive_unused', 'imageOptimization' => true], + ]; + } + + private function designer(Request $request): void + { + abort_unless($request->user() && $this->permissions->allows($request->user(), Permission::CoursesAuthor), 403); + } + + private function platformAdmin(Request $request): void + { + abort_unless($request->user()?->role === UserRole::SuperAdmin, 403); + } + + /** @return array */ + private function brandPayload(string $organizationName, ?object $row): array + { + $settings = json_decode($row?->settings ?: '{}', true); + $brand = array_replace(['darkLogoAssetId' => null, 'faviconAssetId' => null, 'palette' => 'fluent', 'fontPair' => 'vazirmatn', 'radius' => 'rounded', 'defaultTheme' => 'system', 'surfaceStyle' => 'clean', 'playerStyle' => 'focused', 'certificateStyle' => 'formal', 'emailStyle' => 'branded'], $settings['brand'] ?? []); + $primary = $row?->primary_color ?? '#0078D4'; + $accent = $row?->accent_color ?? '#0F7B0F'; + + return ['displayName' => $row?->display_name ?? $organizationName, 'primaryColor' => $primary, 'accentColor' => $accent, 'learnerWelcome' => $row?->learner_welcome, 'certificateSignatory' => $row?->certificate_signatory, 'logoAssetId' => $row?->logo_asset_id, ...$brand, + 'logoUrl' => $this->brandAssetUrl($row?->logo_asset_id), 'darkLogoUrl' => $this->brandAssetUrl($brand['darkLogoAssetId']), 'faviconUrl' => $this->brandAssetUrl($brand['faviconAssetId']), 'defaultLogoUrl' => '/brand/microlearn-logo.png', + 'contrast' => ['primaryOnLight' => $this->contrastRatio($primary, '#FFFFFF'), 'accentOnLight' => $this->contrastRatio($accent, '#FFFFFF')], + ]; + } + + private function brandAssetUrl(?string $id): ?string + { + if (! $id) { + return null; + } + $asset = Asset::query()->where('organization_id', $this->tenant->id())->find($id); + + return $asset ? URL::temporarySignedRoute('assets.content', now()->addMinutes(15), ['asset' => $asset->getKey()], absolute: false) : null; + } + + private function contrastRatio(string $foreground, string $background): float + { + $luminance = function (string $hex): float { + $values = array_map(fn (string $value) => hexdec($value) / 255, str_split(ltrim($hex, '#'), 2)); + $values = array_map(fn (float $value) => $value <= .03928 ? $value / 12.92 : (($value + .055) / 1.055) ** 2.4, $values); + + return .2126 * $values[0] + .7152 * $values[1] + .0722 * $values[2]; + }; + $a = $luminance($foreground); + $b = $luminance($background); + + return round((max($a, $b) + .05) / (min($a, $b) + .05), 2); + } + + private function audit(Request $request, string $action, ?string $entityType, ?string $entityId): void + { + DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'organization_id' => $request->user()->organization_id, 'actor_id' => $request->user()->getKey(), 'action' => $action, 'entity_type' => $entityType, 'entity_id' => $entityId, 'metadata' => json_encode(['schemaVersion' => 1]), 'ip_address' => $request->ip(), 'created_at' => now()]); + } +} + +final class SchemaSafe +{ + public static function count(string $table): int + { + return Schema::hasTable($table) ? DB::table($table)->count() : 0; + } +} diff --git a/backend/app/Modules/Publishing/Application/CoursePublication.php b/backend/app/Modules/Publishing/Application/CoursePublication.php new file mode 100644 index 0000000..28c2b9a --- /dev/null +++ b/backend/app/Modules/Publishing/Application/CoursePublication.php @@ -0,0 +1,163 @@ +>} */ + public function readiness(CourseVersion $version): array + { + $version->load(['modules.lessons.blocks', 'lessons', 'blocks', 'course', 'assessments.questions']); + $checks = []; + $this->check($checks, 'metadata', trim($version->title) !== '' && trim((string) $version->description) !== '', 'مشخصات دوره', 'عنوان و توضیح دوره کامل است.', 'عنوان و توضیح دوره را کامل کنید.', ['type' => 'course', 'tab' => 'overview']); + $this->check($checks, 'structure', $version->modules->isNotEmpty() && $version->lessons->isNotEmpty(), 'ساختار محتوا', 'ساختار دوره دارای ماژول و درس است.', 'حداقل یک ماژول و یک درس لازم است.', ['type' => 'course', 'tab' => 'content']); + $emptyLessons = $version->lessons->filter(fn ($lesson) => $lesson->blocks()->count() === 0); + $emptyLesson = $emptyLessons->first(); + $this->check($checks, 'lesson_content', $emptyLessons->isEmpty(), 'محتوای درس‌ها', 'همه درس‌ها محتوا دارند.', $emptyLessons->isEmpty() ? '' : 'درس‌های بدون محتوا: '.$emptyLessons->pluck('title')->implode('، '), $emptyLesson ? ['type' => 'lesson', 'lessonId' => $emptyLesson->getKey()] : ['type' => 'course', 'tab' => 'content']); + $invalidBlocks = []; + foreach ($version->blocks as $block) { + try { + $this->blocks->validate($block->type, $block->schema_version, $block->data); + } catch (ValidationException) { + $invalidBlocks[] = ['id' => $block->getKey(), 'lessonId' => $block->lesson_id]; + } + } + $invalidBlock = $invalidBlocks[0] ?? null; + $this->check($checks, 'blocks', $invalidBlocks === [], 'اعتبار بلوک‌ها', 'همه بلوک‌ها معتبر هستند.', $invalidBlocks === [] ? '' : count($invalidBlocks).' بلوک نامعتبر است.', $invalidBlock ? ['type' => 'block', 'lessonId' => $invalidBlock['lessonId'], 'blockId' => $invalidBlock['id']] : ['type' => 'course', 'tab' => 'content']); + $assetIds = $version->blocks->flatMap(fn ($block) => $this->assets->assetIds($block->data))->unique()->values(); + $existingAssetIds = Asset::query()->where('organization_id', $version->organization_id)->whereIn('id', $assetIds)->pluck('id'); + $missingAssetIds = $assetIds->diff($existingAssetIds); + $assetBlock = $version->blocks->first(fn ($block) => collect($this->assets->assetIds($block->data))->intersect($missingAssetIds)->isNotEmpty()); + $this->check($checks, 'assets', $missingAssetIds->isEmpty(), 'فایل‌های وابسته', 'همه فایل‌های استفاده‌شده در دسترس‌اند.', 'یک یا چند فایل استفاده‌شده حذف یا خارج از سازمان است.', $assetBlock ? ['type' => 'block', 'lessonId' => $assetBlock->lesson_id, 'blockId' => $assetBlock->getKey()] : ['type' => 'assets']); + $invalidQuestions = []; + foreach ($version->assessments->flatMap->questions as $question) { + try { + $this->questions->validate($question->type, $question->configuration); + } catch (ValidationException) { + $invalidQuestions[] = $question->getKey(); + } + } + $emptyAssessments = $version->assessments->filter(fn ($assessment) => $assessment->questions->isEmpty()); + $assessmentContentReady = $invalidQuestions === [] && $emptyAssessments->isEmpty(); + $rules = $version->completion_rules ?? []; + $rulesReady = isset($rules['mode'], $rules['rules']) && in_array($rules['mode'], ['all', 'any'], true) && is_array($rules['rules']) && $rules['rules'] !== []; + $assessmentReady = $assessmentContentReady && $rulesReady; + $assessmentFailure = ! $assessmentContentReady ? 'ارزیابی بدون سؤال یا سؤال/سناریوی نامعتبر وجود دارد.' : (! $rulesReady ? 'حداقل یک قانون تکمیل تعریف کنید.' : ''); + $this->check($checks, 'assessments', $assessmentReady, 'ارزیابی‌ها و سناریوها', 'ارزیابی‌ها، سناریوها و قانون تکمیل معتبر هستند.', $assessmentFailure, $assessmentContentReady ? ['type' => 'completion'] : ['type' => 'assessments'], $assessmentContentReady && ! $rulesReady ? 'warning' : 'error'); + + return ['ready' => collect($checks)->every(fn (array $check): bool => $check['passed']), 'checks' => $checks]; + } + + public function submitReview(CourseVersion $version): CourseVersion + { + if ($version->status !== CourseVersionStatus::Draft) { + throw ValidationException::withMessages(['version' => ['فقط Draft را می‌توان برای بازبینی ارسال کرد.']]); + } + $readiness = $this->readiness($version); + if (! $readiness['ready']) { + throw ValidationException::withMessages(['readiness' => collect($readiness['checks'])->where('passed', false)->pluck('failure')->values()->all()]); + } + $version->update(['status' => CourseVersionStatus::InReview, 'review_submitted_at' => now()]); + + return $version->fresh(); + } + + public function returnToDraft(CourseVersion $version): CourseVersion + { + if ($version->status !== CourseVersionStatus::InReview || $version->scheduled_publish_at) { + throw ValidationException::withMessages(['version' => ['نسخه زمان‌بندی‌شده را ابتدا از زمان‌بندی خارج کنید.']]); + } + $version->update(['status' => CourseVersionStatus::Draft, 'review_submitted_at' => null]); + + return $version->fresh(); + } + + /** @param array $completionRules */ + public function configure(CourseVersion $version, array $completionRules): CourseVersion + { + if (! in_array($version->status, [CourseVersionStatus::Draft, CourseVersionStatus::InReview], true)) { + throw ValidationException::withMessages(['version' => ['نسخه منتشرشده قابل تغییر نیست.']]); + } + $version->update(['completion_rules' => $completionRules]); + + return $version->fresh(); + } + + public function publish(CourseVersion $version, ?string $publishedBy = null): CourseVersion + { + if ($version->status !== CourseVersionStatus::InReview) { + throw ValidationException::withMessages(['version' => ['نسخه باید ابتدا در وضعیت بازبینی باشد.']]); + } + $readiness = $this->readiness($version); + if (! $readiness['ready']) { + throw ValidationException::withMessages(['readiness' => collect($readiness['checks'])->where('passed', false)->pluck('failure')->values()->all()]); + } + + return DB::transaction(function () use ($version, $publishedBy): CourseVersion { + $snapshot = ContentTaxonomyMapping::query()->with('taxonomyNode.type')->where('course_version_id', $version->getKey())->get()->map(fn (ContentTaxonomyMapping $mapping) => [ + 'mappingId' => $mapping->getKey(), 'mappableType' => $mapping->mappable_type->value, 'mappableId' => $mapping->mappable_id, + 'mappingType' => $mapping->mapping_type->value, 'weight' => (float) $mapping->weight, + 'taxonomyNode' => ['id' => $mapping->taxonomy_node_id, 'name' => $mapping->taxonomyNode->name, 'code' => $mapping->taxonomyNode->code, 'type' => $mapping->taxonomyNode->type->key], + ])->values()->all(); + $version->update(['status' => CourseVersionStatus::Published, 'published_at' => now(), 'published_by' => $publishedBy, 'scheduled_publish_at' => null, 'taxonomy_snapshot' => $snapshot]); + $version->course()->update(['status' => 'published']); + + return $version->fresh(); + }); + } + + public function schedule(CourseVersion $version, \DateTimeInterface $publishAt, ?\DateTimeInterface $unpublishAt): CourseVersion + { + if ($version->status !== CourseVersionStatus::InReview) { + throw ValidationException::withMessages(['version' => ['فقط نسخه در حال بازبینی قابل زمان‌بندی است.']]); + } + if ($unpublishAt && $unpublishAt <= $publishAt) { + throw ValidationException::withMessages(['scheduledUnpublishAt' => ['زمان توقف انتشار باید بعد از انتشار باشد.']]); + } + $version->update(['scheduled_publish_at' => $publishAt, 'scheduled_unpublish_at' => $unpublishAt]); + + return $version->fresh(); + } + + public function cancelSchedule(CourseVersion $version): CourseVersion + { + if ($version->status !== CourseVersionStatus::InReview) { + throw ValidationException::withMessages(['version' => ['زمان‌بندی این نسخه قابل لغو نیست.']]); + } + $version->update(['scheduled_publish_at' => null, 'scheduled_unpublish_at' => null]); + + return $version->fresh(); + } + + public function unpublish(CourseVersion $version): void + { + if ($version->status !== CourseVersionStatus::Published || $version->unpublished_at) { + throw ValidationException::withMessages(['version' => ['این نسخه منتشرشده و فعال نیست.']]); + } + DB::table('course_versions')->where('id', $version->getKey())->update(['unpublished_at' => now(), 'updated_at' => now()]); + $hasActive = CourseVersion::query()->where('course_id', $version->course_id)->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->exists(); + if (! $hasActive) { + Course::query()->whereKey($version->course_id)->update(['status' => 'archived']); + } + } + + /** @param list> $checks */ + private function check(array &$checks, string $key, bool $passed, string $label, string $success, string $failure, array $target, string $failureStatus = 'error'): void + { + $status = $passed ? 'success' : $failureStatus; + $checks[] = compact('key', 'passed', 'label', 'success', 'failure', 'status', 'target'); + } +} diff --git a/backend/app/Modules/Publishing/Http/CoursePublicationController.php b/backend/app/Modules/Publishing/Http/CoursePublicationController.php new file mode 100644 index 0000000..89c33fe --- /dev/null +++ b/backend/app/Modules/Publishing/Http/CoursePublicationController.php @@ -0,0 +1,141 @@ +authorize($request); + + return response()->json(['data' => $this->publication->readiness($this->version($course, $version))]); + } + + public function configure(Request $request, string $course, string $version): JsonResponse + { + $this->authorize($request); + $data = $request->validate([ + 'mode' => ['required', Rule::in(['all', 'any'])], 'rules' => ['required', 'array', 'min:1', 'max:8'], + 'rules.*.type' => ['required', Rule::in(['all_required_lessons', 'minimum_lesson_percentage', 'assessment_passed', 'minimum_score', 'required_interaction'])], + 'rules.*.value' => ['nullable', 'numeric', 'between:0,100'], + ]); + $model = $this->publication->configure($this->version($course, $version), $data); + + return response()->json(['data' => $this->payload($model)]); + } + + public function submitReview(Request $request, string $course, string $version): JsonResponse + { + $this->authorize($request); + + return response()->json(['data' => $this->payload($this->publication->submitReview($this->version($course, $version)))]); + } + + public function returnDraft(Request $request, string $course, string $version): JsonResponse + { + $this->authorize($request); + + return response()->json(['data' => $this->payload($this->publication->returnToDraft($this->version($course, $version)))]); + } + + public function publish(Request $request, string $course, string $version): JsonResponse + { + $this->authorize($request); + $data = $request->validate(['reassignMode' => ['required', Rule::in(['none', 'everyone', 'selected'])], 'assignmentIds' => ['nullable', 'array'], 'assignmentIds.*' => ['string']]); + $model = $this->version($course, $version); + $published = $this->publication->publish($model, (string) $request->user()->getKey()); + $created = $this->reassign($request, $published, $data['reassignMode'], $data['assignmentIds'] ?? []); + + return response()->json(['data' => [...$this->payload($published), 'reassignmentsCreated' => $created]]); + } + + public function schedule(Request $request, string $course, string $version): JsonResponse + { + $this->authorize($request); + $data = $request->validate(['scheduledPublishAt' => ['required', 'date', 'after:now'], 'scheduledUnpublishAt' => ['nullable', 'date', 'after:scheduledPublishAt']]); + $model = $this->publication->schedule($this->version($course, $version), new \DateTimeImmutable($data['scheduledPublishAt']), isset($data['scheduledUnpublishAt']) ? new \DateTimeImmutable($data['scheduledUnpublishAt']) : null); + + return response()->json(['data' => $this->payload($model)]); + } + + public function cancelSchedule(Request $request, string $course, string $version): JsonResponse + { + $this->authorize($request); + + return response()->json(['data' => $this->payload($this->publication->cancelSchedule($this->version($course, $version)))]); + } + + public function unpublish(Request $request, string $course, string $version): JsonResponse + { + $this->authorize($request); + $this->publication->unpublish($this->version($course, $version)); + + return response()->json(status: 204); + } + + /** @param list $selected */ + private function reassign(Request $request, CourseVersion $published, string $mode, array $selected): int + { + if ($mode === 'none' || ! $published->source_version_id) { + return 0; + } + $query = Assignment::query()->where('organization_id', $this->tenant->id())->where('assignable_type', 'course')->where('assignable_id', $published->source_version_id)->where('status', 'active'); + if ($mode === 'selected') { + $query->whereIn('id', $selected); + } + + return DB::transaction(function () use ($query, $request, $published): int { + $count = 0; + foreach ($query->get() as $source) { + $copy = $source->replicate(['cancelled_at']); + $copy->assignable_id = $published->getKey(); + $copy->assigned_by = $request->user()->getKey(); + $copy->source = 'version_reassignment'; + $copy->save(); + $this->resolver->sync($copy); + $count++; + } + + return $count; + }); + } + + private function version(string $course, string $version): CourseVersion + { + Course::query()->where('organization_id', $this->tenant->id())->findOrFail($course); + + return CourseVersion::query()->where('organization_id', $this->tenant->id())->where('course_id', $course)->findOrFail($version); + } + + /** @return array */ + private function payload(CourseVersion $version): array + { + return [ + 'id' => $version->getKey(), 'status' => $version->status->value, 'completionRules' => $version->completion_rules, + 'reviewSubmittedAt' => $version->review_submitted_at?->toISOString(), 'publishedAt' => $version->published_at?->toISOString(), + 'scheduledPublishAt' => $version->scheduled_publish_at?->toISOString(), 'scheduledUnpublishAt' => $version->scheduled_unpublish_at?->toISOString(), + 'unpublishedAt' => $version->unpublished_at?->toISOString(), 'taxonomySnapshotCount' => count($version->taxonomy_snapshot ?? []), + ]; + } + + private function authorize(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::CoursesPublish), 403); + } +} diff --git a/backend/app/Modules/Subscriptions/Application/SeatQuota.php b/backend/app/Modules/Subscriptions/Application/SeatQuota.php new file mode 100644 index 0000000..5143328 --- /dev/null +++ b/backend/app/Modules/Subscriptions/Application/SeatQuota.php @@ -0,0 +1,44 @@ +where('organization_id', $organizationId) + ->where('status', 'active') + ->where('starts_at', '<=', now()) + ->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->latest('starts_at') + ->first(); + + if (! $subscription || $subscription->seat_limit === null) { + return; + } + + $users = User::query() + ->where('organization_id', $organizationId) + ->whereIn('status', [AccountStatus::Active, AccountStatus::Invited]) + ->count(); + $pendingInvitations = UserInvitation::query() + ->where('organization_id', $organizationId) + ->whereNull('accepted_at') + ->whereNull('revoked_at') + ->where('expires_at', '>', now()) + ->count(); + + if ($users + $pendingInvitations >= $subscription->seat_limit) { + throw ValidationException::withMessages([ + $errorField => ['The organization has reached its seat limit.'], + ]); + } + } +} diff --git a/backend/app/Modules/Subscriptions/Domain/Subscription.php b/backend/app/Modules/Subscriptions/Domain/Subscription.php new file mode 100644 index 0000000..0260663 --- /dev/null +++ b/backend/app/Modules/Subscriptions/Domain/Subscription.php @@ -0,0 +1,32 @@ + 'immutable_datetime', 'expires_at' => 'immutable_datetime', + 'seat_limit' => 'integer', 'storage_quota_bytes' => 'integer', 'storage_used_bytes' => 'integer', + 'ai_credit_quota' => 'integer', 'ai_credits_used' => 'integer', 'enabled_features' => 'array', 'metadata' => 'array', + ]; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } +} diff --git a/backend/app/Modules/Subscriptions/Domain/SubscriptionPlan.php b/backend/app/Modules/Subscriptions/Domain/SubscriptionPlan.php new file mode 100644 index 0000000..1e033d1 --- /dev/null +++ b/backend/app/Modules/Subscriptions/Domain/SubscriptionPlan.php @@ -0,0 +1,20 @@ + 'integer', 'storage_quota_bytes' => 'integer', 'ai_credit_quota' => 'integer', 'enabled_features' => 'array', 'metadata' => 'array']; + } +} diff --git a/backend/app/Modules/Subscriptions/Http/PlatformOrganizationPlanController.php b/backend/app/Modules/Subscriptions/Http/PlatformOrganizationPlanController.php new file mode 100644 index 0000000..384923a --- /dev/null +++ b/backend/app/Modules/Subscriptions/Http/PlatformOrganizationPlanController.php @@ -0,0 +1,56 @@ +authorize($request); + $organizationModel = Organization::query()->findOrFail($organization); + $subscription = Subscription::query()->where('organization_id', $organizationModel->getKey())->latest('starts_at')->first(); + + return response()->json(['data' => ['organizationId' => $organizationModel->getKey(), 'subscription' => $subscription ? ['id' => $subscription->getKey(), 'planKey' => $subscription->plan_key, 'status' => $subscription->status] : null, 'plans' => SubscriptionPlan::query()->where('status', 'active')->orderBy('name')->get(['id', 'key', 'name', 'seat_limit', 'storage_quota_bytes', 'ai_credit_quota'])]]); + } + + public function update(Request $request, string $organization): JsonResponse + { + $this->authorize($request); + $data = $request->validate(['planId' => ['required', 'string', 'exists:subscription_plans,id']]); + $organizationModel = Organization::query()->findOrFail($organization); + $plan = SubscriptionPlan::query()->where('status', 'active')->findOrFail($data['planId']); + $subscription = DB::transaction(function () use ($organizationModel, $plan) { + $subscription = Subscription::query()->where('organization_id', $organizationModel->getKey())->latest('starts_at')->lockForUpdate()->first(); + $attributes = ['plan_key' => $plan->key, 'seat_limit' => $plan->seat_limit, 'storage_quota_bytes' => $plan->storage_quota_bytes, 'ai_credit_quota' => $plan->ai_credit_quota, 'enabled_features' => $plan->enabled_features ?? [], 'status' => 'active']; + if ($subscription) { + $subscription->update($attributes); + + return $subscription->refresh(); + } + + return Subscription::query()->create($attributes + ['organization_id' => $organizationModel->getKey(), 'starts_at' => now()]); + }); + DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'organization_id' => $organizationModel->getKey(), 'actor_id' => $request->user()->getKey(), 'action' => 'organization.plan_assigned', 'entity_type' => 'subscription', 'entity_id' => $subscription->getKey(), 'metadata' => json_encode(['planKey' => $plan->key]), 'ip_address' => $request->ip(), 'created_at' => now()]); + + return response()->json(['data' => ['subscriptionId' => $subscription->getKey(), 'planKey' => $subscription->plan_key, 'status' => $subscription->status]]); + } + + private function authorize(Request $request): void + { + abort_unless($this->deployment->exposesSubscriptionManagement() && config('deployment.mode') === 'saas', 404); + abort_unless($this->permissions->allows($request->user(), Permission::PlatformManageOrganizations), 403); + } +} diff --git a/backend/app/Modules/Subscriptions/Http/PlatformPlanController.php b/backend/app/Modules/Subscriptions/Http/PlatformPlanController.php new file mode 100644 index 0000000..bcf4391 --- /dev/null +++ b/backend/app/Modules/Subscriptions/Http/PlatformPlanController.php @@ -0,0 +1,71 @@ +authorize($request); + $plans = SubscriptionPlan::query()->when($request->filled('status'), fn ($query) => $query->where('status', $request->string('status')))->orderBy('name')->get(); + + return response()->json(['data' => $plans->map($this->payload(...))->values()]); + } + + public function store(Request $request): JsonResponse + { + $this->authorize($request); + $plan = SubscriptionPlan::query()->create($this->validated($request)); + $this->audit($request, 'plan.created', $plan); + + return response()->json(['data' => $this->payload($plan)], 201); + } + + public function update(Request $request, string $plan): JsonResponse + { + $this->authorize($request); + $model = SubscriptionPlan::query()->findOrFail($plan); + $model->update($this->validated($request, false)); + $this->audit($request, 'plan.updated', $model); + + return response()->json(['data' => $this->payload($model->refresh())]); + } + + private function authorize(Request $request): void + { + abort_unless($this->deployment->exposesSubscriptionManagement() && config('deployment.mode') === 'saas', 404); + abort_unless($this->permissions->allows($request->user(), Permission::PlatformManageOrganizations), 403); + } + + private function validated(Request $request, bool $create = true): array + { + $rules = ['key' => [$create ? 'required' : 'sometimes', 'string', 'max:80', 'alpha_dash'], 'name' => [$create ? 'required' : 'sometimes', 'string', 'max:160'], 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], 'status' => ['sometimes', 'in:active,inactive'], 'seatLimit' => ['sometimes', 'nullable', 'integer', 'min:0'], 'storageQuotaBytes' => ['sometimes', 'nullable', 'integer', 'min:0'], 'aiCreditQuota' => ['sometimes', 'nullable', 'integer', 'min:0'], 'enabledFeatures' => ['sometimes', 'array', 'max:100'], 'enabledFeatures.*' => ['string', 'max:80']]; + $data = $request->validate($rules); + if ($create) { + $data['status'] ??= 'active'; + } + + return ['key' => $data['key'] ?? null, 'name' => $data['name'] ?? null, 'description' => $data['description'] ?? null, 'status' => $data['status'] ?? null, 'seat_limit' => $data['seatLimit'] ?? null, 'storage_quota_bytes' => $data['storageQuotaBytes'] ?? null, 'ai_credit_quota' => $data['aiCreditQuota'] ?? null, 'enabled_features' => $data['enabledFeatures'] ?? null]; + } + + private function payload(SubscriptionPlan $plan): array + { + return ['id' => $plan->getKey(), 'key' => $plan->key, 'name' => $plan->name, 'description' => $plan->description, 'status' => $plan->status, 'seatLimit' => $plan->seat_limit, 'storageQuotaBytes' => $plan->storage_quota_bytes, 'aiCreditQuota' => $plan->ai_credit_quota, 'enabledFeatures' => $plan->enabled_features ?? [], 'createdAt' => $plan->created_at?->toISOString(), 'updatedAt' => $plan->updated_at?->toISOString()]; + } + + private function audit(Request $request, string $action, SubscriptionPlan $plan): void + { + DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'actor_id' => $request->user()->getKey(), 'action' => $action, 'entity_type' => 'subscription_plan', 'entity_id' => $plan->getKey(), 'metadata' => json_encode(['key' => $plan->key]), 'ip_address' => $request->ip(), 'created_at' => now()]); + } +} diff --git a/backend/app/Modules/Subscriptions/Http/PlatformSubscriptionController.php b/backend/app/Modules/Subscriptions/Http/PlatformSubscriptionController.php new file mode 100644 index 0000000..15a195d --- /dev/null +++ b/backend/app/Modules/Subscriptions/Http/PlatformSubscriptionController.php @@ -0,0 +1,91 @@ +authorize($request); + $query = Subscription::query()->with('organization')->latest('starts_at'); + if ($search = trim((string) $request->query('search', ''))) { + $query->whereHas('organization', fn ($organization) => $organization->where('name', 'like', "%{$search}%")); + } + if ($status = $request->query('status')) { + $query->where('status', $status); + } + if ($plan = $request->query('plan')) { + $query->where('plan_key', $plan); + } + $subscriptions = $query->paginate(min(max($request->integer('perPage', 20), 1), 100)); + + return response()->json(['data' => $subscriptions->getCollection()->map($this->payload(...)), 'meta' => ['currentPage' => $subscriptions->currentPage(), 'lastPage' => $subscriptions->lastPage(), 'perPage' => $subscriptions->perPage(), 'total' => $subscriptions->total()]]); + } + + public function store(Request $request): JsonResponse + { + $this->authorize($request); + $data = $this->validated($request, true); + $organization = Organization::query()->findOrFail($data['organizationId']); + $subscription = Subscription::query()->create($this->attributes($data)); + $this->audit($request, 'subscription.created', $subscription); + + return response()->json(['data' => $this->payload($subscription->load('organization'))], 201); + } + + public function update(Request $request, string $subscription): JsonResponse + { + $this->authorize($request); + $model = Subscription::query()->findOrFail($subscription); + $data = $this->validated($request, false); + $model->update($this->attributes($data, false)); + $this->audit($request, 'subscription.updated', $model); + + return response()->json(['data' => $this->payload($model->refresh()->load('organization'))]); + } + + private function authorize(Request $request): void + { + abort_unless($this->deployment->exposesSubscriptionManagement() && config('deployment.mode') === 'saas', 404); + abort_unless($this->permissions->allows($request->user(), Permission::PlatformManageOrganizations), 403); + } + + private function validated(Request $request, bool $create): array + { + return $request->validate(array_filter(['organizationId' => [$create ? 'required' : 'sometimes', 'string', 'exists:organizations,id'], 'planKey' => [$create ? 'required' : 'sometimes', 'string', 'max:80'], 'status' => [$create ? 'required' : 'sometimes', 'in:active,suspended,expired'], 'startsAt' => [$create ? 'required' : 'sometimes', 'date'], 'expiresAt' => ['sometimes', 'nullable', 'date', 'after_or_equal:startsAt'], 'seatLimit' => ['sometimes', 'nullable', 'integer', 'min:0'], 'storageQuotaBytes' => ['sometimes', 'nullable', 'integer', 'min:0'], 'aiCreditQuota' => ['sometimes', 'nullable', 'integer', 'min:0'], 'enabledFeatures' => ['sometimes', 'array', 'max:50'], 'enabledFeatures.*' => ['string', 'max:80']], fn ($rules) => $create || $rules !== null)); + } + + private function attributes(array $data, bool $create = true): array + { + $attributes = []; + foreach (['organizationId' => 'organization_id', 'planKey' => 'plan_key', 'status' => 'status', 'startsAt' => 'starts_at', 'expiresAt' => 'expires_at', 'seatLimit' => 'seat_limit', 'storageQuotaBytes' => 'storage_quota_bytes', 'aiCreditQuota' => 'ai_credit_quota', 'enabledFeatures' => 'enabled_features'] as $input => $column) { + if ($create || array_key_exists($input, $data)) { + $attributes[$column] = $data[$input] ?? null; + } + } + + return $attributes; + } + + private function payload(Subscription $subscription): array + { + return ['id' => $subscription->getKey(), 'organization' => ['id' => $subscription->organization?->getKey(), 'name' => $subscription->organization?->name], 'planKey' => $subscription->plan_key, 'status' => $subscription->status, 'startsAt' => $subscription->starts_at?->toISOString(), 'expiresAt' => $subscription->expires_at?->toISOString(), 'seatLimit' => $subscription->seat_limit, 'storageQuotaBytes' => $subscription->storage_quota_bytes, 'storageUsedBytes' => $subscription->storage_used_bytes, 'aiCreditQuota' => $subscription->ai_credit_quota, 'aiCreditsUsed' => $subscription->ai_credits_used, 'enabledFeatures' => $subscription->enabled_features ?? []]; + } + + private function audit(Request $request, string $action, Subscription $subscription): void + { + DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'organization_id' => $subscription->organization_id, 'actor_id' => $request->user()->getKey(), 'action' => $action, 'entity_type' => 'subscription', 'entity_id' => $subscription->getKey(), 'metadata' => json_encode(['schemaVersion' => 1]), 'ip_address' => $request->ip(), 'created_at' => now()]); + } +} diff --git a/backend/app/Modules/Subscriptions/Http/SubscriptionController.php b/backend/app/Modules/Subscriptions/Http/SubscriptionController.php new file mode 100644 index 0000000..a8713b1 --- /dev/null +++ b/backend/app/Modules/Subscriptions/Http/SubscriptionController.php @@ -0,0 +1,46 @@ +permissions->allows($request->user(), Permission::SubscriptionView), 403); + $subscription = Subscription::query() + ->where('organization_id', $this->tenant->id()) + ->where('status', 'active') + ->where('starts_at', '<=', now()) + ->where(fn ($query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->latest('starts_at') + ->first(); + + $seatUsed = User::query()->where('organization_id', $this->tenant->id())->whereIn('status', [AccountStatus::Active, AccountStatus::Invited])->count() + + UserInvitation::query()->where('organization_id', $this->tenant->id())->whereNull('accepted_at')->whereNull('revoked_at')->where('expires_at', '>', now())->count(); + + return response()->json(['data' => $subscription ? [ + 'id' => $subscription->getKey(), 'planKey' => $subscription->plan_key, + 'status' => $subscription->status, 'startsAt' => $subscription->starts_at->toISOString(), + 'expiresAt' => $subscription->expires_at?->toISOString(), 'seatLimit' => $subscription->seat_limit, + 'storageQuotaBytes' => $subscription->storage_quota_bytes, + 'storageUsedBytes' => $subscription->storage_used_bytes, + 'aiCreditQuota' => $subscription->ai_credit_quota, + 'aiCreditsUsed' => $subscription->ai_credits_used, + 'seatUsed' => $seatUsed, + 'enabledFeatures' => $subscription->enabled_features ?? [], + ] : null]); + } +} diff --git a/backend/app/Modules/System/Application/QueueHeartbeat.php b/backend/app/Modules/System/Application/QueueHeartbeat.php new file mode 100644 index 0000000..6d2876b --- /dev/null +++ b/backend/app/Modules/System/Application/QueueHeartbeat.php @@ -0,0 +1,20 @@ +addMinutes(5)); + } +} diff --git a/backend/app/Modules/System/Domain/DeploymentCapabilities.php b/backend/app/Modules/System/Domain/DeploymentCapabilities.php new file mode 100644 index 0000000..b0a853e --- /dev/null +++ b/backend/app/Modules/System/Domain/DeploymentCapabilities.php @@ -0,0 +1,12 @@ +probe(fn () => DB::select('select 1')); + $storage = $this->probe(function (): void { + $path = 'health/'.gethostname().'.probe'; + Storage::disk(config('filesystems.default'))->put($path, now()->toISOString()); + Storage::disk(config('filesystems.default'))->delete($path); + }); + $heartbeat = Cache::get('system:scheduler-heartbeat'); + $queueHeartbeat = Cache::get('system:queue-heartbeat'); + $queue = config('queue.default') === 'sync' + ? 'local' + : ($queueHeartbeat && now()->diffInMinutes($queueHeartbeat) < 3 ? 'ok' : 'degraded'); + $services = [ + 'api' => 'ok', 'database' => $database, 'storage' => $storage, + 'queue' => $queue, + 'scheduler' => $heartbeat && now()->diffInMinutes($heartbeat) < 3 ? 'ok' : 'degraded', + 'mail' => config('mail.default') === 'log' ? 'local' : 'configured', + 'websocket' => config('broadcasting.default') === 'log' ? 'degraded' : 'configured', + 'exportWorker' => $queue, + 'aiProvider' => config('ai.provider', 'local-structuring'), + ]; + $ok = $database === 'ok' && $storage === 'ok'; + + return response()->json(['data' => ['status' => $ok ? 'ok' : 'degraded', 'deploymentMode' => $deployment->mode()->value, 'services' => $services, 'checkedAt' => now()->toISOString()]], $ok ? 200 : 503); + } + + private function probe(callable $callback): string + { + try { + $callback(); + + return 'ok'; + } catch (Throwable) { + return 'failed'; + } + } +} diff --git a/backend/app/Modules/System/Infrastructure/ConfiguredDeploymentCapabilities.php b/backend/app/Modules/System/Infrastructure/ConfiguredDeploymentCapabilities.php new file mode 100644 index 0000000..5eaf00a --- /dev/null +++ b/backend/app/Modules/System/Infrastructure/ConfiguredDeploymentCapabilities.php @@ -0,0 +1,26 @@ +deploymentMode; + } + + public function supportsMultipleOrganizations(): bool + { + return $this->deploymentMode === DeploymentMode::Saas; + } + + public function exposesSubscriptionManagement(): bool + { + return $this->deploymentMode === DeploymentMode::Saas; + } +} diff --git a/backend/app/Modules/System/Infrastructure/SystemServiceProvider.php b/backend/app/Modules/System/Infrastructure/SystemServiceProvider.php new file mode 100644 index 0000000..67cc1f8 --- /dev/null +++ b/backend/app/Modules/System/Infrastructure/SystemServiceProvider.php @@ -0,0 +1,20 @@ +app->singleton( + DeploymentCapabilities::class, + fn (): ConfiguredDeploymentCapabilities => new ConfiguredDeploymentCapabilities( + DeploymentMode::from(config('deployment.mode')), + ), + ); + } +} diff --git a/backend/app/Modules/Taxonomy/Application/ContentMappingService.php b/backend/app/Modules/Taxonomy/Application/ContentMappingService.php new file mode 100644 index 0000000..cef939b --- /dev/null +++ b/backend/app/Modules/Taxonomy/Application/ContentMappingService.php @@ -0,0 +1,84 @@ +where('organization_id', $organizationId)->findOrFail($courseVersionId); + + if ($version->status === CourseVersionStatus::Published) { + $this->invalid('courseVersionId', 'Published course versions cannot receive new mappings.'); + } + + TaxonomyNode::query()->where('organization_id', $organizationId)->findOrFail($taxonomyNodeId); + $this->resolveTarget($organizationId, $version, $mappableType, $mappableId); + + $confirmation = $source === MappingSource::AiSuggested + ? MappingConfirmationStatus::Draft + : MappingConfirmationStatus::Confirmed; + + return ContentTaxonomyMapping::query()->create([ + 'organization_id' => $organizationId, + 'course_version_id' => $version->getKey(), + 'mappable_type' => $mappableType, + 'mappable_id' => $mappableId, + 'taxonomy_node_id' => $taxonomyNodeId, + 'mapping_type' => $mappingType, + 'mastery_level' => $masteryLevel, + 'weight' => $weight, + 'source' => $source, + 'confidence' => $confidence, + 'confirmation_status' => $confirmation, + 'confirmed_by' => $confirmation === MappingConfirmationStatus::Confirmed ? $actor->getKey() : null, + 'confirmed_at' => $confirmation === MappingConfirmationStatus::Confirmed ? now() : null, + ]); + } + + private function resolveTarget(string $organizationId, CourseVersion $version, MappableType $type, string $id): Model + { + $modelClass = $type->modelClass(); + $query = $modelClass::query()->where('organization_id', $organizationId); + + if ($type !== MappableType::Course) { + $query->where('course_version_id', $version->getKey()); + } + + $target = $query->find($id); + + if (! $target || ($type === MappableType::Course && $target->getKey() !== $version->getKey())) { + $this->invalid('mappableId', 'The selected content does not belong to this course version.'); + } + + return $target; + } + + private function invalid(string $field, string $message): never + { + throw ValidationException::withMessages([$field => [$message]]); + } +} diff --git a/backend/app/Modules/Taxonomy/Application/TaxonomyHierarchy.php b/backend/app/Modules/Taxonomy/Application/TaxonomyHierarchy.php new file mode 100644 index 0000000..c51bcee --- /dev/null +++ b/backend/app/Modules/Taxonomy/Application/TaxonomyHierarchy.php @@ -0,0 +1,48 @@ +invalidParent(); + } + + $parent = TaxonomyNode::query() + ->where('organization_id', $organizationId) + ->where('taxonomy_type_id', $taxonomyTypeId) + ->find($parentId); + + if (! $parent) { + $this->invalidParent('The selected parent does not belong to this taxonomy.'); + } + + $visited = []; + $cursor = $parent; + + while ($cursor !== null) { + if (isset($visited[$cursor->getKey()]) || $cursor->getKey() === $movingNodeId) { + $this->invalidParent('The selected parent would create a circular hierarchy.'); + } + + $visited[$cursor->getKey()] = true; + $cursor = $cursor->parent_id + ? TaxonomyNode::query()->where('organization_id', $organizationId)->find($cursor->parent_id) + : null; + } + } + + private function invalidParent(string $message = 'A taxonomy node cannot be its own parent.'): never + { + throw ValidationException::withMessages(['parentId' => [$message]]); + } +} diff --git a/backend/app/Modules/Taxonomy/Domain/ContentTaxonomyMapping.php b/backend/app/Modules/Taxonomy/Domain/ContentTaxonomyMapping.php new file mode 100644 index 0000000..82dac0d --- /dev/null +++ b/backend/app/Modules/Taxonomy/Domain/ContentTaxonomyMapping.php @@ -0,0 +1,62 @@ + MappableType::class, + 'mapping_type' => MappingType::class, + 'source' => MappingSource::class, + 'confirmation_status' => MappingConfirmationStatus::class, + 'weight' => 'decimal:4', + 'confidence' => 'decimal:4', + 'confirmed_at' => 'immutable_datetime', + ]; + } + + protected static function booted(): void + { + $assertDraftVersion = function (self $mapping): void { + $version = CourseVersion::query()->findOrFail($mapping->course_version_id); + + if ($version->status === CourseVersionStatus::Published) { + throw new DomainException('Mappings for published course versions are immutable.'); + } + }; + + static::updating($assertDraftVersion); + static::deleting($assertDraftVersion); + } + + public function courseVersion(): BelongsTo + { + return $this->belongsTo(CourseVersion::class); + } + + public function taxonomyNode(): BelongsTo + { + return $this->belongsTo(TaxonomyNode::class); + } +} diff --git a/backend/app/Modules/Taxonomy/Domain/Enums/MappableType.php b/backend/app/Modules/Taxonomy/Domain/Enums/MappableType.php new file mode 100644 index 0000000..eabe474 --- /dev/null +++ b/backend/app/Modules/Taxonomy/Domain/Enums/MappableType.php @@ -0,0 +1,34 @@ + */ + public function modelClass(): string + { + return match ($this) { + self::Course => CourseVersion::class, + self::Module => CourseModule::class, + self::Lesson => Lesson::class, + self::Block => Block::class, + self::Question => Question::class, + self::Assessment => Assessment::class, + }; + } +} diff --git a/backend/app/Modules/Taxonomy/Domain/Enums/MappingConfirmationStatus.php b/backend/app/Modules/Taxonomy/Domain/Enums/MappingConfirmationStatus.php new file mode 100644 index 0000000..f22285b --- /dev/null +++ b/backend/app/Modules/Taxonomy/Domain/Enums/MappingConfirmationStatus.php @@ -0,0 +1,10 @@ + TaxonomyStatus::class, 'metadata' => 'array']; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function type(): BelongsTo + { + return $this->belongsTo(TaxonomyType::class, 'taxonomy_type_id'); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + public function children(): HasMany + { + return $this->hasMany(self::class, 'parent_id'); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } +} diff --git a/backend/app/Modules/Taxonomy/Domain/TaxonomyRelationship.php b/backend/app/Modules/Taxonomy/Domain/TaxonomyRelationship.php new file mode 100644 index 0000000..b4471fc --- /dev/null +++ b/backend/app/Modules/Taxonomy/Domain/TaxonomyRelationship.php @@ -0,0 +1,31 @@ + 'decimal:4', 'metadata' => 'array']; + } + + public function source(): BelongsTo + { + return $this->belongsTo(TaxonomyNode::class, 'source_node_id'); + } + + public function target(): BelongsTo + { + return $this->belongsTo(TaxonomyNode::class, 'target_node_id'); + } +} diff --git a/backend/app/Modules/Taxonomy/Domain/TaxonomyType.php b/backend/app/Modules/Taxonomy/Domain/TaxonomyType.php new file mode 100644 index 0000000..a103b64 --- /dev/null +++ b/backend/app/Modules/Taxonomy/Domain/TaxonomyType.php @@ -0,0 +1,32 @@ + TaxonomyStatus::class, 'metadata' => 'array']; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + public function nodes(): HasMany + { + return $this->hasMany(TaxonomyNode::class); + } +} diff --git a/backend/app/Modules/Taxonomy/Http/ContentMappingController.php b/backend/app/Modules/Taxonomy/Http/ContentMappingController.php new file mode 100644 index 0000000..9153961 --- /dev/null +++ b/backend/app/Modules/Taxonomy/Http/ContentMappingController.php @@ -0,0 +1,104 @@ +policy->view($request->user()), 403); + $request->validate([ + 'mappableType' => ['required', Rule::enum(MappableType::class)], + 'mappableId' => ['required', 'string'], + ]); + + $mappings = ContentTaxonomyMapping::query() + ->with('taxonomyNode.type') + ->where('organization_id', $this->tenant->id()) + ->where('mappable_type', $request->string('mappableType')) + ->where('mappable_id', $request->string('mappableId')) + ->get() + ->map(fn (ContentTaxonomyMapping $mapping) => $this->payload($mapping)); + + return response()->json(['data' => $mappings]); + } + + public function store(StoreContentMappingRequest $request): JsonResponse + { + abort_unless($this->policy->manage($request->user()), 403); + $data = $request->validated(); + $mapping = $this->service->create( + $this->tenant->id(), + $data['courseVersionId'], + MappableType::from($data['mappableType']), + $data['mappableId'], + $data['taxonomyNodeId'], + MappingType::from($data['mappingType']), + $data['masteryLevel'] ?? null, + (float) $data['weight'], + MappingSource::from($data['source']), + isset($data['confidence']) ? (float) $data['confidence'] : null, + $request->user(), + ); + + return response()->json(['data' => $this->payload($mapping->load('taxonomyNode.type'))], 201); + } + + public function destroy(Request $request, string $mapping): JsonResponse + { + abort_unless($this->policy->manage($request->user()), 403); + $model = ContentTaxonomyMapping::query() + ->where('organization_id', $this->tenant->id()) + ->findOrFail($mapping); + if ($model->courseVersion->status->value === 'published') { + throw ValidationException::withMessages(['courseVersionId' => ['Published course version mappings are immutable.']]); + } + $model->delete(); + + return response()->json(status: 204); + } + + private function payload(ContentTaxonomyMapping $mapping): array + { + return [ + 'id' => $mapping->getKey(), + 'courseVersionId' => $mapping->course_version_id, + 'entityType' => $mapping->mappable_type->value, + 'entityId' => $mapping->mappable_id, + 'mappingType' => $mapping->mapping_type->value, + 'masteryLevel' => $mapping->mastery_level, + 'weight' => (float) $mapping->weight, + 'source' => $mapping->source->value, + 'confidence' => $mapping->confidence === null ? null : (float) $mapping->confidence, + 'confirmationStatus' => $mapping->confirmation_status->value, + 'direct' => true, + 'inheritedFrom' => null, + 'taxonomyNode' => [ + 'id' => $mapping->taxonomyNode->getKey(), + 'name' => $mapping->taxonomyNode->name, + 'code' => $mapping->taxonomyNode->code, + 'type' => $mapping->taxonomyNode->type->key, + ], + ]; + } +} diff --git a/backend/app/Modules/Taxonomy/Http/Requests/StoreContentMappingRequest.php b/backend/app/Modules/Taxonomy/Http/Requests/StoreContentMappingRequest.php new file mode 100644 index 0000000..091ca1c --- /dev/null +++ b/backend/app/Modules/Taxonomy/Http/Requests/StoreContentMappingRequest.php @@ -0,0 +1,32 @@ + ['required', 'string'], + 'mappableType' => ['required', Rule::enum(MappableType::class)], + 'mappableId' => ['required', 'string'], + 'taxonomyNodeId' => ['required', 'string'], + 'mappingType' => ['required', Rule::enum(MappingType::class)], + 'masteryLevel' => ['nullable', Rule::in(['awareness', 'foundation', 'applied', 'advanced', 'expert'])], + 'weight' => ['required', 'numeric', 'gt:0', 'lte:1'], + 'source' => ['required', Rule::enum(MappingSource::class)], + 'confidence' => ['nullable', 'numeric', 'between:0,1'], + ]; + } +} diff --git a/backend/app/Modules/Taxonomy/Http/Requests/StoreTaxonomyNodeRequest.php b/backend/app/Modules/Taxonomy/Http/Requests/StoreTaxonomyNodeRequest.php new file mode 100644 index 0000000..d565b8e --- /dev/null +++ b/backend/app/Modules/Taxonomy/Http/Requests/StoreTaxonomyNodeRequest.php @@ -0,0 +1,25 @@ + ['required', 'string'], + 'parentId' => ['nullable', 'string'], + 'name' => ['required', 'string', 'max:200'], + 'description' => ['nullable', 'string', 'max:4000'], + 'code' => ['nullable', 'string', 'max:100'], + 'metadata' => ['sometimes', 'array'], + ]; + } +} diff --git a/backend/app/Modules/Taxonomy/Http/Requests/StoreTaxonomyTypeRequest.php b/backend/app/Modules/Taxonomy/Http/Requests/StoreTaxonomyTypeRequest.php new file mode 100644 index 0000000..16fb28b --- /dev/null +++ b/backend/app/Modules/Taxonomy/Http/Requests/StoreTaxonomyTypeRequest.php @@ -0,0 +1,24 @@ + ['required', 'string', 'max:80', 'regex:/^[a-z][a-z0-9_]*$/', Rule::unique('taxonomy_types')->where('organization_id', $this->user()->organization_id)], + 'name' => ['required', 'string', 'max:160'], + 'description' => ['nullable', 'string', 'max:2000'], + 'metadata' => ['sometimes', 'array'], + ]; + } +} diff --git a/backend/app/Modules/Taxonomy/Http/Requests/UpdateTaxonomyNodeRequest.php b/backend/app/Modules/Taxonomy/Http/Requests/UpdateTaxonomyNodeRequest.php new file mode 100644 index 0000000..26fb200 --- /dev/null +++ b/backend/app/Modules/Taxonomy/Http/Requests/UpdateTaxonomyNodeRequest.php @@ -0,0 +1,26 @@ + ['sometimes', 'nullable', 'string'], + 'name' => ['sometimes', 'string', 'max:200'], + 'description' => ['sometimes', 'nullable', 'string', 'max:4000'], + 'code' => ['sometimes', 'nullable', 'string', 'max:100'], + 'status' => ['sometimes', Rule::in(['active', 'archived'])], + 'metadata' => ['sometimes', 'array'], + ]; + } +} diff --git a/backend/app/Modules/Taxonomy/Http/TaxonomyNodeController.php b/backend/app/Modules/Taxonomy/Http/TaxonomyNodeController.php new file mode 100644 index 0000000..4455e6e --- /dev/null +++ b/backend/app/Modules/Taxonomy/Http/TaxonomyNodeController.php @@ -0,0 +1,103 @@ +policy->view($request->user()), 403); + + $nodes = TaxonomyNode::query() + ->with('type:id,key,name') + ->where('organization_id', $this->tenant->id()) + ->when($request->string('taxonomyTypeId')->isNotEmpty(), fn ($query) => $query->where('taxonomy_type_id', $request->string('taxonomyTypeId'))) + ->when($request->string('status')->isNotEmpty(), fn ($query) => $query->where('status', $request->string('status'))) + ->when($request->string('search')->isNotEmpty(), function ($query) use ($request) { + $search = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $request->string('search')).'%'; + $query->where(fn ($nested) => $nested->where('name', 'like', $search)->orWhere('code', 'like', $search)); + }) + ->orderBy('name') + ->limit(500) + ->get() + ->map(fn (TaxonomyNode $node) => $this->payload($node)); + + return response()->json(['data' => $nodes]); + } + + public function store(StoreTaxonomyNodeRequest $request): JsonResponse + { + abort_unless($this->policy->manage($request->user()), 403); + $data = $request->validated(); + $type = TaxonomyType::query()->where('organization_id', $this->tenant->id())->findOrFail($data['taxonomyTypeId']); + $this->hierarchy->assertValidParent($this->tenant->id(), $type->getKey(), $data['parentId'] ?? null); + + $node = TaxonomyNode::query()->create([ + 'organization_id' => $this->tenant->id(), + 'taxonomy_type_id' => $type->getKey(), + 'parent_id' => $data['parentId'] ?? null, + 'name' => $data['name'], + 'description' => $data['description'] ?? null, + 'code' => $data['code'] ?? null, + 'metadata' => $data['metadata'] ?? null, + 'status' => TaxonomyStatus::Active, + 'created_by' => $request->user()->getKey(), + ]); + + return response()->json(['data' => $this->payload($node->load('type:id,key,name'))], 201); + } + + public function update(UpdateTaxonomyNodeRequest $request, string $node): JsonResponse + { + abort_unless($this->policy->manage($request->user()), 403); + $taxonomyNode = TaxonomyNode::query()->where('organization_id', $this->tenant->id())->findOrFail($node); + $data = $request->validated(); + + if (array_key_exists('parentId', $data)) { + $this->hierarchy->assertValidParent($this->tenant->id(), $taxonomyNode->taxonomy_type_id, $data['parentId'], $taxonomyNode->getKey()); + $taxonomyNode->parent_id = $data['parentId']; + } + + foreach (['name', 'description', 'code', 'status', 'metadata'] as $attribute) { + if (array_key_exists($attribute, $data)) { + $taxonomyNode->{$attribute} = $data[$attribute]; + } + } + + $taxonomyNode->save(); + + return response()->json(['data' => $this->payload($taxonomyNode->load('type:id,key,name'))]); + } + + private function payload(TaxonomyNode $node): array + { + return [ + 'id' => $node->getKey(), + 'taxonomyType' => $node->type ? ['id' => $node->type->getKey(), 'key' => $node->type->key, 'name' => $node->type->name] : null, + 'parentId' => $node->parent_id, + 'name' => $node->name, + 'description' => $node->description, + 'code' => $node->code, + 'status' => $node->status->value, + 'metadata' => $node->metadata, + ]; + } +} diff --git a/backend/app/Modules/Taxonomy/Http/TaxonomyTypeController.php b/backend/app/Modules/Taxonomy/Http/TaxonomyTypeController.php new file mode 100644 index 0000000..6e940b7 --- /dev/null +++ b/backend/app/Modules/Taxonomy/Http/TaxonomyTypeController.php @@ -0,0 +1,55 @@ +policy->view($request->user()), 403); + + $types = TaxonomyType::query() + ->where('organization_id', $this->tenant->id()) + ->orderBy('name') + ->get() + ->map(fn (TaxonomyType $type) => $this->payload($type)); + + return response()->json(['data' => $types]); + } + + public function store(StoreTaxonomyTypeRequest $request): JsonResponse + { + abort_unless($this->policy->manage($request->user()), 403); + + $type = TaxonomyType::query()->create([ + ...$request->validated(), + 'organization_id' => $this->tenant->id(), + 'status' => TaxonomyStatus::Active, + ]); + + return response()->json(['data' => $this->payload($type)], 201); + } + + private function payload(TaxonomyType $type): array + { + return [ + 'id' => $type->getKey(), + 'key' => $type->key, + 'name' => $type->name, + 'description' => $type->description, + 'status' => $type->status->value, + 'metadata' => $type->metadata, + ]; + } +} diff --git a/backend/app/Modules/Taxonomy/Http/TaxonomyWorkspaceController.php b/backend/app/Modules/Taxonomy/Http/TaxonomyWorkspaceController.php new file mode 100644 index 0000000..0ea78d8 --- /dev/null +++ b/backend/app/Modules/Taxonomy/Http/TaxonomyWorkspaceController.php @@ -0,0 +1,128 @@ +policy->view($request->user()), 403); + $organizationId = $this->tenant->id(); + $nodes = DB::table('taxonomy_nodes')->where('organization_id', $organizationId)->get(); + $mappingCounts = DB::table('content_taxonomy_mappings')->where('organization_id', $organizationId)->selectRaw('taxonomy_node_id, count(*) as total')->groupBy('taxonomy_node_id')->pluck('total', 'taxonomy_node_id'); + $evidenceCounts = DB::table('evidence_records')->where('organization_id', $organizationId)->selectRaw('taxonomy_node_id, count(*) as total')->groupBy('taxonomy_node_id')->pluck('total', 'taxonomy_node_id'); + $coverage = DB::table('content_taxonomy_mappings as mapping')->join('taxonomy_nodes as node', 'node.id', '=', 'mapping.taxonomy_node_id')->where('mapping.organization_id', $organizationId)->orderBy('node.name')->get(['mapping.id', 'mapping.taxonomy_node_id', 'mapping.mappable_type', 'mapping.mappable_id', 'mapping.mapping_type', 'mapping.mastery_level', 'node.name as node_name']); + $labels = $this->contentLabels($coverage); + $coverageRows = $nodes->where('status', 'active')->map(function ($node) use ($coverage, $labels, $evidenceCounts) { + $connections = $coverage->where('taxonomy_node_id', $node->id)->values()->map(fn ($mapping) => ['id' => $mapping->id, 'type' => $mapping->mappable_type, 'entityId' => $mapping->mappable_id, 'label' => ($labels[$mapping->mappable_type] ?? [])[$mapping->mappable_id] ?? 'محتوای مرتبط', 'mappingType' => $mapping->mapping_type, 'masteryLevel' => $mapping->mastery_level]); + + return ['nodeId' => $node->id, 'name' => $node->name, 'courseCount' => $connections->where('type', 'course')->count(), 'moduleCount' => $connections->where('type', 'module')->count(), 'lessonCount' => $connections->where('type', 'lesson')->count(), 'evidenceCount' => (int) ($evidenceCounts[$node->id] ?? 0), 'connections' => $connections]; + })->values(); + + return response()->json(['data' => [ + 'overview' => ['active' => $nodes->where('status', 'active')->count(), 'archived' => $nodes->where('status', 'archived')->count(), 'uncovered' => $nodes->where('status', 'active')->filter(fn ($node) => ! isset($mappingCounts[$node->id]))->count(), 'withoutEvidence' => $nodes->where('status', 'active')->filter(fn ($node) => ! isset($evidenceCounts[$node->id]))->count(), 'recent' => $nodes->sortByDesc('updated_at')->take(6)->map(fn ($node) => ['id' => $node->id, 'name' => $node->name, 'updatedAt' => $node->updated_at])->values()], + 'frameworks' => $this->frameworks(), 'coverage' => $coverageRows, + 'audiences' => ['departments' => DB::table('users')->where('organization_id', $organizationId)->whereNotNull('department')->distinct()->orderBy('department')->pluck('department'), 'jobLevels' => DB::table('users')->where('organization_id', $organizationId)->whereNotNull('job_level')->distinct()->orderBy('job_level')->pluck('job_level'), 'teams' => DB::table('teams')->where('organization_id', $organizationId)->orderBy('name')->get(['id', 'name'])], + ]]); + } + + public function storeFramework(Request $request): JsonResponse + { + abort_unless($this->policy->manage($request->user()), 403); + $data = $this->frameworkData($request); + $id = (string) Str::ulid(); + DB::table('competency_frameworks')->insert(['id' => $id, 'organization_id' => $this->tenant->id(), ...$this->frameworkColumns($data), 'status' => 'active', 'created_by' => $request->user()->getKey(), 'created_at' => now(), 'updated_at' => now()]); + + return response()->json(['data' => $this->framework($id)], 201); + } + + public function updateFramework(Request $request, string $framework): JsonResponse + { + abort_unless($this->policy->manage($request->user()), 403); + $this->frameworkRow($framework); + $data = $this->frameworkData($request); + DB::table('competency_frameworks')->where('id', $framework)->update([...$this->frameworkColumns($data), 'updated_at' => now()]); + + return response()->json(['data' => $this->framework($framework)]); + } + + public function syncItems(Request $request, string $framework): JsonResponse + { + abort_unless($this->policy->manage($request->user()), 403); + $this->frameworkRow($framework); + $data = $request->validate(['items' => ['required', 'array', 'max:100'], 'items.*.taxonomyNodeId' => ['required', 'string', 'distinct'], 'items.*.masteryLevel' => ['required', Rule::in(['awareness', 'foundation', 'applied', 'advanced', 'expert'])], 'items.*.required' => ['required', 'boolean']]); + $ids = collect($data['items'])->pluck('taxonomyNodeId'); + abort_if($ids->count() !== DB::table('taxonomy_nodes')->where('organization_id', $this->tenant->id())->whereIn('id', $ids)->count(), 422, 'یک یا چند مهارت معتبر نیست.'); + DB::transaction(function () use ($framework, $data) { + DB::table('competency_framework_items')->where('framework_id', $framework)->delete(); + foreach ($data['items'] as $index => $item) { + DB::table('competency_framework_items')->insert(['id' => (string) Str::ulid(), 'organization_id' => $this->tenant->id(), 'framework_id' => $framework, 'taxonomy_node_id' => $item['taxonomyNodeId'], 'mastery_level' => $item['masteryLevel'], 'required' => $item['required'], 'position' => $index + 1, 'created_at' => now(), 'updated_at' => now()]); + } + }); + + return response()->json(['data' => $this->framework($framework)]); + } + + public function destroyFramework(Request $request, string $framework): JsonResponse + { + abort_unless($this->policy->manage($request->user()), 403); + $this->frameworkRow($framework); + DB::table('competency_frameworks')->where('id', $framework)->delete(); + + return response()->json(status: 204); + } + + private function frameworkData(Request $request): array + { + return $request->validate(['name' => ['required', 'string', 'max:200'], 'description' => ['nullable', 'string', 'max:2000'], 'audienceType' => ['required', Rule::in(['organization', 'department', 'job_level', 'team'])], 'audienceValue' => ['nullable', 'string', 'max:200']]); + } + + private function frameworkColumns(array $data): array + { + return ['name' => $data['name'], 'description' => $data['description'] ?? null, 'audience_type' => $data['audienceType'], 'audience_value' => $data['audienceValue'] ?? null]; + } + + private function frameworkRow(string $id): object + { + $framework = DB::table('competency_frameworks')->where('organization_id', $this->tenant->id())->where('id', $id)->first(); + abort_if($framework === null, 404); + + return $framework; + } + + private function frameworks(): array + { + return DB::table('competency_frameworks')->where('organization_id', $this->tenant->id())->orderBy('name')->get()->map(fn ($row) => $this->framework($row->id))->all(); + } + + private function framework(string $id): array + { + $row = $this->frameworkRow($id); + $items = DB::table('competency_framework_items as item')->join('taxonomy_nodes as node', 'node.id', '=', 'item.taxonomy_node_id')->where('item.framework_id', $id)->orderBy('item.position')->get(['item.taxonomy_node_id as taxonomyNodeId', 'node.name', 'item.mastery_level as masteryLevel', 'item.required']); + + return ['id' => $row->id, 'name' => $row->name, 'description' => $row->description, 'audienceType' => $row->audience_type, 'audienceValue' => $row->audience_value, 'status' => $row->status, 'items' => $items]; + } + + private function contentLabels($coverage): array + { + $tables = ['course' => 'course_versions', 'module' => 'course_modules', 'lesson' => 'lessons']; + $labels = []; + foreach ($tables as $type => $table) { + $ids = $coverage->where('mappable_type', $type)->pluck('mappable_id')->unique(); + $labels[$type] = DB::table($table)->whereIn('id', $ids)->pluck('title', 'id')->all(); + } + + return $labels; + } +} diff --git a/backend/app/Modules/Taxonomy/Policies/TaxonomyPolicy.php b/backend/app/Modules/Taxonomy/Policies/TaxonomyPolicy.php new file mode 100644 index 0000000..032c3f3 --- /dev/null +++ b/backend/app/Modules/Taxonomy/Policies/TaxonomyPolicy.php @@ -0,0 +1,22 @@ +permissions->allows($user, Permission::TaxonomyView); + } + + public function manage(User $user): bool + { + return $this->permissions->allows($user, Permission::TaxonomyManage); + } +} diff --git a/backend/app/Modules/Teams/Domain/Team.php b/backend/app/Modules/Teams/Domain/Team.php new file mode 100644 index 0000000..e7a664e --- /dev/null +++ b/backend/app/Modules/Teams/Domain/Team.php @@ -0,0 +1,25 @@ +belongsToMany(User::class, 'team_memberships')->withTimestamps(); + } + + public function managers(): BelongsToMany + { + return $this->belongsToMany(User::class, 'team_managers')->withTimestamps(); + } +} diff --git a/backend/app/Modules/Teams/Http/Requests/AttachTeamUserRequest.php b/backend/app/Modules/Teams/Http/Requests/AttachTeamUserRequest.php new file mode 100644 index 0000000..268e9f2 --- /dev/null +++ b/backend/app/Modules/Teams/Http/Requests/AttachTeamUserRequest.php @@ -0,0 +1,18 @@ + ['required', 'string']]; + } +} diff --git a/backend/app/Modules/Teams/Http/Requests/StoreTeamRequest.php b/backend/app/Modules/Teams/Http/Requests/StoreTeamRequest.php new file mode 100644 index 0000000..4727530 --- /dev/null +++ b/backend/app/Modules/Teams/Http/Requests/StoreTeamRequest.php @@ -0,0 +1,23 @@ + ['required', 'string', 'max:160', Rule::unique('teams')->where('organization_id', $this->user()->organization_id)], + 'description' => ['nullable', 'string', 'max:2000'], + 'managerId' => ['nullable', 'string'], + ]; + } +} diff --git a/backend/app/Modules/Teams/Http/Requests/UpdateTeamRequest.php b/backend/app/Modules/Teams/Http/Requests/UpdateTeamRequest.php new file mode 100644 index 0000000..97e6a6b --- /dev/null +++ b/backend/app/Modules/Teams/Http/Requests/UpdateTeamRequest.php @@ -0,0 +1,30 @@ + [ + 'sometimes', + 'required', + 'string', + 'max:160', + Rule::unique('teams') + ->where('organization_id', $this->user()->organization_id) + ->ignore($this->route('team')), + ], + 'description' => ['sometimes', 'nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/backend/app/Modules/Teams/Http/TeamController.php b/backend/app/Modules/Teams/Http/TeamController.php new file mode 100644 index 0000000..4fd60de --- /dev/null +++ b/backend/app/Modules/Teams/Http/TeamController.php @@ -0,0 +1,182 @@ +permissions->allows($request->user(), Permission::TeamsView), 403); + $query = Team::query()->where('organization_id', $this->tenant->id()); + + if ($request->user()->role === UserRole::Manager) { + $query->whereHas('managers', fn ($manager) => $manager->whereKey($request->user()->getKey())); + } + + $teams = $query->withCount(['members', 'managers'])->orderBy('name')->get()->map(fn (Team $team) => $this->payload($team)); + + return response()->json(['data' => $teams]); + } + + public function store(StoreTeamRequest $request): JsonResponse + { + $this->authorizeDesigner($request); + $team = DB::transaction(function () use ($request): Team { + $team = Team::query()->create([ + 'organization_id' => $this->tenant->id(), + 'name' => $request->validated('name'), + 'description' => $request->validated('description'), + 'status' => 'active', + 'created_by' => $request->user()->getKey(), + ]); + if ($request->filled('managerId')) { + $this->attachManagerAndReports($team, $this->manager($request->validated('managerId'))); + } + + return $team->loadCount(['members', 'managers']); + }); + + return response()->json(['data' => $this->payload($team)], 201); + } + + public function show(Request $request, string $team): JsonResponse + { + abort_unless($this->permissions->allows($request->user(), Permission::TeamsView), 403); + $query = Team::query()->where('organization_id', $this->tenant->id()); + if ($request->user()->role === UserRole::Manager) { + $query->whereHas('managers', fn ($manager) => $manager->whereKey($request->user()->getKey())); + } + $model = $query + ->withCount(['members', 'managers']) + ->with(['members:id,name,email,role,status,department,job_level,direct_manager_id', 'managers:id,name,email,role,status,department,job_level,direct_manager_id']) + ->findOrFail($team); + + return response()->json(['data' => array_merge($this->payload($model), [ + 'members' => $model->members->map(fn (User $user) => $this->userPayload($user)), + 'managers' => $model->managers->map(fn (User $user) => $this->userPayload($user)), + ])]); + } + + public function update(UpdateTeamRequest $request, string $team): JsonResponse + { + $this->authorizeDesigner($request); + $model = Team::query()->where('organization_id', $this->tenant->id())->findOrFail($team); + $model->update($request->safe()->only(['name', 'description'])); + + return response()->json(['data' => $this->payload($model)]); + } + + public function attachMember(AttachTeamUserRequest $request, string $team): JsonResponse + { + $this->authorizeDesigner($request); + [$targetTeam, $user] = $this->resolveTeamAndUser($team, $request->validated('userId')); + $targetTeam->members()->syncWithoutDetaching([$user->getKey()]); + $this->assignmentResolver->syncOrganization($this->tenant->id()); + + return response()->json(['data' => ['attached' => true]]); + } + + public function attachManager(AttachTeamUserRequest $request, string $team): JsonResponse + { + $this->authorizeDesigner($request); + $targetTeam = Team::query()->where('organization_id', $this->tenant->id())->findOrFail($team); + $this->attachManagerAndReports($targetTeam, $this->manager($request->validated('userId'))); + $this->assignmentResolver->syncOrganization($this->tenant->id()); + + return response()->json(['data' => ['attached' => true]]); + } + + public function detachMember(Request $request, string $team, string $user): JsonResponse + { + $this->authorizeDesigner($request); + [$targetTeam, $targetUser] = $this->resolveTeamAndUser($team, $user); + $targetTeam->members()->detach($targetUser); + + return response()->json(['data' => ['detached' => true]]); + } + + public function detachManager(Request $request, string $team, string $user): JsonResponse + { + $this->authorizeDesigner($request); + [$targetTeam, $targetUser] = $this->resolveTeamAndUser($team, $user); + $targetTeam->managers()->detach($targetUser); + + return response()->json(['data' => ['detached' => true]]); + } + + private function authorizeDesigner(Request $request): void + { + abort_unless($this->permissions->allows($request->user(), Permission::TeamsManage), 403); + } + + private function resolveTeamAndUser(string $team, string $user): array + { + $targetTeam = Team::query()->where('organization_id', $this->tenant->id())->findOrFail($team); + $targetUser = User::query()->where('organization_id', $this->tenant->id())->findOrFail($user); + + return [$targetTeam, $targetUser]; + } + + private function manager(string $user): User + { + $manager = User::query()->where('organization_id', $this->tenant->id())->findOrFail($user); + if ($manager->role !== UserRole::Manager) { + throw ValidationException::withMessages(['userId' => ['Only a Manager may manage a team.']]); + } + + return $manager; + } + + private function attachManagerAndReports(Team $team, User $manager): void + { + $team->managers()->syncWithoutDetaching([$manager->getKey()]); + $reports = User::query() + ->where('organization_id', $this->tenant->id()) + ->where('direct_manager_id', $manager->getKey()) + ->where('status', 'active') + ->pluck('id') + ->all(); + $team->members()->syncWithoutDetaching($reports); + } + + private function payload(Team $team): array + { + return [ + 'id' => $team->getKey(), 'name' => $team->name, 'description' => $team->description, + 'status' => $team->status, 'memberCount' => $team->members_count ?? 0, + 'managerCount' => $team->managers_count ?? 0, + ]; + } + + private function userPayload(User $user): array + { + return [ + 'id' => $user->getKey(), + 'name' => $user->name, + 'email' => $user->email, + 'role' => $user->role->value, + 'status' => $user->status->value, + 'department' => $user->department, + 'jobLevel' => $user->job_level, + 'directManagerId' => $user->direct_manager_id, + ]; + } +} diff --git a/backend/app/Modules/Tenancy/Application/TenantContext.php b/backend/app/Modules/Tenancy/Application/TenantContext.php new file mode 100644 index 0000000..e036e04 --- /dev/null +++ b/backend/app/Modules/Tenancy/Application/TenantContext.php @@ -0,0 +1,31 @@ +organization = $organization; + } + + public function organization(): Organization + { + return $this->organization ?? throw new LogicException('Tenant context has not been established.'); + } + + public function id(): string + { + return $this->organization()->getKey(); + } + + public function hasTenant(): bool + { + return $this->organization !== null; + } +} diff --git a/backend/app/Modules/Tenancy/Http/EstablishTenantContext.php b/backend/app/Modules/Tenancy/Http/EstablishTenantContext.php new file mode 100644 index 0000000..541f8cd --- /dev/null +++ b/backend/app/Modules/Tenancy/Http/EstablishTenantContext.php @@ -0,0 +1,38 @@ +user(); + + if (! $user) { + return new JsonResponse(['error' => ['code' => 'unauthenticated', 'message' => 'Authentication is required.']], 401); + } + + if ($user->role === UserRole::SuperAdmin) { + return new JsonResponse(['error' => ['code' => 'tenant_context_not_applicable', 'message' => 'This endpoint requires an organization context.']], 403); + } + + $organization = $user->organization; + + if (! $organization || $organization->status !== 'active') { + return new JsonResponse(['error' => ['code' => 'organization_unavailable', 'message' => 'The organization is unavailable.']], 403); + } + + $this->tenantContext->set($organization); + + return $next($request); + } +} diff --git a/backend/app/Modules/Tenancy/Infrastructure/TenancyServiceProvider.php b/backend/app/Modules/Tenancy/Infrastructure/TenancyServiceProvider.php new file mode 100644 index 0000000..7a8d476 --- /dev/null +++ b/backend/app/Modules/Tenancy/Infrastructure/TenancyServiceProvider.php @@ -0,0 +1,14 @@ +app->scoped(TenantContext::class, fn (): TenantContext => new TenantContext); + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..e965ece --- /dev/null +++ b/backend/app/Providers/AppServiceProvider.php @@ -0,0 +1,32 @@ +app->bind(RiskProvider::class, ExplainableHeuristicRiskProvider::class); + $this->app->bind(AiProvider::class, LocalStructuringProvider::class); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)->by(mb_strtolower((string) $request->input('email')).'|'.$request->ip())); + } +} diff --git a/backend/artisan b/backend/artisan new file mode 100644 index 0000000..c35e31d --- /dev/null +++ b/backend/artisan @@ -0,0 +1,18 @@ +#!/usr/bin/env php +handleCommand(new ArgvInput); + +exit($status); diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php new file mode 100644 index 0000000..9d098b3 --- /dev/null +++ b/backend/bootstrap/app.php @@ -0,0 +1,22 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware) { + $middleware->alias([ + 'tenant' => EstablishTenantContext::class, + ]); + }) + ->withExceptions(function (Exceptions $exceptions) { + // + })->create(); diff --git a/backend/bootstrap/cache/.gitignore b/backend/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/backend/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php new file mode 100644 index 0000000..052756b --- /dev/null +++ b/backend/bootstrap/providers.php @@ -0,0 +1,13 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dompdf/dompdf", + "version": "v3.1.6", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" + }, + "time": "2026-07-20T12:29:38+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "endroid/qr-code", + "version": "6.0.9", + "source": { + "type": "git", + "url": "https://github.com/endroid/qr-code.git", + "reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/endroid/qr-code/zipball/21e888e8597440b2205e2e5c484b6c8e556bcd1a", + "reference": "21e888e8597440b2205e2e5c484b6c8e556bcd1a", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "php": "^8.2" + }, + "require-dev": { + "endroid/quality": "dev-main", + "ext-gd": "*", + "khanamiryan/qrcode-detector-decoder": "^2.0.2", + "setasign/fpdf": "^1.8.2" + }, + "suggest": { + "ext-gd": "Enables you to write PNG images", + "khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator", + "roave/security-advisories": "Makes sure package versions with known security issues are not installed", + "setasign/fpdf": "Enables you to use the PDF writer" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Endroid\\QrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeroen van den Enden", + "email": "info@endroid.nl" + } + ], + "description": "Endroid QR Code", + "homepage": "https://github.com/endroid/qr-code", + "keywords": [ + "code", + "endroid", + "php", + "qr", + "qrcode" + ], + "support": { + "issues": "https://github.com/endroid/qr-code/issues", + "source": "https://github.com/endroid/qr-code/tree/6.0.9" + }, + "funding": [ + { + "url": "https://github.com/endroid", + "type": "github" + } + ], + "time": "2025-07-13T19:59:45+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.15.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5.2", + "guzzlehttp/psr7": "^2.13", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.15.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-08-05T19:48:21+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-05T19:30:54+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/dad89620b7a6edb60c15858442eb2e408b45d8f4", + "reference": "dad89620b7a6edb60c15858442eb2e408b45d8f4", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-07-16T22:23:49+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v1.0.10", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/f6c24c21f42b990e9a58912b332d0874df6ba839", + "reference": "f6c24c21f42b990e9a58912b332d0874df6ba839", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "uri-template/tests": "1.0.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v1.0.10" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-07-17T13:53:03+00:00" + }, + { + "name": "laravel/framework", + "version": "v12.65.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "99a8fb3153f962a323377d6742be08da86bcccb8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/99a8fb3153f962a323377d6742be08da86bcccb8", + "reference": "99a8fb3153f962a323377d6742be08da86bcccb8", + "shasum": "" + }, + "require": { + "brick/math": "^0.11|^0.12|^0.13|^0.14", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^3.2.1|^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2", + "guzzlehttp/uri-template": "^1.0", + "laravel/prompts": "^0.3.0", + "laravel/serializable-closure": "^1.3|^2.0", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.0", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.2.0", + "symfony/error-handler": "^7.2.0", + "symfony/finder": "^7.2.0", + "symfony/http-foundation": "^7.2.0", + "symfony/http-kernel": "^7.2.0", + "symfony/mailer": "^7.2.0", + "symfony/mime": "^7.2.0", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php84": "^1.34", + "symfony/polyfill-php85": "^1.34", + "symfony/process": "^7.2.0", + "symfony/routing": "^7.2.0", + "symfony/uid": "^7.2.0", + "symfony/var-dumper": "^7.2.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "psr/log-implementation": "1.0|2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "guzzlehttp/promises": "^2.0.3", + "guzzlehttp/psr7": "^2.4", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^10.9.0", + "pda/pheanstalk": "^5.0.6|^7.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.1.41", + "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1", + "predis/predis": "^2.3|^3.0", + "resend/resend-php": "^0.10.0|^1.0", + "symfony/cache": "^7.2.0", + "symfony/http-client": "^7.2.0", + "symfony/psr-http-message-bridge": "^7.2.0", + "symfony/translation": "^7.2.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0|^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).", + "predis/predis": "Required to use the predis connector (^2.3|^3.0).", + "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0|^1.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.2).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-08-05T15:33:16+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.22", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/02b89b39e8972a998db4d5d4ad4719239dd4aee4", + "reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.22" + }, + "time": "2026-08-04T14:50:50+00:00" + }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-06-23T18:26:55+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.15", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/dccd8bcb851bb03fcc005df650b708b57cc52661", + "reference": "dccd8bcb851bb03fcc005df650b708b57cc52661", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-07-21T16:49:22+00:00" + }, + { + "name": "laravel/tinker", + "version": "v2.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/c9f80cc835649b5c1842898fb043f8cc098dd741", + "reference": "c9f80cc835649b5c1842898fb043f8cc098dd741", + "shasum": "" + }, + "require": { + "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": "^7.2.5|^8.0", + "psy/psysh": "^0.11.1|^0.12.0", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.8|^9.3.3|^10.0" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v2.11.1" + }, + "time": "2026-02-06T14:12:35+00:00" + }, + { + "name": "league/commonmark", + "version": "2.9.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "72e9a87efcf41a8e83be3ed0866b69d77565cb12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/72e9a87efcf41a8e83be3ed0866b69d77565cb12", + "reference": "72e9a87efcf41a8e83be3ed0866b69d77565cb12", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.10-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-08-11T00:58:45+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.35.2", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "b277b5dc3d56650b68904117124e79c851e12376" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/b277b5dc3d56650b68904117124e79c851e12376", + "reference": "b277b5dc3d56650b68904117124e79c851e12376", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.35.2" + }, + "time": "2026-07-06T14:42:07+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.31.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "reference": "2f669db18a4c20c755c2bb7d3a7b0b2340488079", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.31.0" + }, + "time": "2026-01-23T15:30:45+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "masterminds/html5", + "version": "2.10.1", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29", + "reference": "fd5018f6815fff903946d0564977b44ce8010e29", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.1" + }, + "time": "2026-06-23T18:43:15+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.10.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-01-02T08:56:05+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.13.2", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-08-08T11:40:35+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.5", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", + "reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.5" + }, + "time": "2026-02-23T03:47:12+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.3", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "shasum": "" + }, + "require": { + "brick/math": ">=0.8.16 <=0.18", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.3" + }, + "time": "2026-06-18T03:57:49+00:00" + }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.4.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.5.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + }, + "time": "2026-06-18T15:10:53+00:00" + }, + { + "name": "smalot/pdfparser", + "version": "v2.12.0", + "source": { + "type": "git", + "url": "https://github.com/smalot/pdfparser.git", + "reference": "8440edbf58c8596074e78ada38dcb0bd041a5948" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/smalot/pdfparser/zipball/8440edbf58c8596074e78ada38dcb0bd041a5948", + "reference": "8440edbf58c8596074e78ada38dcb0bd041a5948", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-zlib": "*", + "php": ">=7.1", + "symfony/polyfill-mbstring": "^1.18" + }, + "type": "library", + "autoload": { + "psr-0": { + "Smalot\\PdfParser\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0" + ], + "authors": [ + { + "name": "Sebastien MALOT", + "email": "sebastien@malot.fr" + } + ], + "description": "Pdf parser library. Can read and extract information from pdf file.", + "homepage": "https://www.pdfparser.org", + "keywords": [ + "extract", + "parse", + "parser", + "pdf", + "text" + ], + "support": { + "issues": "https://github.com/smalot/pdfparser/issues", + "source": "https://github.com/smalot/pdfparser/tree/v2.12.0" + }, + "time": "2025-03-31T13:16:09+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.16", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "f4c69c9aed03abf933b294257d618bdd9b30a06d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/f4c69c9aed03abf933b294257d618bdd9b30a06d", + "reference": "f4c69c9aed03abf933b294257d618bdd9b30a06d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.16" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-31T12:37:14+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:18:21+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/d49f6a19f326db41ae7103bdc38e3eb35a791261", + "reference": "d49f6a19f326db41ae7103bdc38e3eb35a791261", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/336e7f3b9e95aba04f93ea9143920c2186abfbb9", + "reference": "336e7f3b9e95aba04f93ea9143920c2186abfbb9", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.14", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "13b38720174286f55d1761152b575a8d1436fc25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/13b38720174286f55d1761152b575a8d1436fc25", + "reference": "13b38720174286f55d1761152b575a8d1436fc25", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.14" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T08:31:18+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.16", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "b676451bb638e99a7d34d8a2be90406822e301eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/b676451bb638e99a7d34d8a2be90406822e301eb", + "reference": "b676451bb638e99a7d34d8a2be90406822e301eb", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.16" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T11:50:27+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.16", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "f5e728670fa2218ae8be8ea91f2b44b7d6e5304c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f5e728670fa2218ae8be8ea91f2b44b7d6e5304c", + "reference": "f5e728670fa2218ae8be8ea91f2b44b7d6e5304c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.16" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T18:00:13+00:00" + }, + { + "name": "symfony/mailer", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "68c1f27c97edd0222eb8d440a6c8c4da5354ab46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/68c1f27c97edd0222eb8d440a6c8c4da5354ab46", + "reference": "68c1f27c97edd0222eb8d440a6c8c4da5354ab46", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/mime": "^7.2|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/messenger": "<6.4", + "symfony/mime": "<6.4", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:33:02+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.16", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "20094b76a7106dbe978d31cd3bd7aa1ed248d0e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/20094b76a7106dbe978d31cd3bd7aa1ed248d0e5", + "reference": "20094b76a7106dbe978d31cd3bd7aa1ed248d0e5", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.16" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T14:56:57+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T15:22:23+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "reference": "5ea99087fb99c273a9b9236ed4c31e78b16103c6", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/80c0a93d3f8e7499f716204a1fb38ead942a7a2b", + "reference": "80c0a93d3f8e7499f716204a1fb38ead942a7a2b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/c0a284bab1ed8aa0417e3d69250ab437739563a0", + "reference": "c0a284bab1ed8aa0417e3d69250ab437739563a0", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-16T09:55:08+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/e394af32256bf9e7bf80849d95e589167c10097b", + "reference": "e394af32256bf9e7bf80849d95e589167c10097b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:33:02+00:00" + }, + { + "name": "symfony/translation", + "version": "v7.4.16", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "501e0ff4553c744209ca1a68790d8a4541563710" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/501e0ff4553c744209ca1a68790d8a4541563710", + "reference": "501e0ff4553c744209ca1a68790d8a4541563710", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5.3|^3.3" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<6.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v7.4.16" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-30T12:37:26+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T15:19:22+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", + "reference": "04ba4add636a95ff437af3a5a9499bb1d6c6d4bd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.4", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/416df702837983f8d5ff48c9c3fee4f5f57b980b", + "reference": "416df702837983f8d5ff48c9c3fee4f5f57b980b", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-07-06T19:11:50+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v2.1.1", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "reference": "f8b1c0173b22fa6ec77a81fe63e5b01eba7e6487", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v2.1.1" + }, + "time": "2025-04-30T06:54:44+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.30.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/a96cb6eee2961905d2fce7207aefb80945bf6b28", + "reference": "a96cb6eee2961905d2fce7207aefb80945bf6b28", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "composer/semver": "^3.4.4", + "friendsofphp/php-cs-fixer": "^3.95.18", + "illuminate/view": "^12.65.0", + "larastan/larastan": "^3.10.0", + "laravel-zero/framework": "^12.1.0", + "laravel/agent-detector": "^2.0.2", + "laravel/prompts": "^0.3.22", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.7" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-08-05T16:47:22+00:00" + }, + { + "name": "laravel/sail", + "version": "v1.65.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sail.git", + "reference": "d4b92139858d2a189a6302ca133e494f58afe20b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sail/zipball/d4b92139858d2a189a6302ca133e494f58afe20b", + "reference": "d4b92139858d2a189a6302ca133e494f58afe20b", + "shasum": "" + }, + "require": { + "illuminate/console": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.52.16|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/yaml": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.0" + }, + "bin": [ + "bin/sail" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sail\\SailServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Docker files for running a basic Laravel application.", + "keywords": [ + "docker", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/sail/issues", + "source": "https://github.com/laravel/sail" + }, + "time": "2026-08-03T18:00:17+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.12", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "reference": "1f4efdd7d3beafe9807b08156dfcb176d18f1699", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0.1", + "lib-pcre": ">=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.5", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-07-15T19:09:14+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.12", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56", + "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.1", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.3.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.46" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2025-12-24T07:01:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T13:52:54+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.56", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5f83edffa6967c3db468d48a695ec7bcb02e9256", + "reference": "5f83edffa6967c3db468d48a695ec7bcb02e9256", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.12", + "phpunit/php-file-iterator": "^5.1.1", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.3", + "sebastian/comparator": "^6.3.3", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.1", + "sebastian/exporter": "^6.3.2", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/recursion-context": "^6.0.3", + "sebastian/type": "^5.1.3", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.56" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:52:39+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:41:36+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-03-19T07:56:08+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:45:54+00:00" + }, + { + "name": "sebastian/comparator", + "version": "6.3.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.4" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:26:40+00:00" + }, + { + "name": "sebastian/complexity", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:49:50+00:00" + }, + { + "name": "sebastian/diff", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:53:05+00:00" + }, + { + "name": "sebastian/environment", + "version": "7.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4", + "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2025-05-21T11:55:47+00:00" + }, + { + "name": "sebastian/exporter", + "version": "6.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74", + "reference": "70a298763b40b213ec087c51c739efcaa90bcd74", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:12:51+00:00" + }, + { + "name": "sebastian/global-state", + "version": "7.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:57:36+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T04:58:38+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:00:13+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:01:32+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:42:22+00:00" + }, + { + "name": "sebastian/type", + "version": "5.1.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2025-08-09T06:55:48+00:00" + }, + { + "name": "sebastian/version", + "version": "5.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-10-09T05:16:32+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.15", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "e101850ded5d2c0d44bf32abb8996404afec2dec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/e101850ded5d2c0d44bf32abb8996404afec2dec", + "reference": "e101850ded5d2c0d44bf32abb8996404afec2dec", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.15" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-21T15:13:06+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.2" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/backend/config/ai.php b/backend/config/ai.php new file mode 100644 index 0000000..a0f3ce3 --- /dev/null +++ b/backend/config/ai.php @@ -0,0 +1,22 @@ + [ + 'base_url' => env('AI_OPENAI_BASE_URL', 'https://api.openai.com/v1'), + 'api_key' => env('AI_OPENAI_API_KEY'), + 'models' => [ + 'fast' => env('AI_OPENAI_MODEL_FAST', 'gpt-4.1-mini'), + 'balanced' => env('AI_OPENAI_MODEL_BALANCED', 'gpt-4.1-mini'), + 'advanced' => env('AI_OPENAI_MODEL_ADVANCED', 'gpt-4.1-mini'), + ], + 'timeout' => (int) env('AI_OPENAI_TIMEOUT', 90), + ], + + 'security' => [ + 'custom_provider_allowed_hosts' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env('AI_CUSTOM_PROVIDER_ALLOWED_HOSTS', '')), + ))), + ], + +]; diff --git a/backend/config/app.php b/backend/config/app.php new file mode 100644 index 0000000..c51504b --- /dev/null +++ b/backend/config/app.php @@ -0,0 +1,128 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + 'frontend_url' => env('FRONTEND_URL', 'http://127.0.0.1:5173'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/backend/config/auth.php b/backend/config/auth.php new file mode 100644 index 0000000..9daae00 --- /dev/null +++ b/backend/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the amount of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/backend/config/cache.php b/backend/config/cache.php new file mode 100644 index 0000000..925f7d2 --- /dev/null +++ b/backend/config/cache.php @@ -0,0 +1,108 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "octane", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), + +]; diff --git a/backend/config/capability.php b/backend/config/capability.php new file mode 100644 index 0000000..aa975d6 --- /dev/null +++ b/backend/config/capability.php @@ -0,0 +1,23 @@ + [ + 'exposure' => 0.10, + 'knowledge_check' => 0.30, + 'quiz' => 0.55, + 'assessment' => 0.80, + 'scenario' => 0.85, + 'interactive_exercise' => 0.60, + 'repeated_performance' => 0.90, + 'manual' => 0.50, + 'external' => 0.50, + ], + 'scoring_model_version' => 'v1', + 'recency_half_life_days' => 365, + 'minimum_effective_evidence' => 0.5, + 'confidence' => [ + 'medium_threshold' => 0.40, + 'high_threshold' => 0.70, + 'exposure_only_cap' => 0.30, + ], +]; diff --git a/backend/config/database.php b/backend/config/database.php new file mode 100644 index 0000000..d09c01c --- /dev/null +++ b/backend/config/database.php @@ -0,0 +1,182 @@ + env('DB_CONNECTION', 'mysql'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + ], + + 'sqlite_import' => [ + 'driver' => 'sqlite', + 'database' => env('SQLITE_IMPORT_PATH', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => true, + 'busy_timeout' => 5000, + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => 'prefer', + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + ], + + ], + +]; diff --git a/backend/config/deployment.php b/backend/config/deployment.php new file mode 100644 index 0000000..58666ae --- /dev/null +++ b/backend/config/deployment.php @@ -0,0 +1,5 @@ + env('DEPLOYMENT_MODE', 'saas'), +]; diff --git a/backend/config/exports.php b/backend/config/exports.php new file mode 100644 index 0000000..7fbcd2c --- /dev/null +++ b/backend/config/exports.php @@ -0,0 +1,9 @@ + env('EXPORT_DISK', env('FILESYSTEM_DISK', 'local')), + 'ffmpeg_binary' => env('FFMPEG_BINARY', 'ffmpeg'), + 'ffmpeg_font' => env('FFMPEG_FONT', ''), + 'ffmpeg_timeout' => (int) env('FFMPEG_TIMEOUT', 600), + 'retention_days' => (int) env('EXPORT_RETENTION_DAYS', 30), +]; diff --git a/backend/config/filesystems.php b/backend/config/filesystems.php new file mode 100644 index 0000000..3d671bd --- /dev/null +++ b/backend/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/backend/config/logging.php b/backend/config/logging.php new file mode 100644 index 0000000..8d94292 --- /dev/null +++ b/backend/config/logging.php @@ -0,0 +1,132 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'days' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'with' => [ + 'stream' => 'php://stderr', + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/backend/config/mail.php b/backend/config/mail.php new file mode 100644 index 0000000..756305b --- /dev/null +++ b/backend/config/mail.php @@ -0,0 +1,116 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', 'Example'), + ], + +]; diff --git a/backend/config/monitoring.php b/backend/config/monitoring.php new file mode 100644 index 0000000..c666fb0 --- /dev/null +++ b/backend/config/monitoring.php @@ -0,0 +1,21 @@ + [ + 'completion' => 0.30, + 'engagement' => 0.25, + 'assessment' => 0.20, + 'on_time' => 0.15, + 'inverse_risk' => 0.10, + ], + 'risk_provider' => 'explainable-heuristic', + 'risk_provider_version' => '1.0', + 'risk_weights' => [ + 'inactivity' => 30, + 'deadline' => 25, + 'low_progress' => 25, + 'assessment_failure' => 20, + ], + 'inactivity_days' => 7, + 'due_soon_days' => 7, +]; diff --git a/backend/config/queue.php b/backend/config/queue.php new file mode 100644 index 0000000..62c5905 --- /dev/null +++ b/backend/config/queue.php @@ -0,0 +1,112 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'mysql'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'mysql'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/backend/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/backend/config/services.php b/backend/config/services.php new file mode 100644 index 0000000..27a3617 --- /dev/null +++ b/backend/config/services.php @@ -0,0 +1,38 @@ + [ + 'token' => env('POSTMARK_TOKEN'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'resend' => [ + 'key' => env('RESEND_KEY'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/backend/config/session.php b/backend/config/session.php new file mode 100644 index 0000000..ba0aa60 --- /dev/null +++ b/backend/config/session.php @@ -0,0 +1,217 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "apc", "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug(env('APP_NAME', 'laravel'), '_').'_session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain and all subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + +]; diff --git a/backend/database/.gitignore b/backend/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/backend/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/backend/database/factories/OrganizationFactory.php b/backend/database/factories/OrganizationFactory.php new file mode 100644 index 0000000..b0bd798 --- /dev/null +++ b/backend/database/factories/OrganizationFactory.php @@ -0,0 +1,23 @@ + */ +class OrganizationFactory extends Factory +{ + protected $model = Organization::class; + + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'slug' => fake()->unique()->slug(2), + 'status' => 'active', + 'default_locale' => 'fa', + 'timezone' => 'Asia/Tehran', + ]; + } +} diff --git a/backend/database/factories/UserFactory.php b/backend/database/factories/UserFactory.php new file mode 100644 index 0000000..7288826 --- /dev/null +++ b/backend/database/factories/UserFactory.php @@ -0,0 +1,53 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'organization_id' => Organization::factory(), + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'role' => UserRole::Learner, + 'status' => AccountStatus::Active, + 'locale' => 'fa', + 'timezone' => 'Asia/Tehran', + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/backend/database/migrations/0000_12_31_235959_create_organizations_table.php b/backend/database/migrations/0000_12_31_235959_create_organizations_table.php new file mode 100644 index 0000000..6fc3c82 --- /dev/null +++ b/backend/database/migrations/0000_12_31_235959_create_organizations_table.php @@ -0,0 +1,27 @@ +ulid('id')->primary(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('status')->default('active')->index(); + $table->string('default_locale', 5)->default('fa'); + $table->string('timezone')->default('Asia/Tehran'); + $table->json('settings')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/backend/database/migrations/0001_01_01_000000_create_users_table.php b/backend/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..cb0f4bc --- /dev/null +++ b/backend/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,54 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->nullable()->constrained()->restrictOnDelete(); + $table->string('name'); + $table->string('email')->unique(); + $table->string('role')->index(); + $table->string('status')->default('active')->index(); + $table->string('locale', 5)->default('fa'); + $table->string('timezone')->default('Asia/Tehran'); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->ulid('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/backend/database/migrations/0001_01_01_000001_create_cache_table.php b/backend/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..b9c106b --- /dev/null +++ b/backend/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->integer('expiration'); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->integer('expiration'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/backend/database/migrations/0001_01_01_000002_create_jobs_table.php b/backend/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..425e705 --- /dev/null +++ b/backend/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,57 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->text('connection'); + $table->text('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/backend/database/migrations/2026_08_11_121040_create_personal_access_tokens_table.php b/backend/database/migrations/2026_08_11_121040_create_personal_access_tokens_table.php new file mode 100644 index 0000000..c31f2ce --- /dev/null +++ b/backend/database/migrations/2026_08_11_121040_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->ulidMorphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/backend/database/migrations/2026_08_11_130000_create_taxonomy_tables.php b/backend/database/migrations/2026_08_11_130000_create_taxonomy_tables.php new file mode 100644 index 0000000..ad5e7c5 --- /dev/null +++ b/backend/database/migrations/2026_08_11_130000_create_taxonomy_tables.php @@ -0,0 +1,58 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->string('key', 80); + $table->string('name', 160); + $table->text('description')->nullable(); + $table->string('status')->default('active')->index(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['organization_id', 'key']); + }); + + Schema::create('taxonomy_nodes', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('taxonomy_type_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('parent_id')->nullable()->constrained('taxonomy_nodes')->restrictOnDelete(); + $table->string('name', 200); + $table->text('description')->nullable(); + $table->string('code', 100)->nullable(); + $table->string('status')->default('active')->index(); + $table->json('metadata')->nullable(); + $table->foreignUlid('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->index(['organization_id', 'taxonomy_type_id', 'parent_id']); + $table->unique(['organization_id', 'code']); + }); + + Schema::create('taxonomy_relationships', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('source_node_id')->constrained('taxonomy_nodes')->restrictOnDelete(); + $table->foreignUlid('target_node_id')->constrained('taxonomy_nodes')->restrictOnDelete(); + $table->string('relationship_type', 80); + $table->decimal('weight', 7, 4)->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['organization_id', 'source_node_id', 'target_node_id', 'relationship_type'], 'taxonomy_relationship_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('taxonomy_relationships'); + Schema::dropIfExists('taxonomy_nodes'); + Schema::dropIfExists('taxonomy_types'); + } +}; diff --git a/backend/database/migrations/2026_08_11_131000_create_course_authoring_tables.php b/backend/database/migrations/2026_08_11_131000_create_course_authoring_tables.php new file mode 100644 index 0000000..ad03a58 --- /dev/null +++ b/backend/database/migrations/2026_08_11_131000_create_course_authoring_tables.php @@ -0,0 +1,114 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('slug'); + $table->string('status')->default('draft')->index(); + $table->foreignUlid('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->unique(['organization_id', 'slug']); + }); + + Schema::create('course_versions', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('source_version_id')->nullable()->constrained('course_versions')->nullOnDelete(); + $table->unsignedInteger('version_number'); + $table->string('status')->default('draft')->index(); + $table->string('title'); + $table->text('description')->nullable(); + $table->json('settings')->nullable(); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + $table->unique(['course_id', 'version_number']); + $table->index(['organization_id', 'status']); + }); + + Schema::create('course_modules', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->unsignedInteger('position'); + $table->json('settings')->nullable(); + $table->timestamps(); + $table->unique(['course_version_id', 'position']); + }); + + Schema::create('lessons', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_module_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->unsignedInteger('position'); + $table->string('presentation_mode')->default('flow'); + $table->json('settings')->nullable(); + $table->timestamps(); + $table->unique(['course_module_id', 'position']); + }); + + Schema::create('blocks', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('lesson_id')->constrained()->cascadeOnDelete(); + $table->string('type', 80)->index(); + $table->unsignedSmallInteger('schema_version')->default(1); + $table->json('data'); + $table->json('style')->nullable(); + $table->json('behavior')->nullable(); + $table->json('responsive')->nullable(); + $table->json('accessibility')->nullable(); + $table->unsignedInteger('position'); + $table->timestamps(); + $table->unique(['lesson_id', 'position']); + }); + + Schema::create('assessments', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('lesson_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->json('settings')->nullable(); + $table->timestamps(); + }); + + Schema::create('questions', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('assessment_id')->constrained()->cascadeOnDelete(); + $table->string('type', 80); + $table->text('prompt'); + $table->json('configuration'); + $table->string('difficulty')->nullable(); + $table->unsignedInteger('position'); + $table->timestamps(); + $table->unique(['assessment_id', 'position']); + }); + } + + public function down(): void + { + Schema::dropIfExists('questions'); + Schema::dropIfExists('assessments'); + Schema::dropIfExists('blocks'); + Schema::dropIfExists('lessons'); + Schema::dropIfExists('course_modules'); + Schema::dropIfExists('course_versions'); + Schema::dropIfExists('courses'); + } +}; diff --git a/backend/database/migrations/2026_08_11_132000_create_content_taxonomy_mappings_table.php b/backend/database/migrations/2026_08_11_132000_create_content_taxonomy_mappings_table.php new file mode 100644 index 0000000..2a0401a --- /dev/null +++ b/backend/database/migrations/2026_08_11_132000_create_content_taxonomy_mappings_table.php @@ -0,0 +1,39 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->restrictOnDelete(); + $table->string('mappable_type', 40); + $table->ulid('mappable_id'); + $table->foreignUlid('taxonomy_node_id')->constrained()->restrictOnDelete(); + $table->string('mapping_type', 40); + $table->decimal('weight', 7, 4)->default(1); + $table->string('source', 40); + $table->decimal('confidence', 7, 4)->nullable(); + $table->string('confirmation_status', 40); + $table->foreignUlid('confirmed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('confirmed_at')->nullable(); + $table->timestamps(); + $table->index(['organization_id', 'mappable_type', 'mappable_id'], 'content_mapping_target_index'); + $table->index(['organization_id', 'taxonomy_node_id', 'mapping_type'], 'content_mapping_taxonomy_index'); + $table->unique( + ['course_version_id', 'mappable_type', 'mappable_id', 'taxonomy_node_id', 'mapping_type'], + 'content_mapping_unique', + ); + }); + } + + public function down(): void + { + Schema::dropIfExists('content_taxonomy_mappings'); + } +}; diff --git a/backend/database/migrations/2026_08_11_133000_create_evidence_tables.php b/backend/database/migrations/2026_08_11_133000_create_evidence_tables.php new file mode 100644 index 0000000..78b73a0 --- /dev/null +++ b/backend/database/migrations/2026_08_11_133000_create_evidence_tables.php @@ -0,0 +1,68 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->restrictOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('assessment_id')->constrained()->restrictOnDelete(); + $table->unsignedInteger('attempt_number'); + $table->string('status')->index(); + $table->decimal('score', 7, 4)->nullable(); + $table->timestamp('started_at'); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->unique(['learner_id', 'assessment_id', 'attempt_number'], 'assessment_attempt_unique'); + }); + + Schema::create('question_results', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('assessment_attempt_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('question_id')->constrained()->restrictOnDelete(); + $table->json('raw_value'); + $table->decimal('normalized_value', 7, 4); + $table->boolean('is_correct')->nullable(); + $table->timestamp('occurred_at'); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['assessment_attempt_id', 'question_id']); + }); + + Schema::create('evidence_records', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->restrictOnDelete(); + $table->foreignUlid('taxonomy_node_id')->constrained()->restrictOnDelete(); + $table->string('source_type', 80); + $table->ulid('source_id'); + $table->string('evidence_type', 80)->index(); + $table->json('raw_value')->nullable(); + $table->decimal('normalized_value', 7, 4); + $table->decimal('strength', 7, 4); + $table->decimal('mapping_weight', 7, 4)->default(1); + $table->timestamp('occurred_at')->index(); + $table->foreignUlid('course_version_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('content_mapping_id')->constrained('content_taxonomy_mappings')->restrictOnDelete(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['source_type', 'source_id', 'taxonomy_node_id', 'content_mapping_id'], 'evidence_source_mapping_unique'); + $table->index(['organization_id', 'learner_id', 'taxonomy_node_id', 'occurred_at'], 'evidence_capability_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('evidence_records'); + Schema::dropIfExists('question_results'); + Schema::dropIfExists('assessment_attempts'); + } +}; diff --git a/backend/database/migrations/2026_08_11_134000_create_capability_tables.php b/backend/database/migrations/2026_08_11_134000_create_capability_tables.php new file mode 100644 index 0000000..00aa142 --- /dev/null +++ b/backend/database/migrations/2026_08_11_134000_create_capability_tables.php @@ -0,0 +1,51 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->restrictOnDelete(); + $table->foreignUlid('taxonomy_node_id')->constrained()->restrictOnDelete(); + $table->decimal('score', 6, 2)->nullable(); + $table->decimal('confidence', 7, 4); + $table->string('confidence_level', 40)->index(); + $table->unsignedInteger('evidence_count'); + $table->timestamp('last_evidence_at')->nullable(); + $table->decimal('trend', 7, 2)->nullable(); + $table->string('scoring_model_version', 40); + $table->json('explanation'); + $table->timestamp('calculated_at'); + $table->timestamps(); + $table->unique(['organization_id', 'learner_id', 'taxonomy_node_id'], 'capability_current_unique'); + }); + + Schema::create('capability_snapshots', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->restrictOnDelete(); + $table->foreignUlid('taxonomy_node_id')->constrained()->restrictOnDelete(); + $table->decimal('score', 6, 2)->nullable(); + $table->decimal('confidence', 7, 4); + $table->string('confidence_level', 40)->index(); + $table->unsignedInteger('evidence_count'); + $table->string('scoring_model_version', 40); + $table->json('explanation'); + $table->timestamp('captured_at')->index(); + $table->timestamps(); + $table->index(['organization_id', 'learner_id', 'taxonomy_node_id', 'captured_at'], 'capability_snapshot_lookup'); + }); + } + + public function down(): void + { + Schema::dropIfExists('capability_snapshots'); + Schema::dropIfExists('capability_scores'); + } +}; diff --git a/backend/database/migrations/2026_08_11_140000_upgrade_existing_users_for_tenancy.php b/backend/database/migrations/2026_08_11_140000_upgrade_existing_users_for_tenancy.php new file mode 100644 index 0000000..79794ca --- /dev/null +++ b/backend/database/migrations/2026_08_11_140000_upgrade_existing_users_for_tenancy.php @@ -0,0 +1,34 @@ +foreignUlid('organization_id')->nullable()->after('id')->constrained()->restrictOnDelete(); + } + if (! Schema::hasColumn('users', 'role')) { + $table->string('role')->default('learner')->after('email')->index(); + } + if (! Schema::hasColumn('users', 'status')) { + $table->string('status')->default('active')->after('role')->index(); + } + if (! Schema::hasColumn('users', 'locale')) { + $table->string('locale', 5)->default('fa')->after('status'); + } + if (! Schema::hasColumn('users', 'timezone')) { + $table->string('timezone')->default('Asia/Tehran')->after('locale'); + } + }); + } + + public function down(): void + { + // Existing installations may contain tenant data; rollback is intentionally non-destructive. + } +}; diff --git a/backend/database/migrations/2026_08_11_141000_create_teams_subscriptions_and_invitations.php b/backend/database/migrations/2026_08_11_141000_create_teams_subscriptions_and_invitations.php new file mode 100644 index 0000000..1842fa0 --- /dev/null +++ b/backend/database/migrations/2026_08_11_141000_create_teams_subscriptions_and_invitations.php @@ -0,0 +1,76 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('status')->default('active')->index(); + $table->foreignUlid('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->unique(['organization_id', 'name']); + }); + + Schema::create('team_memberships', function (Blueprint $table) { + $table->foreignUlid('team_id')->constrained('teams')->cascadeOnDelete(); + $table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + $table->primary(['team_id', 'user_id']); + }); + + Schema::create('team_managers', function (Blueprint $table) { + $table->foreignUlid('team_id')->constrained('teams')->cascadeOnDelete(); + $table->foreignUlid('user_id')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + $table->primary(['team_id', 'user_id']); + }); + + Schema::create('subscriptions', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->string('plan_key', 80); + $table->string('status')->index(); + $table->timestamp('starts_at'); + $table->timestamp('expires_at')->nullable()->index(); + $table->unsignedInteger('seat_limit')->nullable(); + $table->unsignedBigInteger('storage_quota_bytes')->nullable(); + $table->unsignedBigInteger('ai_credit_quota')->nullable(); + $table->json('enabled_features')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['organization_id', 'status', 'starts_at']); + }); + + Schema::create('user_invitations', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->string('email'); + $table->string('role', 40); + $table->string('token_hash', 64)->unique(); + $table->timestamp('expires_at')->index(); + $table->foreignUlid('invited_by')->constrained('users')->restrictOnDelete(); + $table->foreignUlid('accepted_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamp('revoked_at')->nullable(); + $table->timestamps(); + $table->unique(['organization_id', 'email']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_invitations'); + Schema::dropIfExists('subscriptions'); + Schema::dropIfExists('team_managers'); + Schema::dropIfExists('team_memberships'); + Schema::dropIfExists('teams'); + } +}; diff --git a/backend/database/migrations/2026_08_11_142000_add_revision_to_blocks_table.php b/backend/database/migrations/2026_08_11_142000_add_revision_to_blocks_table.php new file mode 100644 index 0000000..6e7a655 --- /dev/null +++ b/backend/database/migrations/2026_08_11_142000_add_revision_to_blocks_table.php @@ -0,0 +1,22 @@ +unsignedInteger('revision')->default(1)->after('position'); + }); + } + + public function down(): void + { + Schema::table('blocks', function (Blueprint $table) { + $table->dropColumn('revision'); + }); + } +}; diff --git a/backend/database/migrations/2026_08_11_143000_add_usage_to_subscriptions_table.php b/backend/database/migrations/2026_08_11_143000_add_usage_to_subscriptions_table.php new file mode 100644 index 0000000..479bf31 --- /dev/null +++ b/backend/database/migrations/2026_08_11_143000_add_usage_to_subscriptions_table.php @@ -0,0 +1,21 @@ +unsignedBigInteger('storage_used_bytes')->default(0)->after('storage_quota_bytes'); + $table->unsignedBigInteger('ai_credits_used')->default(0)->after('ai_credit_quota'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', fn (Blueprint $table) => $table->dropColumn(['storage_used_bytes', 'ai_credits_used'])); + } +}; diff --git a/backend/database/migrations/2026_08_11_144000_add_lock_state_to_course_structure.php b/backend/database/migrations/2026_08_11_144000_add_lock_state_to_course_structure.php new file mode 100644 index 0000000..53f79df --- /dev/null +++ b/backend/database/migrations/2026_08_11_144000_add_lock_state_to_course_structure.php @@ -0,0 +1,20 @@ + $table->boolean('is_locked')->default(false)->after('settings')); + Schema::table('lessons', fn (Blueprint $table) => $table->boolean('is_locked')->default(false)->after('settings')); + } + + public function down(): void + { + Schema::table('lessons', fn (Blueprint $table) => $table->dropColumn('is_locked')); + Schema::table('course_modules', fn (Blueprint $table) => $table->dropColumn('is_locked')); + } +}; diff --git a/backend/database/migrations/2026_08_11_145000_create_assets_table.php b/backend/database/migrations/2026_08_11_145000_create_assets_table.php new file mode 100644 index 0000000..43b8c63 --- /dev/null +++ b/backend/database/migrations/2026_08_11_145000_create_assets_table.php @@ -0,0 +1,34 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('uploaded_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('kind', 32)->index(); + $table->string('original_name'); + $table->string('disk', 32)->default('local'); + $table->string('path'); + $table->string('mime_type', 160); + $table->unsignedBigInteger('size'); + $table->char('sha256', 64); + $table->string('alt_text', 300)->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['organization_id', 'sha256']); + $table->index(['organization_id', 'kind', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('assets'); + } +}; diff --git a/backend/database/migrations/2026_08_11_160000_add_workforce_profile_and_course_cover.php b/backend/database/migrations/2026_08_11_160000_add_workforce_profile_and_course_cover.php new file mode 100644 index 0000000..ee8cc93 --- /dev/null +++ b/backend/database/migrations/2026_08_11_160000_add_workforce_profile_and_course_cover.php @@ -0,0 +1,32 @@ +string('first_name')->nullable()->after('name'); + $table->string('last_name')->nullable()->after('first_name'); + $table->string('department')->nullable()->index()->after('last_name'); + $table->string('job_level')->nullable()->index()->after('department'); + $table->foreignUlid('direct_manager_id')->nullable()->after('job_level')->constrained('users')->nullOnDelete(); + }); + + Schema::table('courses', function (Blueprint $table) { + $table->foreignUlid('cover_asset_id')->nullable()->after('status')->constrained('assets')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('courses', fn (Blueprint $table) => $table->dropConstrainedForeignId('cover_asset_id')); + Schema::table('users', function (Blueprint $table) { + $table->dropConstrainedForeignId('direct_manager_id'); + $table->dropColumn(['first_name', 'last_name', 'department', 'job_level']); + }); + } +}; diff --git a/backend/database/migrations/2026_08_11_170000_expand_assessment_authoring.php b/backend/database/migrations/2026_08_11_170000_expand_assessment_authoring.php new file mode 100644 index 0000000..a236677 --- /dev/null +++ b/backend/database/migrations/2026_08_11_170000_expand_assessment_authoring.php @@ -0,0 +1,32 @@ +foreignUlid('course_version_id')->nullable()->change(); + $table->foreignUlid('assessment_id')->nullable()->change(); + $table->boolean('is_bank_item')->default(false)->index(); + $table->foreignUlid('source_question_id')->nullable()->constrained('questions')->nullOnDelete(); + $table->unsignedSmallInteger('schema_version')->default(1); + $table->string('topic', 160)->nullable()->index(); + $table->json('tags')->nullable(); + $table->text('explanation')->nullable(); + }); + } + + public function down(): void + { + Schema::table('questions', function (Blueprint $table) { + $table->dropConstrainedForeignId('source_question_id'); + $table->dropColumn(['is_bank_item', 'schema_version', 'topic', 'tags', 'explanation']); + $table->foreignUlid('course_version_id')->nullable(false)->change(); + $table->foreignUlid('assessment_id')->nullable(false)->change(); + }); + } +}; diff --git a/backend/database/migrations/2026_08_12_100000_create_publishing_assignment_and_learning_path_tables.php b/backend/database/migrations/2026_08_12_100000_create_publishing_assignment_and_learning_path_tables.php new file mode 100644 index 0000000..6f9b3f5 --- /dev/null +++ b/backend/database/migrations/2026_08_12_100000_create_publishing_assignment_and_learning_path_tables.php @@ -0,0 +1,113 @@ +json('completion_rules')->nullable()->after('settings'); + $table->json('taxonomy_snapshot')->nullable()->after('completion_rules'); + $table->timestamp('review_submitted_at')->nullable()->after('published_at'); + $table->timestamp('scheduled_publish_at')->nullable()->index()->after('review_submitted_at'); + $table->timestamp('scheduled_unpublish_at')->nullable()->index()->after('scheduled_publish_at'); + $table->timestamp('unpublished_at')->nullable()->after('scheduled_unpublish_at'); + }); + + Schema::create('learning_paths', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->string('title'); + $table->string('slug'); + $table->string('status')->default('draft')->index(); + $table->foreignUlid('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->unique(['organization_id', 'slug']); + }); + + Schema::create('learning_path_versions', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learning_path_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('source_version_id')->nullable()->constrained('learning_path_versions')->nullOnDelete(); + $table->unsignedInteger('version_number'); + $table->string('status')->default('draft')->index(); + $table->string('title'); + $table->text('description')->nullable(); + $table->json('settings')->nullable(); + $table->timestamp('review_submitted_at')->nullable(); + $table->timestamp('published_at')->nullable(); + $table->timestamp('scheduled_publish_at')->nullable()->index(); + $table->timestamp('scheduled_unpublish_at')->nullable()->index(); + $table->timestamp('unpublished_at')->nullable(); + $table->timestamps(); + $table->unique(['learning_path_id', 'version_number']); + }); + + Schema::create('learning_path_items', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learning_path_version_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('prerequisite_item_id')->nullable()->constrained('learning_path_items')->nullOnDelete(); + $table->unsignedInteger('position'); + $table->json('completion_rules')->nullable(); + $table->timestamps(); + $table->unique(['learning_path_version_id', 'position']); + $table->unique(['learning_path_version_id', 'course_version_id'], 'learning_path_item_course_unique'); + }); + + Schema::create('assignments', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->string('assignable_type', 40); + $table->ulid('assignable_id'); + $table->string('target_type', 40); + $table->ulid('target_id')->nullable(); + $table->string('target_value')->nullable(); + $table->string('status')->default('active')->index(); + $table->boolean('mandatory')->default(true); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('due_at')->nullable()->index(); + $table->unsignedSmallInteger('recurring_months')->nullable(); + $table->unsignedSmallInteger('reminder_days')->nullable(); + $table->json('escalation_policy')->nullable(); + $table->string('source')->default('manual'); + $table->foreignUlid('assigned_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('cancelled_at')->nullable(); + $table->timestamps(); + $table->index(['organization_id', 'assignable_type', 'assignable_id']); + $table->index(['organization_id', 'target_type', 'target_id']); + }); + + Schema::create('assignment_users', function (Blueprint $table) { + $table->ulid('assignment_id'); + $table->ulid('user_id'); + $table->string('status')->default('assigned')->index(); + $table->timestamp('assigned_at'); + $table->timestamp('starts_at')->nullable(); + $table->timestamp('due_at')->nullable()->index(); + $table->timestamp('completed_at')->nullable(); + $table->unsignedTinyInteger('progress')->default(0); + $table->timestamps(); + $table->primary(['assignment_id', 'user_id']); + $table->foreign('assignment_id')->references('id')->on('assignments')->cascadeOnDelete(); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('assignment_users'); + Schema::dropIfExists('assignments'); + Schema::dropIfExists('learning_path_items'); + Schema::dropIfExists('learning_path_versions'); + Schema::dropIfExists('learning_paths'); + Schema::table('course_versions', function (Blueprint $table) { + $table->dropColumn(['completion_rules', 'taxonomy_snapshot', 'review_submitted_at', 'scheduled_publish_at', 'scheduled_unpublish_at', 'unpublished_at']); + }); + } +}; diff --git a/backend/database/migrations/2026_08_12_140000_create_learner_player_tables.php b/backend/database/migrations/2026_08_12_140000_create_learner_player_tables.php new file mode 100644 index 0000000..75f8fc0 --- /dev/null +++ b/backend/database/migrations/2026_08_12_140000_create_learner_player_tables.php @@ -0,0 +1,91 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('assignment_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('lesson_id')->constrained()->restrictOnDelete(); + $table->string('status')->default('not_started')->index(); + $table->unsignedTinyInteger('progress')->default(0); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('last_activity_at')->nullable(); + $table->timestamps(); + $table->unique(['assignment_id', 'learner_id', 'lesson_id']); + }); + + Schema::create('block_progress', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('assignment_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('lesson_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('block_id')->constrained()->restrictOnDelete(); + $table->string('status')->default('viewed')->index(); + $table->decimal('score', 7, 4)->nullable(); + $table->json('response')->nullable(); + $table->timestamp('first_viewed_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->unique(['assignment_id', 'learner_id', 'block_id']); + }); + + Schema::create('learner_notes', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('lesson_id')->nullable()->constrained()->restrictOnDelete(); + $table->text('body'); + $table->timestamps(); + $table->index(['learner_id', 'course_version_id']); + }); + + Schema::create('learner_bookmarks', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('lesson_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('block_id')->nullable()->constrained()->restrictOnDelete(); + $table->timestamps(); + $table->unique(['learner_id', 'lesson_id', 'block_id']); + }); + + Schema::create('learning_events', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->cascadeOnDelete(); + $table->uuid('client_event_id'); + $table->string('event_type', 100)->index(); + $table->foreignUlid('assignment_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignUlid('course_version_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignUlid('lesson_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignUlid('block_id')->nullable()->constrained()->nullOnDelete(); + $table->json('payload')->nullable(); + $table->timestamp('occurred_at')->index(); + $table->timestamp('received_at'); + $table->timestamps(); + $table->unique(['learner_id', 'client_event_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('learning_events'); + Schema::dropIfExists('learner_bookmarks'); + Schema::dropIfExists('learner_notes'); + Schema::dropIfExists('block_progress'); + Schema::dropIfExists('lesson_progress'); + } +}; diff --git a/backend/database/migrations/2026_08_12_150000_create_learner_social_tables.php b/backend/database/migrations/2026_08_12_150000_create_learner_social_tables.php new file mode 100644 index 0000000..9450ad4 --- /dev/null +++ b/backend/database/migrations/2026_08_12_150000_create_learner_social_tables.php @@ -0,0 +1,59 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('lesson_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('block_id')->constrained()->restrictOnDelete(); + $table->text('quote'); + $table->string('color', 30)->default('yellow'); + $table->timestamps(); + }); + Schema::create('learner_favorites', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('assignment_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['learner_id', 'assignment_id']); + }); + Schema::create('lesson_discussions', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('lesson_id')->constrained()->restrictOnDelete(); + $table->foreignUlid('parent_id')->nullable()->constrained('lesson_discussions')->cascadeOnDelete(); + $table->text('body'); + $table->timestamps(); + $table->index(['lesson_id', 'created_at']); + }); + Schema::create('discussion_reactions', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('discussion_id')->constrained('lesson_discussions')->cascadeOnDelete(); + $table->string('reaction', 30); + $table->timestamps(); + $table->unique(['learner_id', 'discussion_id', 'reaction']); + }); + } + + public function down(): void + { + Schema::dropIfExists('discussion_reactions'); + Schema::dropIfExists('lesson_discussions'); + Schema::dropIfExists('learner_favorites'); + Schema::dropIfExists('learner_highlights'); + } +}; diff --git a/backend/database/migrations/2026_08_15_120000_create_analytics_monitoring_tables.php b/backend/database/migrations/2026_08_15_120000_create_analytics_monitoring_tables.php new file mode 100644 index 0000000..3f63306 --- /dev/null +++ b/backend/database/migrations/2026_08_15_120000_create_analytics_monitoring_tables.php @@ -0,0 +1,87 @@ +unsignedSmallInteger('schema_version')->default(1)->after('event_type'); + $table->string('session_id', 120)->nullable()->after('schema_version')->index(); + $table->uuid('correlation_id')->nullable()->after('session_id')->index(); + $table->uuid('causation_id')->nullable()->after('correlation_id'); + $table->json('device_context')->nullable()->after('causation_id'); + }); + + Schema::create('analytics_event_projections', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learning_event_id')->constrained('learning_events')->cascadeOnDelete(); + $table->string('processor', 80); + $table->unsignedSmallInteger('processor_version'); + $table->timestamp('processed_at'); + $table->timestamps(); + $table->unique(['learning_event_id', 'processor', 'processor_version'], 'analytics_event_processor_unique'); + }); + + Schema::create('analytics_daily_metrics', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->date('metric_date')->index(); + $table->string('scope_type', 40); + $table->string('scope_id', 64); + $table->json('metrics'); + $table->timestamp('calculated_at'); + $table->timestamps(); + $table->unique(['organization_id', 'metric_date', 'scope_type', 'scope_id'], 'analytics_daily_scope_unique'); + }); + + Schema::create('monitoring_risks', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('learner_id')->constrained('users')->cascadeOnDelete(); + $table->unsignedSmallInteger('score'); + $table->string('level', 20)->index(); + $table->string('provider', 80); + $table->string('provider_version', 40); + $table->json('factors'); + $table->timestamp('calculated_at'); + $table->timestamps(); + $table->unique(['organization_id', 'learner_id']); + }); + + Schema::create('monitoring_insights', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->string('fingerprint', 160); + $table->string('type', 80)->index(); + $table->string('severity', 20)->index(); + $table->string('entity_type', 40); + $table->string('entity_id', 64)->nullable(); + $table->text('reason'); + $table->json('evidence'); + $table->decimal('trend', 8, 2)->nullable(); + $table->string('suggested_action', 160); + $table->string('drilldown', 500); + $table->string('status', 20)->default('open')->index(); + $table->timestamp('detected_at'); + $table->timestamps(); + $table->unique(['organization_id', 'fingerprint']); + }); + } + + public function down(): void + { + Schema::dropIfExists('monitoring_insights'); + Schema::dropIfExists('monitoring_risks'); + Schema::dropIfExists('analytics_daily_metrics'); + Schema::dropIfExists('analytics_event_projections'); + + Schema::table('learning_events', function (Blueprint $table) { + $table->dropColumn(['schema_version', 'session_id', 'correlation_id', 'causation_id', 'device_context']); + }); + } +}; diff --git a/backend/database/migrations/2026_08_15_130000_create_collaboration_tables.php b/backend/database/migrations/2026_08_15_130000_create_collaboration_tables.php new file mode 100644 index 0000000..ce6afb0 --- /dev/null +++ b/backend/database/migrations/2026_08_15_130000_create_collaboration_tables.php @@ -0,0 +1,115 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('lesson_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignUlid('user_id')->constrained()->cascadeOnDelete(); + $table->uuid('client_session_id'); + $table->string('transport', 40)->default('change-feed'); + $table->timestamp('last_seen_at')->index(); + $table->timestamps(); + $table->unique(['course_version_id', 'user_id', 'client_session_id'], 'collaboration_session_unique'); + }); + + Schema::create('block_soft_locks', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('lesson_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('block_id')->constrained()->cascadeOnDelete()->unique(); + $table->foreignUlid('holder_id')->constrained('users')->cascadeOnDelete(); + $table->uuid('client_session_id'); + $table->uuid('lock_token')->unique(); + $table->timestamp('expires_at')->index(); + $table->timestamps(); + }); + + Schema::create('review_threads', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('lesson_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignUlid('block_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignUlid('author_id')->constrained('users')->restrictOnDelete(); + $table->string('status', 20)->default('open')->index(); + $table->text('body'); + $table->json('mention_user_ids')->nullable(); + $table->foreignUlid('resolved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + $table->index(['organization_id', 'course_version_id', 'status']); + }); + + Schema::create('review_replies', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('review_thread_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('author_id')->constrained('users')->restrictOnDelete(); + $table->text('body'); + $table->json('mention_user_ids')->nullable(); + $table->timestamps(); + }); + + Schema::create('review_reactions', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('review_thread_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignUlid('review_reply_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignUlid('user_id')->constrained()->cascadeOnDelete(); + $table->string('reaction', 30); + $table->timestamps(); + $table->unique(['review_thread_id', 'review_reply_id', 'user_id', 'reaction'], 'review_reaction_unique'); + }); + + Schema::create('in_app_notifications', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('recipient_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('type', 80)->index(); + $table->string('title', 180); + $table->text('body'); + $table->string('target_url', 500)->nullable(); + $table->string('entity_type', 60); + $table->ulid('entity_id')->nullable(); + $table->json('data')->nullable(); + $table->timestamp('read_at')->nullable()->index(); + $table->timestamps(); + $table->index(['recipient_id', 'created_at']); + }); + + Schema::create('collaboration_changes', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->unsignedSmallInteger('schema_version')->default(1); + $table->string('type', 80)->index(); + $table->json('payload'); + $table->timestamp('occurred_at')->index(); + $table->timestamps(); + $table->index(['course_version_id', 'id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('collaboration_changes'); + Schema::dropIfExists('in_app_notifications'); + Schema::dropIfExists('review_reactions'); + Schema::dropIfExists('review_replies'); + Schema::dropIfExists('review_threads'); + Schema::dropIfExists('block_soft_locks'); + Schema::dropIfExists('collaboration_sessions'); + } +}; diff --git a/backend/database/migrations/2026_08_15_140000_add_collaboration_idempotency_keys.php b/backend/database/migrations/2026_08_15_140000_add_collaboration_idempotency_keys.php new file mode 100644 index 0000000..4285f42 --- /dev/null +++ b/backend/database/migrations/2026_08_15_140000_add_collaboration_idempotency_keys.php @@ -0,0 +1,32 @@ +uuid('client_mutation_id')->nullable()->after('author_id'); + $table->unique(['organization_id', 'author_id', 'client_mutation_id'], 'review_threads_idempotency_unique'); + }); + Schema::table('review_replies', function (Blueprint $table): void { + $table->uuid('client_mutation_id')->nullable()->after('author_id'); + $table->unique(['organization_id', 'author_id', 'client_mutation_id'], 'review_replies_idempotency_unique'); + }); + } + + public function down(): void + { + Schema::table('review_replies', function (Blueprint $table): void { + $table->dropUnique('review_replies_idempotency_unique'); + $table->dropColumn('client_mutation_id'); + }); + Schema::table('review_threads', function (Blueprint $table): void { + $table->dropUnique('review_threads_idempotency_unique'); + $table->dropColumn('client_mutation_id'); + }); + } +}; diff --git a/backend/database/migrations/2026_08_15_150000_create_product_completion_and_ai_tables.php b/backend/database/migrations/2026_08_15_150000_create_product_completion_and_ai_tables.php new file mode 100644 index 0000000..98bfe38 --- /dev/null +++ b/backend/database/migrations/2026_08_15_150000_create_product_completion_and_ai_tables.php @@ -0,0 +1,177 @@ +timestamp('dismissed_at')->nullable()->after('read_at'); + }); + Schema::create('organization_profiles', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->unique()->constrained()->cascadeOnDelete(); + $table->foreignUlid('logo_asset_id')->nullable()->constrained('assets')->nullOnDelete(); + $table->string('display_name')->nullable(); + $table->string('primary_color', 16)->default('#5b3fd3'); + $table->string('accent_color', 16)->default('#09bdd1'); + $table->text('learner_welcome')->nullable(); + $table->string('certificate_signatory')->nullable(); + $table->json('settings')->nullable(); + $table->timestamps(); + }); + + Schema::create('user_preferences', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->json('preferences'); + $table->timestamps(); + }); + + Schema::create('platform_settings', function (Blueprint $table): void { + $table->string('key')->primary(); + $table->json('value'); + $table->foreignUlid('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + }); + + Schema::create('course_templates', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('created_by')->constrained('users')->cascadeOnDelete(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('category')->default('custom'); + $table->json('blueprint'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->index(['organization_id', 'is_active']); + }); + + Schema::create('export_jobs', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('requested_by')->constrained('users')->cascadeOnDelete(); + $table->string('type'); + $table->string('format'); + $table->string('status')->default('queued'); + $table->json('filters')->nullable(); + $table->string('disk')->nullable(); + $table->string('path')->nullable(); + $table->unsignedBigInteger('size')->nullable(); + $table->text('error')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->index(['organization_id', 'status']); + }); + + Schema::create('certificates', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('user_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('course_version_id')->constrained('course_versions')->cascadeOnDelete(); + $table->string('serial')->unique(); + $table->timestamp('issued_at'); + $table->timestamp('revoked_at')->nullable(); + $table->json('snapshot'); + $table->timestamps(); + $table->unique(['user_id', 'course_version_id']); + }); + + Schema::create('audit_logs', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignUlid('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('action'); + $table->string('entity_type')->nullable(); + $table->string('entity_id')->nullable(); + $table->json('metadata')->nullable(); + $table->string('ip_address', 64)->nullable(); + $table->timestamp('created_at'); + $table->index(['organization_id', 'created_at']); + }); + + Schema::create('ai_jobs', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('requested_by')->constrained('users')->cascadeOnDelete(); + $table->string('provider'); + $table->string('operation'); + $table->string('status')->default('queued'); + $table->uuid('idempotency_key'); + $table->json('input')->nullable(); + $table->json('output')->nullable(); + $table->unsignedInteger('input_units')->default(0); + $table->unsignedInteger('output_units')->default(0); + $table->decimal('cost', 12, 6)->nullable(); + $table->text('error')->nullable(); + $table->unsignedTinyInteger('progress')->default(0); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamp('cancelled_at')->nullable(); + $table->timestamps(); + $table->unique(['organization_id', 'idempotency_key']); + $table->index(['organization_id', 'status']); + }); + + Schema::create('ai_source_documents', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('ai_job_id')->constrained()->cascadeOnDelete(); + $table->string('original_name'); + $table->string('mime_type'); + $table->unsignedBigInteger('size'); + $table->string('sha256', 64); + $table->string('disk')->default('local'); + $table->string('path'); + $table->string('kind'); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['organization_id', 'sha256']); + }); + + Schema::create('ai_source_fragments', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('source_document_id')->constrained('ai_source_documents')->cascadeOnDelete(); + $table->unsignedInteger('position'); + $table->string('locator')->nullable(); + $table->string('heading')->nullable(); + $table->longText('content'); + $table->string('content_hash', 64); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['source_document_id', 'position']); + }); + + Schema::create('ai_suggestions', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('ai_job_id')->constrained()->cascadeOnDelete(); + $table->string('suggestion_type'); + $table->string('entity_type')->nullable(); + $table->string('entity_id')->nullable(); + $table->json('payload'); + $table->unsignedTinyInteger('confidence')->default(0); + $table->text('rationale')->nullable(); + $table->string('status')->default('draft'); + $table->foreignUlid('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + $table->index(['organization_id', 'status']); + }); + } + + public function down(): void + { + foreach (['ai_suggestions', 'ai_source_fragments', 'ai_source_documents', 'ai_jobs', 'audit_logs', 'certificates', 'export_jobs', 'course_templates', 'platform_settings', 'user_preferences', 'organization_profiles'] as $table) { + Schema::dropIfExists($table); + } + Schema::table('in_app_notifications', function (Blueprint $table): void { + $table->dropColumn('dismissed_at'); + }); + } +}; diff --git a/backend/database/migrations/2026_08_15_180000_complete_exports_certificates_and_operations.php b/backend/database/migrations/2026_08_15_180000_complete_exports_certificates_and_operations.php new file mode 100644 index 0000000..f3e94f8 --- /dev/null +++ b/backend/database/migrations/2026_08_15_180000_complete_exports_certificates_and_operations.php @@ -0,0 +1,55 @@ +foreignUlid('course_version_id')->nullable()->after('requested_by')->constrained('course_versions')->nullOnDelete(); + $table->unsignedTinyInteger('progress')->default(0)->after('status'); + $table->json('warnings')->nullable()->after('filters'); + $table->json('metadata')->nullable()->after('warnings'); + $table->unsignedSmallInteger('attempts')->default(0)->after('metadata'); + $table->timestamp('started_at')->nullable()->after('error'); + }); + + Schema::create('certificate_templates', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('created_by')->constrained('users')->cascadeOnDelete(); + $table->string('name'); + $table->json('canvas'); + $table->boolean('is_default')->default(false); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->index(['organization_id', 'is_active']); + }); + + Schema::table('certificates', function (Blueprint $table): void { + $table->foreignUlid('template_id')->nullable()->after('course_version_id')->constrained('certificate_templates')->nullOnDelete(); + $table->string('verification_code', 64)->nullable()->unique()->after('serial'); + $table->string('certificate_number')->nullable()->unique()->after('verification_code'); + $table->timestamp('expires_at')->nullable()->after('issued_at'); + $table->text('revocation_reason')->nullable()->after('revoked_at'); + $table->string('disk')->nullable()->after('snapshot'); + $table->string('path')->nullable()->after('disk'); + }); + } + + public function down(): void + { + Schema::table('certificates', function (Blueprint $table): void { + $table->dropConstrainedForeignId('template_id'); + $table->dropColumn(['verification_code', 'certificate_number', 'expires_at', 'revocation_reason', 'disk', 'path']); + }); + Schema::dropIfExists('certificate_templates'); + Schema::table('export_jobs', function (Blueprint $table): void { + $table->dropConstrainedForeignId('course_version_id'); + $table->dropColumn(['progress', 'warnings', 'metadata', 'attempts', 'started_at']); + }); + } +}; diff --git a/backend/database/migrations/2026_08_16_120000_add_mastery_level_to_content_taxonomy_mappings.php b/backend/database/migrations/2026_08_16_120000_add_mastery_level_to_content_taxonomy_mappings.php new file mode 100644 index 0000000..f7cf48c --- /dev/null +++ b/backend/database/migrations/2026_08_16_120000_add_mastery_level_to_content_taxonomy_mappings.php @@ -0,0 +1,22 @@ +string('mastery_level', 40)->nullable()->after('mapping_type'); + }); + } + + public function down(): void + { + Schema::table('content_taxonomy_mappings', function (Blueprint $table) { + $table->dropColumn('mastery_level'); + }); + } +}; diff --git a/backend/database/migrations/2026_08_16_130000_create_competency_framework_tables.php b/backend/database/migrations/2026_08_16_130000_create_competency_framework_tables.php new file mode 100644 index 0000000..c0b09ed --- /dev/null +++ b/backend/database/migrations/2026_08_16_130000_create_competency_framework_tables.php @@ -0,0 +1,42 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->string('name', 200); + $table->text('description')->nullable(); + $table->string('audience_type', 40)->default('organization'); + $table->string('audience_value', 200)->nullable(); + $table->string('status', 40)->default('active'); + $table->foreignUlid('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->index(['organization_id', 'status']); + }); + + Schema::create('competency_framework_items', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('framework_id')->constrained('competency_frameworks')->cascadeOnDelete(); + $table->foreignUlid('taxonomy_node_id')->constrained()->restrictOnDelete(); + $table->string('mastery_level', 40)->default('applied'); + $table->boolean('required')->default(true); + $table->unsignedInteger('position')->default(1); + $table->timestamps(); + $table->unique(['framework_id', 'taxonomy_node_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('competency_framework_items'); + Schema::dropIfExists('competency_frameworks'); + } +}; diff --git a/backend/database/migrations/2026_08_16_140000_create_ai_provider_connections.php b/backend/database/migrations/2026_08_16_140000_create_ai_provider_connections.php new file mode 100644 index 0000000..d8fbb37 --- /dev/null +++ b/backend/database/migrations/2026_08_16_140000_create_ai_provider_connections.php @@ -0,0 +1,38 @@ +ulid('id')->primary(); + $table->ulid('organization_id')->index(); + $table->string('name', 160); + $table->string('provider', 60); + $table->string('mode', 20); + $table->string('base_url', 1000); + $table->text('encrypted_api_key')->nullable(); + $table->json('models')->nullable(); + $table->string('default_model', 255)->nullable(); + $table->unsignedSmallInteger('timeout_seconds')->default(90); + $table->boolean('enabled')->default(true); + $table->boolean('is_default')->default(false); + $table->string('last_status', 30)->nullable(); + $table->unsignedInteger('last_latency_ms')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamp('last_tested_at')->nullable(); + $table->ulid('created_by')->nullable(); + $table->timestamps(); + $table->unique(['organization_id', 'name']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ai_provider_connections'); + } +}; diff --git a/backend/database/migrations/2026_08_16_141000_add_profile_to_user_invitations.php b/backend/database/migrations/2026_08_16_141000_add_profile_to_user_invitations.php new file mode 100644 index 0000000..91ba4cc --- /dev/null +++ b/backend/database/migrations/2026_08_16_141000_add_profile_to_user_invitations.php @@ -0,0 +1,18 @@ + $table->json('profile')->nullable()->after('role')); + } + + public function down(): void + { + Schema::table('user_invitations', fn (Blueprint $table) => $table->dropColumn('profile')); + } +}; diff --git a/backend/database/migrations/2026_08_20_000000_create_subscription_plans_table.php b/backend/database/migrations/2026_08_20_000000_create_subscription_plans_table.php new file mode 100644 index 0000000..569a589 --- /dev/null +++ b/backend/database/migrations/2026_08_20_000000_create_subscription_plans_table.php @@ -0,0 +1,30 @@ +ulid('id')->primary(); + $table->string('key', 80)->unique(); + $table->string('name', 160); + $table->text('description')->nullable(); + $table->string('status', 20)->default('active')->index(); + $table->unsignedInteger('seat_limit')->nullable(); + $table->unsignedBigInteger('storage_quota_bytes')->nullable(); + $table->unsignedInteger('ai_credit_quota')->nullable(); + $table->json('enabled_features')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('subscription_plans'); + } +}; diff --git a/backend/database/migrations/2026_08_20_100000_add_notification_scheduling.php b/backend/database/migrations/2026_08_20_100000_add_notification_scheduling.php new file mode 100644 index 0000000..95c00a3 --- /dev/null +++ b/backend/database/migrations/2026_08_20_100000_add_notification_scheduling.php @@ -0,0 +1,47 @@ +string('idempotency_key', 190)->nullable()->unique()->after('data'); + }); + + Schema::create('notification_schedules', function (Blueprint $table): void { + $table->ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('recipient_id')->constrained('users')->cascadeOnDelete(); + $table->foreignUlid('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('type', 80)->index(); + $table->string('title', 180); + $table->text('body'); + $table->string('target_url', 500)->nullable(); + $table->string('entity_type', 60); + $table->string('entity_id')->nullable(); + $table->string('preference_key', 80)->nullable(); + $table->boolean('mandatory')->default(false); + $table->json('condition')->nullable(); + $table->timestamp('scheduled_at')->index(); + $table->string('status', 20)->default('pending')->index(); + $table->string('idempotency_key', 190)->unique(); + $table->timestamp('sent_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + $table->index(['organization_id', 'status', 'scheduled_at'], 'notification_schedule_due_index'); + }); + } + + public function down(): void + { + Schema::dropIfExists('notification_schedules'); + Schema::table('in_app_notifications', function (Blueprint $table): void { + $table->dropUnique(['idempotency_key']); + $table->dropColumn('idempotency_key'); + }); + } +}; diff --git a/backend/database/migrations/2026_08_22_120000_add_library_metadata_to_course_templates.php b/backend/database/migrations/2026_08_22_120000_add_library_metadata_to_course_templates.php new file mode 100644 index 0000000..030780b --- /dev/null +++ b/backend/database/migrations/2026_08_22_120000_add_library_metadata_to_course_templates.php @@ -0,0 +1,26 @@ +string('status', 20)->default('active')->after('category'); + $table->string('level', 20)->default('intermediate')->after('status'); + $table->unsignedSmallInteger('estimated_minutes')->nullable()->after('level'); + $table->index(['organization_id', 'status']); + }); + } + + public function down(): void + { + Schema::table('course_templates', function (Blueprint $table): void { + $table->dropIndex(['organization_id', 'status']); + $table->dropColumn(['status', 'level', 'estimated_minutes']); + }); + } +}; diff --git a/backend/database/migrations/2026_08_22_130000_add_question_categories_and_course_bookmarks.php b/backend/database/migrations/2026_08_22_130000_add_question_categories_and_course_bookmarks.php new file mode 100644 index 0000000..3b25c88 --- /dev/null +++ b/backend/database/migrations/2026_08_22_130000_add_question_categories_and_course_bookmarks.php @@ -0,0 +1,39 @@ +ulid('id')->primary(); + $table->foreignUlid('organization_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('parent_id')->nullable()->constrained('question_categories')->restrictOnDelete(); + $table->string('name', 120); + $table->timestamps(); + $table->unique(['organization_id', 'parent_id', 'name']); + $table->index(['organization_id', 'parent_id']); + }); + + Schema::table('questions', function (Blueprint $table) { + $table->foreignUlid('question_category_id')->nullable()->after('topic')->constrained('question_categories')->nullOnDelete(); + }); + + Schema::create('course_bookmarks', function (Blueprint $table) { + $table->foreignUlid('course_id')->constrained()->cascadeOnDelete(); + $table->foreignUlid('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->primary(['course_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('course_bookmarks'); + Schema::table('questions', fn (Blueprint $table) => $table->dropConstrainedForeignId('question_category_id')); + Schema::dropIfExists('question_categories'); + } +}; diff --git a/backend/database/migrations/2026_08_25_120000_add_authorship_to_course_versions.php b/backend/database/migrations/2026_08_25_120000_add_authorship_to_course_versions.php new file mode 100644 index 0000000..7b7b8b6 --- /dev/null +++ b/backend/database/migrations/2026_08_25_120000_add_authorship_to_course_versions.php @@ -0,0 +1,30 @@ +foreignUlid('created_by')->nullable()->after('source_version_id')->constrained('users')->nullOnDelete(); + $table->foreignUlid('published_by')->nullable()->after('created_by')->constrained('users')->nullOnDelete(); + }); + + DB::table('course_versions')->select(['id', 'course_id'])->orderBy('id')->each(function (object $version): void { + $creator = DB::table('courses')->where('id', $version->course_id)->value('created_by'); + DB::table('course_versions')->where('id', $version->id)->update(['created_by' => $creator]); + }); + } + + public function down(): void + { + Schema::table('course_versions', function (Blueprint $table) { + $table->dropConstrainedForeignId('published_by'); + $table->dropConstrainedForeignId('created_by'); + }); + } +}; diff --git a/backend/database/migrations/2026_08_25_150000_extend_course_review_threads.php b/backend/database/migrations/2026_08_25_150000_extend_course_review_threads.php new file mode 100644 index 0000000..cf0047e --- /dev/null +++ b/backend/database/migrations/2026_08_25_150000_extend_course_review_threads.php @@ -0,0 +1,31 @@ +string('title', 180)->nullable()->after('status'); + $table->json('assignee_user_ids')->nullable()->after('mention_user_ids'); + $table->json('attachment_asset_ids')->nullable()->after('assignee_user_ids'); + }); + Schema::table('review_replies', function (Blueprint $table): void { + $table->json('attachment_asset_ids')->nullable()->after('mention_user_ids'); + $table->timestamp('edited_at')->nullable()->after('attachment_asset_ids'); + }); + } + + public function down(): void + { + Schema::table('review_replies', function (Blueprint $table): void { + $table->dropColumn(['attachment_asset_ids', 'edited_at']); + }); + Schema::table('review_threads', function (Blueprint $table): void { + $table->dropColumn(['title', 'assignee_user_ids', 'attachment_asset_ids']); + }); + } +}; diff --git a/backend/database/migrations/2026_08_25_170000_add_course_analytics_indexes.php b/backend/database/migrations/2026_08_25_170000_add_course_analytics_indexes.php new file mode 100644 index 0000000..8951218 --- /dev/null +++ b/backend/database/migrations/2026_08_25_170000_add_course_analytics_indexes.php @@ -0,0 +1,28 @@ +index(['organization_id', 'course_version_id', 'occurred_at'], 'learning_events_course_analytics_idx'); + }); + Schema::table('assessment_attempts', function (Blueprint $table): void { + $table->index(['organization_id', 'course_version_id', 'status', 'completed_at'], 'assessment_attempts_course_analytics_idx'); + }); + } + + public function down(): void + { + Schema::table('assessment_attempts', function (Blueprint $table): void { + $table->dropIndex('assessment_attempts_course_analytics_idx'); + }); + Schema::table('learning_events', function (Blueprint $table): void { + $table->dropIndex('learning_events_course_analytics_idx'); + }); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..daf7e56 --- /dev/null +++ b/backend/database/seeders/DatabaseSeeder.php @@ -0,0 +1,264 @@ +user(null, 'مدیر سامانه', 'admin@microlearn.test', UserRole::SuperAdmin); + + $organization = Organization::query()->updateOrCreate(['slug' => 'safe-academy'], [ + 'name' => 'آکادمی ایمن', + 'status' => 'active', + 'default_locale' => 'fa', + 'timezone' => 'Asia/Tehran', + ]); + + $designer = $this->user($organization->getKey(), 'سارا محمدی', 'designer@microlearn.test', UserRole::CourseDesigner); + $manager = $this->user($organization->getKey(), 'علی رضایی', 'manager@microlearn.test', UserRole::Manager); + $learners = collect([ + $this->user($organization->getKey(), 'مریم احمدی', 'maryam@microlearn.test', UserRole::Learner), + $this->user($organization->getKey(), 'امیر حسینی', 'amir@microlearn.test', UserRole::Learner), + $this->user($organization->getKey(), 'نازنین کریمی', 'nazanin@microlearn.test', UserRole::Learner), + ]); + $manager->update(['first_name' => 'علی', 'last_name' => 'رضایی', 'department' => 'عملیات', 'job_level' => 'manager']); + $profiles = [ + 'maryam@microlearn.test' => ['مریم', 'احمدی', 'کارشناس'], + 'amir@microlearn.test' => ['امیر', 'حسینی', 'کارشناس'], + 'nazanin@microlearn.test' => ['نازنین', 'کریمی', 'کارشناس'], + ]; + $learners->each(function (User $learner) use ($manager, $profiles): void { + [$firstName, $lastName] = $profiles[$learner->email]; + $learner->update([ + 'first_name' => $firstName, + 'last_name' => $lastName, + 'department' => 'عملیات', + 'job_level' => 'specialist', + 'direct_manager_id' => $manager->getKey(), + ]); + }); + + $team = Team::query()->updateOrCreate( + ['organization_id' => $organization->getKey(), 'name' => 'عملیات'], + ['description' => 'تیم عملیات و ایمنی محیط کار', 'status' => 'active', 'created_by' => $designer->getKey()], + ); + $team->managers()->syncWithoutDetaching([$manager->getKey()]); + $team->members()->syncWithoutDetaching($learners->pluck('id')->all()); + + Subscription::query()->updateOrCreate( + ['organization_id' => $organization->getKey(), 'plan_key' => 'enterprise'], + [ + 'status' => 'active', + 'starts_at' => now()->startOfMonth(), + 'expires_at' => now()->addYear(), + 'seat_limit' => 100, + 'storage_quota_bytes' => 107374182400, + 'storage_used_bytes' => 28991029248, + 'ai_credit_quota' => 10000, + 'ai_credits_used' => 2750, + 'enabled_features' => ['taxonomy', 'advanced_analytics', 'exports', 'certificates'], + ], + ); + + $course = Course::query()->updateOrCreate( + ['organization_id' => $organization->getKey(), 'slug' => 'workplace-safety'], + ['title' => 'ایمنی در محیط کار', 'status' => 'draft', 'created_by' => $designer->getKey()], + ); + $published = CourseVersion::query()->firstOrCreate( + ['course_id' => $course->getKey(), 'version_number' => 1], + [ + 'organization_id' => $organization->getKey(), 'status' => CourseVersionStatus::Published, + 'title' => 'ایمنی در محیط کار', 'description' => 'نسخه پایه منتشرشده برای آموزش ایمنی کارکنان.', + 'settings' => ['language' => 'fa', 'difficulty' => 'beginner'], 'published_at' => now()->subMonth(), + ], + ); + $publishedModule = CourseModule::query()->updateOrCreate( + ['course_version_id' => $published->getKey(), 'position' => 1], + ['organization_id' => $organization->getKey(), 'title' => 'مبانی ایمنی'], + ); + $publishedLesson = Lesson::query()->updateOrCreate( + ['course_module_id' => $publishedModule->getKey(), 'position' => 1], + ['organization_id' => $organization->getKey(), 'course_version_id' => $published->getKey(), 'title' => 'استفاده صحیح از تجهیزات حفاظتی', 'presentation_mode' => 'flow'], + ); + Block::query()->updateOrCreate( + ['lesson_id' => $publishedLesson->getKey(), 'position' => 1], + ['organization_id' => $organization->getKey(), 'course_version_id' => $published->getKey(), 'type' => 'heading', 'schema_version' => 1, 'data' => ['text' => 'ایمنی مسئولیت همه ماست', 'level' => 2], 'revision' => 1], + ); + Block::query()->updateOrCreate( + ['lesson_id' => $publishedLesson->getKey(), 'position' => 2], + ['organization_id' => $organization->getKey(), 'course_version_id' => $published->getKey(), 'type' => 'key_point', 'schema_version' => 1, 'data' => ['title' => 'نکته کلیدی', 'body' => 'پیش از شروع کار، تجهیزات حفاظت فردی را بررسی و به‌درستی استفاده کنید.'], 'revision' => 1], + ); + $draft = CourseVersion::query()->updateOrCreate( + ['course_id' => $course->getKey(), 'version_number' => 2], + [ + 'organization_id' => $organization->getKey(), 'source_version_id' => $published->getKey(), + 'status' => CourseVersionStatus::Draft, 'title' => 'ایمنی در محیط کار', + 'description' => 'بازنگری دوره با تمرکز بر تجهیزات حفاظت فردی و گزارش خطر.', + 'settings' => ['language' => 'fa', 'difficulty' => 'beginner'], + ], + ); + $basics = CourseModule::query()->updateOrCreate( + ['course_version_id' => $draft->getKey(), 'position' => 1], + ['organization_id' => $organization->getKey(), 'title' => 'مبانی ایمنی'], + ); + $response = CourseModule::query()->updateOrCreate( + ['course_version_id' => $draft->getKey(), 'position' => 2], + ['organization_id' => $organization->getKey(), 'title' => 'واکنش و گزارش‌دهی'], + ); + foreach ([[$basics, 1, 'شناخت خطرهای محیط کار'], [$basics, 2, 'تجهیزات حفاظت فردی'], [$response, 1, 'گزارش رویداد و خطر']] as [$module, $position, $title]) { + Lesson::query()->updateOrCreate( + ['course_module_id' => $module->getKey(), 'position' => $position], + ['organization_id' => $organization->getKey(), 'course_version_id' => $draft->getKey(), 'title' => $title, 'presentation_mode' => 'flow'], + ); + } + + $bankQuestion = Question::query()->updateOrCreate( + ['organization_id' => $organization->getKey(), 'is_bank_item' => true, 'prompt' => 'اولین اقدام پس از مشاهده خطر در محیط کار چیست؟'], + [ + 'type' => 'single_choice', 'configuration' => ['options' => [ + ['id' => 'report', 'text' => 'ایمن‌سازی محل و گزارش خطر', 'correct' => true, 'feedback' => 'درست است.'], + ['id' => 'ignore', 'text' => 'نادیده گرفتن تا پایان شیفت', 'correct' => false, 'feedback' => 'خطر باید فوری گزارش شود.'], + ]], + 'difficulty' => 'beginner', 'position' => 0, 'schema_version' => 1, + 'topic' => 'گزارش خطر', 'tags' => ['ایمنی', 'گزارش‌دهی'], 'explanation' => 'ابتدا محیط را ایمن و سپس خطر را طبق فرایند سازمان گزارش کنید.', + ], + ); + Question::query()->updateOrCreate( + ['organization_id' => $organization->getKey(), 'is_bank_item' => true, 'prompt' => 'در زمان نشت ماده ناشناخته چه تصمیمی می‌گیرید؟'], + [ + 'type' => 'scenario', 'configuration' => ['context' => 'در انبار بوی نامعمول و ظرف آسیب‌دیده مشاهده می‌کنید.', 'choices' => [ + ['text' => 'فاصله گرفتن، محدودکردن دسترسی و اطلاع به مسئول ایمنی', 'score' => 1, 'feedback' => 'انتخاب ایمن و مطابق رویه است.'], + ['text' => 'نزدیک شدن و بررسی ماده بدون تجهیزات', 'score' => 0, 'feedback' => 'این اقدام مواجهه را افزایش می‌دهد.'], + ]], + 'difficulty' => 'intermediate', 'position' => 0, 'schema_version' => 1, + 'topic' => 'واکنش اضطراری', 'tags' => ['سناریو', 'تصمیم‌گیری'], 'explanation' => 'در مواجهه با ماده ناشناخته، کنترل محدوده و گزارش فوری اولویت دارد.', + ], + ); + + $learnerAssignment = Assignment::query()->updateOrCreate( + ['organization_id' => $organization->getKey(), 'assignable_type' => 'course', 'assignable_id' => $published->getKey(), 'target_type' => 'team', 'target_id' => $team->getKey()], + ['status' => 'active', 'mandatory' => true, 'due_at' => now()->addDays(14), 'reminder_days' => 3, 'escalation_policy' => ['enabled' => true], 'source' => 'seed', 'assigned_by' => $designer->getKey()], + ); + app(AssignmentResolver::class)->sync($learnerAssignment); + + $progressProfiles = [ + 'maryam@microlearn.test' => ['progress' => 75, 'status' => 'in_progress', 'completed_at' => null], + 'amir@microlearn.test' => ['progress' => 100, 'status' => 'completed', 'completed_at' => now()->subDays(2)], + 'nazanin@microlearn.test' => ['progress' => 25, 'status' => 'in_progress', 'completed_at' => null], + ]; + foreach ($learners as $learner) { + DB::table('assignment_users')->where('assignment_id', $learnerAssignment->getKey())->where('user_id', $learner->getKey())->update([...$progressProfiles[$learner->email], 'updated_at' => now()]); + } + $publishedBlocks = Block::query()->where('course_version_id', $published->getKey())->orderBy('position')->get(); + $eventRows = [ + [$learners[0], '11111111-1111-4111-8111-111111111101', 'course.opened', null, null, 10, 120], + [$learners[0], '11111111-1111-4111-8111-111111111102', 'lesson.started', $publishedLesson, null, 9, 60], + [$learners[0], '11111111-1111-4111-8111-111111111103', 'block.completed', $publishedLesson, $publishedBlocks[0], 8, 240], + [$learners[1], '22222222-2222-4222-8222-222222222201', 'course.opened', null, null, 7, 90], + [$learners[1], '22222222-2222-4222-8222-222222222202', 'lesson.started', $publishedLesson, null, 6, 60], + [$learners[1], '22222222-2222-4222-8222-222222222203', 'lesson.completed', $publishedLesson, null, 2, 420], + [$learners[1], '22222222-2222-4222-8222-222222222204', 'course.completed', null, null, 2, 30], + [$learners[2], '33333333-3333-4333-8333-333333333301', 'course.opened', null, null, 14, 45], + [$learners[2], '33333333-3333-4333-8333-333333333302', 'lesson.started', $publishedLesson, null, 14, 30], + ]; + foreach ($eventRows as [$learner, $clientId, $type, $lesson, $block, $daysAgo, $duration]) { + LearningEvent::query()->firstOrCreate(['learner_id' => $learner->getKey(), 'client_event_id' => $clientId], [ + 'organization_id' => $organization->getKey(), 'event_type' => $type, 'schema_version' => 1, + 'session_id' => 'seed-'.$learner->getKey(), 'correlation_id' => $clientId, + 'device_context' => ['platform' => 'web', 'formFactor' => 'desktop', 'online' => true], + 'assignment_id' => $learnerAssignment->getKey(), 'course_version_id' => $published->getKey(), + 'lesson_id' => $lesson?->getKey(), 'block_id' => $block?->getKey(), 'payload' => ['durationSeconds' => $duration], + 'occurred_at' => now()->subDays($daysAgo), 'received_at' => now()->subDays($daysAgo)->addMinute(), + ]); + } + $publishedAssessment = Assessment::query()->updateOrCreate( + ['organization_id' => $organization->getKey(), 'course_version_id' => $published->getKey(), 'title' => 'آزمون کوتاه ایمنی'], + ['lesson_id' => $publishedLesson->getKey(), 'settings' => ['passingScore' => 70, 'attemptLimit' => 2]], + ); + $publishedQuestion = Question::query()->updateOrCreate( + ['assessment_id' => $publishedAssessment->getKey(), 'prompt' => 'پیش از شروع کار چه اقدامی ضروری است؟'], + ['organization_id' => $organization->getKey(), 'course_version_id' => $published->getKey(), 'is_bank_item' => false, 'type' => 'single_choice', 'configuration' => ['options' => [['text' => 'بررسی تجهیزات حفاظت فردی', 'correct' => true], ['text' => 'شروع فوری کار', 'correct' => false]]], 'difficulty' => 'beginner', 'position' => 1, 'schema_version' => 1], + ); + foreach ([[$learners[0], .85, true], [$learners[1], .72, true], [$learners[2], .45, false]] as [$learner, $score, $correct]) { + $attempt = AssessmentAttempt::query()->updateOrCreate( + ['learner_id' => $learner->getKey(), 'assessment_id' => $publishedAssessment->getKey(), 'attempt_number' => 1], + ['organization_id' => $organization->getKey(), 'course_version_id' => $published->getKey(), 'status' => 'completed', 'score' => $score, 'started_at' => now()->subDays(5), 'completed_at' => now()->subDays(5)->addMinutes(5)], + ); + QuestionResult::query()->updateOrCreate( + ['assessment_attempt_id' => $attempt->getKey(), 'question_id' => $publishedQuestion->getKey()], + ['organization_id' => $organization->getKey(), 'raw_value' => [$correct ? 0 : 1], 'normalized_value' => $score, 'is_correct' => $correct, 'occurred_at' => $attempt->completed_at, 'metadata' => ['source' => 'seed-review']], + ); + } + + $assessment = Assessment::query()->updateOrCreate( + ['organization_id' => $organization->getKey(), 'course_version_id' => $draft->getKey(), 'title' => 'ارزیابی مبانی ایمنی'], + [ + 'lesson_id' => Lesson::query()->where('course_version_id', $draft->getKey())->orderBy('position')->value('id'), + 'settings' => ['randomSelection' => false, 'questionPoolSize' => null, 'shuffleQuestions' => true, 'shuffleOptions' => true, 'passingScore' => 70, 'attemptLimit' => 2, 'feedbackMode' => 'after_submission', 'timeLimitSeconds' => 300], + ], + ); + Question::query()->updateOrCreate( + ['assessment_id' => $assessment->getKey(), 'source_question_id' => $bankQuestion->getKey()], + [ + 'organization_id' => $organization->getKey(), 'course_version_id' => $draft->getKey(), 'is_bank_item' => false, + 'type' => $bankQuestion->type, 'prompt' => $bankQuestion->prompt, 'configuration' => $bankQuestion->configuration, + 'difficulty' => $bankQuestion->difficulty, 'position' => 1, 'schema_version' => 1, + 'topic' => $bankQuestion->topic, 'tags' => $bankQuestion->tags, 'explanation' => $bankQuestion->explanation, + ], + ); + app(AnalyticsProjectionService::class)->rebuild($organization->getKey()); + app(MonitoringEngine::class)->refresh($organization->getKey()); + } + + private function user(?string $organizationId, string $name, string $email, UserRole $role): User + { + $attributes = [ + 'organization_id' => $organizationId, + 'name' => $name, + 'password' => 'password', + 'email_verified_at' => now(), + 'role' => $role, + 'status' => AccountStatus::Active, + 'locale' => 'fa', + 'timezone' => 'Asia/Tehran', + ]; + $user = User::query()->where('email', $email)->first() ?? new User(['email' => $email]); + + if (! $user->exists && Schema::getColumnType('users', 'id') === 'integer') { + $user->forceFill(['id' => ((int) User::query()->max('id')) + 1]); + } + + $user->fill($attributes)->save(); + + return $user; + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..a047e26 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,16 @@ +{ + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "axios": "^1.7.4", + "concurrently": "^9.0.1", + "laravel-vite-plugin": "^1.2.0", + "tailwindcss": "^4.0.0", + "vite": "^6.0.11" + } +} diff --git a/backend/php.ini b/backend/php.ini new file mode 100644 index 0000000..db63485 --- /dev/null +++ b/backend/php.ini @@ -0,0 +1,20 @@ +[PHP] +extension_dir="D:/app/php/ext" +extension=curl +extension=fileinfo +extension=gd +extension=mbstring +extension=mysqli +extension=openssl +extension=pdo_mysql +extension=pdo_sqlite +extension=zip + +date.timezone=Asia/Tehran +memory_limit=512M +upload_max_filesize=2G +post_max_size=2050M +max_execution_time=0 + +[mysqlnd] +mysqlnd.collect_statistics=Off diff --git a/backend/phpunit.xml b/backend/phpunit.xml new file mode 100644 index 0000000..aab7f51 --- /dev/null +++ b/backend/phpunit.xml @@ -0,0 +1,32 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + diff --git a/backend/public/.htaccess b/backend/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/backend/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/backend/public/favicon.ico b/backend/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/backend/public/index.php b/backend/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/backend/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/backend/public/robots.txt b/backend/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/backend/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/backend/resources/css/app.css b/backend/resources/css/app.css new file mode 100644 index 0000000..2243c60 --- /dev/null +++ b/backend/resources/css/app.css @@ -0,0 +1,12 @@ +@import 'tailwindcss'; + +@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; +@source '../../storage/framework/views/*.php'; +@source "../**/*.blade.php"; +@source "../**/*.js"; +@source "../**/*.vue"; + +@theme { + --font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', + 'Segoe UI Symbol', 'Noto Color Emoji'; +} diff --git a/backend/resources/js/app.js b/backend/resources/js/app.js new file mode 100644 index 0000000..e59d6a0 --- /dev/null +++ b/backend/resources/js/app.js @@ -0,0 +1 @@ +import './bootstrap'; diff --git a/backend/resources/js/bootstrap.js b/backend/resources/js/bootstrap.js new file mode 100644 index 0000000..5f1390b --- /dev/null +++ b/backend/resources/js/bootstrap.js @@ -0,0 +1,4 @@ +import axios from 'axios'; +window.axios = axios; + +window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; diff --git a/backend/resources/views/welcome.blade.php b/backend/resources/views/welcome.blade.php new file mode 100644 index 0000000..c893b80 --- /dev/null +++ b/backend/resources/views/welcome.blade.php @@ -0,0 +1,277 @@ + + + + + + + Laravel + + + + + + + @if (file_exists(public_path('build/manifest.json')) || file_exists(public_path('hot'))) + @vite(['resources/css/app.css', 'resources/js/app.js']) + @else + + @endif + + +
        + @if (Route::has('login')) + + @endif +
        +
        +
        +
        +

        Let's get started

        +

        Laravel has an incredibly rich ecosystem.
        We suggest starting with the following.

        + + +
        +
        + {{-- Laravel Logo --}} + + + + + + + + + + + {{-- Light Mode 12 SVG --}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{-- Dark Mode 12 SVG --}} + +
        +
        +
        +
        + + @if (Route::has('login')) + + @endif + + diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..b1efe52 --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,284 @@ +name('assets.content')->middleware('signed:relative'); + +Route::prefix('v1')->group(function () { + Route::get('/health', SystemHealthController::class); + Route::get('/certificates/verify/{code}', [CertificateController::class, 'verify'])->middleware('throttle:60,1'); + + Route::post('/auth/login', [AuthController::class, 'login'])->middleware('throttle:login'); + Route::post('/auth/forgot-password', [PasswordController::class, 'forgot'])->middleware('throttle:login'); + Route::post('/auth/reset-password', [PasswordController::class, 'reset'])->middleware('throttle:login'); + Route::post('/auth/invitations/accept', [InvitationController::class, 'accept'])->middleware('throttle:login'); + + Route::middleware('auth:sanctum')->group(function () { + Route::get('/auth/me', [AuthController::class, 'me']); + Route::post('/auth/logout', [AuthController::class, 'logout']); + Route::get('/organizations', [OrganizationController::class, 'index']); + Route::post('/organizations', [OrganizationController::class, 'store']); + Route::get('/organizations/{organization}', [OrganizationController::class, 'show']); + Route::patch('/organizations/{organization}', [OrganizationController::class, 'update']); + + Route::middleware('tenant')->group(function () { + Route::post('/ai/chat', [AiChatController::class, 'chat'])->middleware('throttle:10,1'); + Route::get('/organization-context', fn (TenantContext $tenant) => response()->json([ + 'data' => [ + 'id' => $tenant->id(), + 'name' => $tenant->organization()->name, + ], + ])); + Route::get('/learner/home', [LearnerController::class, 'home']); + Route::get('/learner/progress', [LearnerController::class, 'progress']); + Route::get('/learner/assignments/{assignment}', [LearnerController::class, 'show']); + Route::post('/learner/events/sync', [LearnerController::class, 'sync']); + Route::post('/learner/assignments/{assignment}/notes', [LearnerController::class, 'note']); + Route::post('/learner/assignments/{assignment}/bookmarks', [LearnerController::class, 'bookmark']); + Route::post('/learner/assignments/{assignment}/highlights', [LearnerController::class, 'highlight']); + Route::post('/learner/assignments/{assignment}/favorite', [LearnerController::class, 'favorite']); + Route::post('/learner/assignments/{assignment}/discussions', [LearnerController::class, 'discuss']); + Route::post('/learner/discussions/{discussion}/reactions', [LearnerController::class, 'react']); + Route::get('/manager/workspace', [ManagerWorkspaceController::class, 'show']); + Route::get('/manager/assignment-contexts', [ManagerAssignmentController::class, 'contexts']); + Route::get('/manager/assignments', [ManagerAssignmentController::class, 'index']); + Route::post('/manager/assignments', [ManagerAssignmentController::class, 'store']); + Route::patch('/manager/assignments/{assignment}/deadline', [ManagerAssignmentController::class, 'deadline']); + Route::post('/manager/assignments/{assignment}/reminders', [ManagerAssignmentController::class, 'remind'])->middleware('throttle:20,1'); + Route::get('/monitoring', [MonitoringController::class, 'show']); + Route::post('/monitoring/rebuild', [MonitoringController::class, 'rebuild']); + Route::get('/courses/{course}/analytics', [CourseAnalyticsController::class, 'show']); + Route::get('/courses/{course}/analytics/report', [CourseAnalyticsController::class, 'report']); + Route::get('/course-versions/{version}/collaboration', [CollaborationController::class, 'snapshot']); + Route::post('/blocks/{block}/soft-lock', [CollaborationController::class, 'acquireLock']); + Route::put('/blocks/{block}/soft-lock', [CollaborationController::class, 'renewLock']); + Route::delete('/blocks/{block}/soft-lock', [CollaborationController::class, 'releaseLock']); + Route::post('/course-versions/{version}/review-threads', [CollaborationController::class, 'createThread']); + Route::post('/review-threads/{thread}/replies', [CollaborationController::class, 'reply']); + Route::patch('/review-threads/{thread}/resolution', [CollaborationController::class, 'resolve']); + Route::patch('/review-threads/{thread}/assignees', [CollaborationController::class, 'assign']); + Route::post('/review-threads/{thread}/read', [CollaborationController::class, 'markRead']); + Route::patch('/review-messages/{kind}/{message}', [CollaborationController::class, 'updateMessage']); + Route::delete('/review-messages/{kind}/{message}', [CollaborationController::class, 'deleteMessage']); + Route::post('/review-threads/{thread}/reactions', [CollaborationController::class, 'react']); + Route::get('/review-center', [CollaborationController::class, 'reviewCenter']); + Route::get('/notifications', [NotificationController::class, 'index']); + Route::post('/notifications/{notification}/read', [NotificationController::class, 'read']); + Route::delete('/notifications/{notification}', [NotificationController::class, 'dismiss']); + Route::get('/preferences', [ProductSurfaceController::class, 'preferences']); + Route::patch('/preferences', [ProductSurfaceController::class, 'updatePreferences']); + Route::get('/organization-settings', [ProductSurfaceController::class, 'organizationSettings']); + Route::patch('/organization-settings', [ProductSurfaceController::class, 'updateOrganizationSettings']); + Route::get('/brand', [ProductSurfaceController::class, 'brand']); + Route::patch('/brand', [ProductSurfaceController::class, 'updateBrand']); + Route::get('/templates', [ProductSurfaceController::class, 'templates']); + Route::post('/templates', [ProductSurfaceController::class, 'storeTemplate']); + Route::patch('/templates/{template}', [ProductSurfaceController::class, 'updateTemplate']); + Route::delete('/templates/{template}', [ProductSurfaceController::class, 'destroyTemplate']); + Route::post('/templates/{template}/instantiate', [ProductSurfaceController::class, 'instantiateTemplate']); + Route::get('/product-surfaces/{surface}', [ProductSurfaceController::class, 'surface']); + Route::get('/exports', [ExportController::class, 'index']); + Route::get('/exports/data/{type}', [ExportController::class, 'data']); + Route::post('/exports/compatibility', [ExportController::class, 'compatibility']); + Route::post('/exports', [ExportController::class, 'store']); + Route::post('/exports/{export}/retry', [ExportController::class, 'retry']); + Route::post('/exports/{export}/cancel', [ExportController::class, 'cancel']); + Route::get('/exports/{export}/download', [ExportController::class, 'download']); + Route::get('/certificates', [CertificateController::class, 'index']); + Route::post('/certificate-templates', [CertificateController::class, 'storeTemplate']); + Route::patch('/certificate-templates/{template}', [CertificateController::class, 'updateTemplate']); + Route::delete('/certificate-templates/{template}', [CertificateController::class, 'archiveTemplate']); + Route::post('/certificates/issue', [CertificateController::class, 'issue']); + Route::post('/certificates/issue-bulk', [CertificateController::class, 'issueBulk']); + Route::post('/certificates/{certificate}/revoke', [CertificateController::class, 'revoke']); + Route::get('/certificates/{certificate}/download', [CertificateController::class, 'download']); + Route::get('/ai-studio/jobs', [AiStudioController::class, 'index']); + Route::get('/ai-settings', [AiStudioController::class, 'settings']); + Route::patch('/ai-settings', [AiStudioController::class, 'updateSettings']); + Route::post('/ai-settings/health', [AiStudioController::class, 'health'])->middleware('throttle:10,1'); + Route::get('/ai-connections', [AiConnectionController::class, 'index']); + Route::post('/ai-connections', [AiConnectionController::class, 'store']); + Route::patch('/ai-connections/{connection}', [AiConnectionController::class, 'update']); + Route::delete('/ai-connections/{connection}', [AiConnectionController::class, 'destroy']); + Route::post('/ai-connections/{connection}/default', [AiConnectionController::class, 'makeDefault']); + Route::post('/ai-connections/{connection}/test', [AiConnectionController::class, 'test'])->middleware('throttle:10,1'); + Route::post('/ai-connections/{connection}/models', [AiConnectionController::class, 'discover'])->middleware('throttle:10,1'); + Route::post('/ai-studio/jobs', [AiStudioController::class, 'store'])->middleware('throttle:10,1'); + Route::get('/ai-studio/jobs/{job}', [AiStudioController::class, 'show']); + Route::post('/ai-studio/jobs/{job}/cancel', [AiStudioController::class, 'cancel']); + Route::post('/ai-studio/jobs/{job}/retry', [AiStudioController::class, 'retry'])->middleware('throttle:10,1'); + Route::post('/ai-studio/assist', [AiStudioController::class, 'assist'])->middleware('throttle:10,1'); + Route::post('/ai-studio/taxonomy-suggestions', [AiStudioController::class, 'taxonomySuggestions']); + Route::post('/ai-studio/suggestions/{suggestion}/accept', [AiStudioController::class, 'accept']); + Route::post('/ai-studio/suggestions/{suggestion}/reject', [AiStudioController::class, 'reject']); + + Route::get('/taxonomy-types', [TaxonomyTypeController::class, 'index']); + Route::post('/taxonomy-types', [TaxonomyTypeController::class, 'store']); + Route::get('/taxonomy-nodes', [TaxonomyNodeController::class, 'index']); + Route::post('/taxonomy-nodes', [TaxonomyNodeController::class, 'store']); + Route::patch('/taxonomy-nodes/{node}', [TaxonomyNodeController::class, 'update']); + Route::get('/content-mappings', [ContentMappingController::class, 'index']); + Route::post('/content-mappings', [ContentMappingController::class, 'store']); + Route::delete('/content-mappings/{mapping}', [ContentMappingController::class, 'destroy']); + Route::get('/taxonomy-workspace', [TaxonomyWorkspaceController::class, 'index']); + Route::post('/competency-frameworks', [TaxonomyWorkspaceController::class, 'storeFramework']); + Route::patch('/competency-frameworks/{framework}', [TaxonomyWorkspaceController::class, 'updateFramework']); + Route::put('/competency-frameworks/{framework}/items', [TaxonomyWorkspaceController::class, 'syncItems']); + Route::delete('/competency-frameworks/{framework}', [TaxonomyWorkspaceController::class, 'destroyFramework']); + Route::get('/assets', [AssetController::class, 'index']); + Route::get('/assets/{asset}', [AssetController::class, 'show']); + Route::post('/assets', [AssetController::class, 'store']); + Route::delete('/assets/{asset}', [AssetController::class, 'destroy']); + Route::get('/assessment-contexts', [AssessmentController::class, 'contexts']); + Route::get('/assessments', [AssessmentController::class, 'index']); + Route::post('/assessments', [AssessmentController::class, 'store']); + Route::get('/assessments/{assessment}', [AssessmentController::class, 'show']); + Route::patch('/assessments/{assessment}', [AssessmentController::class, 'update']); + Route::delete('/assessments/{assessment}', [AssessmentController::class, 'destroy']); + Route::post('/assessments/{assessment}/questions', [AssessmentController::class, 'storeQuestion']); + Route::put('/assessments/{assessment}/questions/order', [AssessmentController::class, 'reorderQuestions']); + Route::patch('/questions/{question}', [AssessmentController::class, 'updateQuestion']); + Route::delete('/questions/{question}', [AssessmentController::class, 'destroyQuestion']); + Route::get('/question-bank', [QuestionBankController::class, 'index']); + Route::post('/question-bank', [QuestionBankController::class, 'store']); + Route::patch('/question-bank/{question}', [QuestionBankController::class, 'update']); + Route::delete('/question-bank/{question}', [QuestionBankController::class, 'destroy']); + Route::get('/question-categories', [QuestionCategoryController::class, 'index']); + Route::post('/question-categories', [QuestionCategoryController::class, 'store']); + Route::delete('/question-categories/{category}', [QuestionCategoryController::class, 'destroy']); + Route::get('/users', [UserController::class, 'index']); + Route::get('/users/import-template', [UserController::class, 'template']); + Route::post('/users/import', [UserController::class, 'import']); + Route::patch('/users/{user}', [UserController::class, 'update']); + Route::post('/user-invitations', [InvitationController::class, 'store']); + Route::get('/user-invitations', [InvitationController::class, 'index']); + Route::post('/user-invitations/{invitation}/resend', [InvitationController::class, 'resend']); + Route::delete('/user-invitations/{invitation}', [InvitationController::class, 'revoke']); + Route::get('/teams', [TeamController::class, 'index']); + Route::post('/teams', [TeamController::class, 'store']); + Route::get('/teams/{team}', [TeamController::class, 'show']); + Route::patch('/teams/{team}', [TeamController::class, 'update']); + Route::put('/teams/{team}/members', [TeamController::class, 'attachMember']); + Route::put('/teams/{team}/managers', [TeamController::class, 'attachManager']); + Route::delete('/teams/{team}/members/{user}', [TeamController::class, 'detachMember']); + Route::delete('/teams/{team}/managers/{user}', [TeamController::class, 'detachManager']); + Route::get('/subscription', [SubscriptionController::class, 'current']); + Route::get('/courses', [CourseController::class, 'index']); + Route::post('/courses', [CourseController::class, 'store']); + Route::get('/courses/{course}', [CourseController::class, 'show']); + Route::patch('/courses/{course}', [CourseController::class, 'update']); + Route::post('/courses/{course}/bookmark', [CourseController::class, 'toggleBookmark']); + Route::post('/courses/{course}/archive', [CourseController::class, 'archive']); + Route::get('/courses/{course}/versions', [CourseVersionController::class, 'index']); + Route::get('/courses/{course}/versions/compare', [CourseVersionController::class, 'compare']); + Route::patch('/courses/{course}/versions/{version}', [CourseController::class, 'updateVersion']); + Route::post('/courses/{course}/versions/{version}/fork', [CourseController::class, 'fork']); + Route::get('/courses/{course}/versions/{version}/readiness', [CoursePublicationController::class, 'readiness']); + Route::put('/courses/{course}/versions/{version}/completion-rules', [CoursePublicationController::class, 'configure']); + Route::post('/courses/{course}/versions/{version}/review', [CoursePublicationController::class, 'submitReview']); + Route::post('/courses/{course}/versions/{version}/return-to-draft', [CoursePublicationController::class, 'returnDraft']); + Route::post('/courses/{course}/versions/{version}/publish', [CoursePublicationController::class, 'publish']); + Route::post('/courses/{course}/versions/{version}/schedule', [CoursePublicationController::class, 'schedule']); + Route::delete('/courses/{course}/versions/{version}/schedule', [CoursePublicationController::class, 'cancelSchedule']); + Route::post('/courses/{course}/versions/{version}/unpublish', [CoursePublicationController::class, 'unpublish']); + Route::get('/assignment-contexts', [AssignmentController::class, 'contexts']); + Route::post('/assignment-audience-import', [AssignmentController::class, 'importAudience']); + Route::get('/assignments', [AssignmentController::class, 'index']); + Route::post('/assignments', [AssignmentController::class, 'store']); + Route::post('/assignments/bulk', [AssignmentController::class, 'bulk']); + Route::get('/assignments/{assignment}', [AssignmentController::class, 'show']); + Route::patch('/assignments/{assignment}', [AssignmentController::class, 'update']); + Route::post('/assignments/{assignment}/reminders', [AssignmentController::class, 'remind'])->middleware('throttle:20,1'); + Route::post('/assignments/sync', [AssignmentController::class, 'sync']); + Route::post('/assignments/{assignment}/cancel', [AssignmentController::class, 'cancel']); + Route::get('/learning-path-contexts', [LearningPathController::class, 'contexts']); + Route::get('/learning-paths', [LearningPathController::class, 'index']); + Route::post('/learning-paths', [LearningPathController::class, 'store']); + Route::get('/learning-paths/{path}', [LearningPathController::class, 'show']); + Route::patch('/learning-paths/{path}/versions/{version}', [LearningPathController::class, 'update']); + Route::post('/learning-paths/{path}/versions/{version}/items', [LearningPathController::class, 'addItem']); + Route::put('/learning-paths/{path}/versions/{version}/items/order', [LearningPathController::class, 'reorder']); + Route::delete('/learning-path-items/{item}', [LearningPathController::class, 'removeItem']); + Route::post('/learning-paths/{path}/versions/{version}/review', [LearningPathController::class, 'submitReview']); + Route::post('/learning-paths/{path}/versions/{version}/publish', [LearningPathController::class, 'publish']); + Route::post('/learning-paths/{path}/versions/{version}/fork', [LearningPathController::class, 'fork']); + Route::post('/course-versions/{version}/modules', [CourseStructureController::class, 'storeModule']); + Route::put('/course-versions/{version}/modules/order', [CourseStructureController::class, 'reorderModules']); + Route::patch('/course-modules/{module}', [CourseStructureController::class, 'updateModule']); + Route::post('/course-modules/{module}/duplicate', [CourseStructureController::class, 'duplicateModule']); + Route::delete('/course-modules/{module}', [CourseStructureController::class, 'destroyModule']); + Route::post('/course-modules/{module}/lessons', [CourseStructureController::class, 'storeLesson']); + Route::put('/course-modules/{module}/lessons/order', [CourseStructureController::class, 'reorderLessons']); + Route::patch('/lessons/{lesson}', [CourseStructureController::class, 'updateLesson']); + Route::post('/lessons/{lesson}/duplicate', [CourseStructureController::class, 'duplicateLesson']); + Route::delete('/lessons/{lesson}', [CourseStructureController::class, 'destroyLesson']); + Route::get('/block-registry', [CourseBuilderController::class, 'registry']); + Route::get('/courses/{course}/versions/{version}/lessons/{lesson}/builder', [CourseBuilderController::class, 'show']); + Route::post('/courses/{course}/versions/{version}/lessons/{lesson}/blocks', [CourseBuilderController::class, 'store']); + Route::patch('/blocks/{block}', [CourseBuilderController::class, 'update']); + Route::post('/blocks/{block}/duplicate', [CourseBuilderController::class, 'duplicate']); + Route::delete('/blocks/{block}', [CourseBuilderController::class, 'destroy']); + Route::put('/lessons/{lesson}/blocks/order', [CourseBuilderController::class, 'reorder']); + }); + Route::get('/platform/overview', [ProductSurfaceController::class, 'platform']); + Route::get('/platform/usage', [PlatformOperationsController::class, 'usage']); + Route::get('/platform/storage', [PlatformOperationsController::class, 'storage']); + Route::get('/platform/ai', [PlatformOperationsController::class, 'ai']); + Route::get('/platform/health', [PlatformOperationsController::class, 'health']); + Route::get('/platform/jobs', [PlatformOperationsController::class, 'jobs']); + Route::get('/platform/backups', [PlatformOperationsController::class, 'backups']); + Route::get('/platform/audit', [PlatformGovernanceController::class, 'audit']); + Route::get('/platform/administrators', [PlatformGovernanceController::class, 'administrators']); + Route::match(['get', 'patch'], '/platform/settings', [ProductSurfaceController::class, 'platformSettings']); + Route::get('/platform/subscriptions', [PlatformSubscriptionController::class, 'index']); + Route::post('/platform/subscriptions', [PlatformSubscriptionController::class, 'store']); + Route::patch('/platform/subscriptions/{subscription}', [PlatformSubscriptionController::class, 'update']); + Route::get('/platform/plans', [PlatformPlanController::class, 'index']); + Route::post('/platform/plans', [PlatformPlanController::class, 'store']); + Route::patch('/platform/plans/{plan}', [PlatformPlanController::class, 'update']); + Route::get('/platform/organizations/{organization}/plan', [PlatformOrganizationPlanController::class, 'show']); + Route::patch('/platform/organizations/{organization}/plan', [PlatformOrganizationPlanController::class, 'update']); + }); +}); diff --git a/backend/routes/console.php b/backend/routes/console.php new file mode 100644 index 0000000..54c9e63 --- /dev/null +++ b/backend/routes/console.php @@ -0,0 +1,60 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); + +Artisan::command('learning:process-schedules', function (CoursePublication $publication) { + $published = 0; + $unpublished = 0; + CourseVersion::query()->where('status', CourseVersionStatus::InReview)->whereNotNull('scheduled_publish_at')->where('scheduled_publish_at', '<=', now())->each(function (CourseVersion $version) use ($publication, &$published) { + $publication->publish($version); + $published++; + }); + CourseVersion::query()->where('status', CourseVersionStatus::Published)->whereNull('unpublished_at')->whereNotNull('scheduled_unpublish_at')->where('scheduled_unpublish_at', '<=', now())->each(function (CourseVersion $version) use ($publication, &$unpublished) { + $publication->unpublish($version); + $unpublished++; + }); + $this->info("Published {$published}; unpublished {$unpublished}."); +})->purpose('Process scheduled course publication lifecycle'); + +Schedule::command('learning:process-schedules')->everyMinute()->withoutOverlapping(); + +Artisan::command('notifications:process-schedules', function (NotificationOrchestrator $notifications) { + $result = $notifications->deliverDue(); + $this->info("Notification schedules: {$result['sent']} sent, {$result['skipped']} skipped, {$result['failed']} failed."); +})->purpose('Deliver due in-app notification schedules'); + +Schedule::command('notifications:process-schedules')->everyMinute()->withoutOverlapping(); + +Artisan::command('system:heartbeat', function () { + Cache::put('system:scheduler-heartbeat', now(), now()->addMinutes(5)); + $this->info('Scheduler heartbeat recorded.'); +}); + +Artisan::command('exports:prune', function () { + $cutoff = now()->subDays((int) config('exports.retention_days', 30)); + DB::table('export_jobs')->where('created_at', '<', $cutoff)->whereIn('status', ['completed', 'failed', 'cancelled'])->orderBy('created_at')->each(function ($row): void { + if ($row->path && $row->disk) { + Storage::disk($row->disk)->delete($row->path); + } + DB::table('export_jobs')->where('id', $row->id)->delete(); + }); + $this->info('Expired export artifacts pruned.'); +}); + +Schedule::command('system:heartbeat')->everyMinute(); +Schedule::job(new QueueHeartbeat)->everyMinute()->withoutOverlapping(); +Schedule::command('exports:prune')->dailyAt('02:30')->withoutOverlapping(); diff --git a/backend/routes/web.php b/backend/routes/web.php new file mode 100644 index 0000000..86a06c5 --- /dev/null +++ b/backend/routes/web.php @@ -0,0 +1,7 @@ +app->instance(AiEndpointPolicy::class, new AiEndpointPolicy( + resolver: fn (string $host): array => ['93.184.216.34'], + customProviderHosts: ['ai.example.test'], + )); + } + + public function test_designer_connects_discovers_and_selects_an_online_model_without_exposing_the_key(): void + { + Http::fake([ + 'https://ai.example.test/v1/models' => Http::response(['data' => [['id' => 'model-b'], ['id' => 'model-a']]]), + ]); + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + + $connection = $this->postJson('/api/v1/ai-connections', [ + 'name' => 'سرویس سازمانی', + 'provider' => 'openai_compatible', + 'mode' => 'online', + 'baseUrl' => 'https://ai.example.test/v1', + 'apiKey' => 'secret-token', + 'defaultModel' => null, + 'timeoutSeconds' => 60, + 'enabled' => true, + ])->assertCreated()->assertJsonPath('data.hasApiKey', true)->assertJsonMissing(['apiKey' => 'secret-token']); + + $id = $connection->json('data.id'); + $this->postJson("/api/v1/ai-connections/{$id}/models") + ->assertOk() + ->assertJsonPath('data.models.0', 'model-a'); + $this->postJson("/api/v1/ai-connections/{$id}/test") + ->assertOk() + ->assertJsonPath('data.connected', true); + + $row = DB::table('ai_provider_connections')->where('id', $id)->first(); + $this->assertNotSame('secret-token', $row->encrypted_api_key); + $this->assertSame('model-a', $row->default_model); + } + + public function test_local_connections_allow_http_but_managers_cannot_manage_connections(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + $this->postJson('/api/v1/ai-connections', [ + 'name' => 'Ollama داخلی', + 'provider' => 'ollama', + 'mode' => 'local', + 'baseUrl' => 'http://127.0.0.1:11434/v1', + 'defaultModel' => null, + 'timeoutSeconds' => 90, + 'enabled' => true, + ])->assertCreated()->assertJsonPath('data.isDefault', true); + + Sanctum::actingAs(User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Manager])); + $this->getJson('/api/v1/ai-connections')->assertForbidden(); + } + + public function test_connection_urls_are_policy_enforced_and_connections_are_tenant_scoped(): void + { + $firstDesigner = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($firstDesigner); + + $this->postJson('/api/v1/ai-connections', [ + 'name' => 'Metadata target', + 'provider' => 'ollama', + 'mode' => 'local', + 'baseUrl' => 'http://169.254.169.254:11434/v1', + 'defaultModel' => null, + 'timeoutSeconds' => 30, + 'enabled' => true, + ])->assertUnprocessable()->assertJsonValidationErrors('baseUrl'); + + $connectionId = $this->postJson('/api/v1/ai-connections', [ + 'name' => 'Tenant local provider', + 'provider' => 'ollama', + 'mode' => 'local', + 'baseUrl' => 'http://127.0.0.1:11434/v1', + 'defaultModel' => null, + 'timeoutSeconds' => 30, + 'enabled' => true, + ])->assertCreated()->json('data.id'); + + Sanctum::actingAs(User::factory()->create(['role' => UserRole::CourseDesigner])); + $this->postJson("/api/v1/ai-connections/{$connectionId}/test")->assertNotFound(); + } + + public function test_enabled_and_default_invariant_is_preserved_across_the_connection_lifecycle(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + + $disabled = $this->postJson('/api/v1/ai-connections', $this->localConnection('Disabled', false)) + ->assertCreated() + ->assertJsonPath('data.enabled', false) + ->assertJsonPath('data.isDefault', false) + ->json('data.id'); + + $first = $this->postJson('/api/v1/ai-connections', $this->localConnection('First enabled')) + ->assertCreated() + ->assertJsonPath('data.isDefault', true) + ->json('data.id'); + $second = $this->postJson('/api/v1/ai-connections', $this->localConnection('Second enabled')) + ->assertCreated() + ->assertJsonPath('data.isDefault', false) + ->json('data.id'); + + $this->postJson("/api/v1/ai-connections/{$second}/default") + ->assertOk() + ->assertJsonPath('data.isDefault', true); + $this->assertDatabaseHas('ai_provider_connections', ['id' => $first, 'is_default' => false]); + + $this->patchJson("/api/v1/ai-connections/{$second}", $this->localConnection('Second enabled', false)) + ->assertOk() + ->assertJsonPath('data.enabled', false) + ->assertJsonPath('data.isDefault', false); + $this->assertDatabaseHas('ai_provider_connections', ['id' => $first, 'enabled' => true, 'is_default' => true]); + + $this->deleteJson("/api/v1/ai-connections/{$first}")->assertNoContent(); + $this->assertSame(0, DB::table('ai_provider_connections')->where('organization_id', $designer->organization_id)->where('is_default', true)->count()); + + $this->assertDatabaseHas('ai_provider_connections', ['id' => $disabled, 'enabled' => false, 'is_default' => false]); + $this->assertDatabaseHas('ai_provider_connections', ['id' => $second, 'enabled' => false, 'is_default' => false]); + } + + public function test_deleting_the_default_promotes_another_enabled_connection(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + + $default = $this->postJson('/api/v1/ai-connections', $this->localConnection('Default'))->assertCreated()->json('data.id'); + $replacement = $this->postJson('/api/v1/ai-connections', $this->localConnection('Replacement'))->assertCreated()->json('data.id'); + + $this->deleteJson("/api/v1/ai-connections/{$default}")->assertNoContent(); + + $this->assertDatabaseHas('ai_provider_connections', ['id' => $replacement, 'enabled' => true, 'is_default' => true]); + $this->assertSame(1, DB::table('ai_provider_connections')->where('organization_id', $designer->organization_id)->where('enabled', true)->where('is_default', true)->count()); + } + + /** @return array */ + private function localConnection(string $name, bool $enabled = true): array + { + return [ + 'name' => $name, + 'provider' => 'ollama', + 'mode' => 'local', + 'baseUrl' => 'http://127.0.0.1:11434/v1', + 'defaultModel' => 'qwen', + 'timeoutSeconds' => 30, + 'enabled' => $enabled, + ]; + } +} diff --git a/backend/tests/Feature/AI/AiProviderResolverTest.php b/backend/tests/Feature/AI/AiProviderResolverTest.php new file mode 100644 index 0000000..ac18456 --- /dev/null +++ b/backend/tests/Feature/AI/AiProviderResolverTest.php @@ -0,0 +1,79 @@ +create(); + $this->connection($organization->id, '01J00000000000000000000001', enabled: true, default: false, createdAt: '2026-01-01 00:00:00'); + $this->connection($organization->id, '01J00000000000000000000002', enabled: true, default: true, createdAt: '2026-01-02 00:00:00'); + + $provider = app(AiProviderResolver::class)->forOrganization($organization->id); + + $this->assertSame('connection:01J00000000000000000000002', $provider->id()); + } + + public function test_disabled_deleted_and_cross_tenant_connections_cannot_be_resolved(): void + { + $first = Organization::factory()->create(); + $second = Organization::factory()->create(); + $id = '01J00000000000000000000003'; + $this->connection($first->id, $id, enabled: false, default: false); + + $resolver = app(AiProviderResolver::class); + + foreach ([$first->id, $second->id] as $organizationId) { + try { + $resolver->byId('connection:'.$id, $organizationId); + $this->fail('Unavailable connection must not resolve.'); + } catch (RuntimeException) { + $this->addToAssertionCount(1); + } + } + + DB::table('ai_provider_connections')->where('id', $id)->delete(); + $this->expectException(RuntimeException::class); + $resolver->byId('connection:'.$id, $first->id); + } + + public function test_failed_external_provider_falls_back_to_the_local_provider_deterministically(): void + { + $organization = Organization::factory()->create(); + + $fallback = app(AiProviderResolver::class)->fallbackForOrganization($organization->id, 'connection:failed'); + + $this->assertNotNull($fallback); + $this->assertSame('local-structuring-v1', $fallback->id()); + $this->assertNull(app(AiProviderResolver::class)->fallbackForOrganization($organization->id, 'local')); + } + + private function connection(string $organizationId, string $id, bool $enabled, bool $default, string $createdAt = '2026-01-01 00:00:00'): void + { + DB::table('ai_provider_connections')->insert([ + 'id' => $id, + 'organization_id' => $organizationId, + 'name' => $id, + 'provider' => 'ollama', + 'mode' => 'local', + 'base_url' => 'http://127.0.0.1:11434/v1', + 'models' => json_encode(['qwen']), + 'default_model' => 'qwen', + 'timeout_seconds' => 30, + 'enabled' => $enabled, + 'is_default' => $default, + 'created_at' => $createdAt, + 'updated_at' => $createdAt, + ]); + } +} diff --git a/backend/tests/Feature/AI/AiSecurityTest.php b/backend/tests/Feature/AI/AiSecurityTest.php new file mode 100644 index 0000000..cca26df --- /dev/null +++ b/backend/tests/Feature/AI/AiSecurityTest.php @@ -0,0 +1,60 @@ +map(fn ($route) => $route->uri()); + + $this->assertFalse($uris->contains(fn (string $uri) => str_starts_with($uri, 'api/v1/v1/'))); + $this->assertSame(1, $uris->filter(fn (string $uri) => $uri === 'api/v1/health')->count()); + $this->assertSame(1, $uris->filter(fn (string $uri) => $uri === 'api/v1/certificates/verify/{code}')->count()); + $this->assertTrue($uris->contains('api/v1/ai/chat')); + } + + public function test_ai_chat_route_uses_the_modular_ai_controller(): void + { + $route = collect(Route::getRoutes())->first(fn ($route) => $route->uri() === 'api/v1/ai/chat'); + + $this->assertNotNull($route); + $this->assertSame(AiChatController::class.'@chat', $route->getActionName()); + } + + public function test_ai_chat_requires_authentication_and_authorization(): void + { + $this->postJson('/api/v1/ai/chat', ['prompt' => 'test'])->assertUnauthorized(); + + $manager = User::factory()->create(['role' => UserRole::Manager]); + Sanctum::actingAs($manager); + $this->postJson('/api/v1/ai/chat', ['prompt' => 'test'])->assertForbidden(); + + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + $this->postJson('/api/v1/ai/chat', ['prompt' => 'test']) + ->assertOk() + ->assertJsonPath('data.answer', 'test'); + } + + public function test_expensive_ai_chat_is_rate_limited_per_authenticated_user(): void + { + Sanctum::actingAs(User::factory()->create(['role' => UserRole::CourseDesigner])); + + foreach (range(1, 10) as $attempt) { + $this->postJson('/api/v1/ai/chat', ['prompt' => "test {$attempt}"])->assertOk(); + } + + $this->postJson('/api/v1/ai/chat', ['prompt' => 'limited'])->assertTooManyRequests(); + } +} diff --git a/backend/tests/Feature/Analytics/CourseAnalyticsTest.php b/backend/tests/Feature/Analytics/CourseAnalyticsTest.php new file mode 100644 index 0000000..df311dc --- /dev/null +++ b/backend/tests/Feature/Analytics/CourseAnalyticsTest.php @@ -0,0 +1,112 @@ +seed(DatabaseSeeder::class); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + $version = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'published')->firstOrFail(); + $assignmentIds = DB::table('assignments')->where('organization_id', $designer->organization_id)->where('assignable_type', 'course')->where('assignable_id', $version->getKey())->pluck('id'); + DB::table('learning_events')->where('course_version_id', $version->getKey())->delete(); + DB::table('assessment_attempts')->where('course_version_id', $version->getKey())->delete(); + DB::table('assignment_users')->whereIn('assignment_id', $assignmentIds)->update(['status' => 'assigned', 'progress' => 0, 'completed_at' => null]); + $learnerId = DB::table('assignment_users')->whereIn('assignment_id', $assignmentIds)->value('user_id'); + $this->assertNotNull($learnerId); + DB::table('assignment_users')->whereIn('assignment_id', $assignmentIds)->where('user_id', $learnerId)->update(['status' => 'completed', 'progress' => 100, 'completed_at' => now()]); + foreach (range(1, 2) as $index) { + LearningEvent::query()->create(['organization_id' => $designer->organization_id, 'learner_id' => $learnerId, 'client_event_id' => fake()->uuid(), 'event_type' => 'course.opened', 'schema_version' => 1, 'course_version_id' => $version->getKey(), 'payload' => ['sequence' => $index], 'occurred_at' => now(), 'received_at' => now()]); + } + Sanctum::actingAs($designer); + + $response = $this->getJson('/api/v1/courses/'.$version->course_id.'/analytics?version='.$version->getKey().'&from='.now()->subDay()->toDateString().'&to='.now()->toDateString()) + ->assertOk() + ->assertJsonPath('data.metrics.starts.value', 1) + ->assertJsonPath('data.metrics.completions.value', 1) + ->assertJsonPath('data.metrics.dropOff.value', 0) + ->assertJsonPath('data.metrics.averageScore.value', null) + ->assertJsonPath('data.hasEvents', true) + ->assertJsonPath('data.definitions.starts', 'تعداد یادگیرندگان تخصیص‌یافته‌ای که تا پایان بازه حداقل یک رویداد یادگیری معتبر در این نسخه ثبت کرده‌اند.'); + $this->assertGreaterThanOrEqual(1, $response->json('data.metrics.learners.value')); + } + + public function test_course_analytics_applies_version_team_and_date_filters_and_exports_same_definitions(): void + { + $this->seed(DatabaseSeeder::class); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + $version = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'published')->firstOrFail(); + $team = DB::table('teams')->where('organization_id', $designer->organization_id)->first(); + Sanctum::actingAs($designer); + $query = '?version='.$version->getKey().'&team='.$team->id.'&from='.now()->subDays(30)->toDateString().'&to='.now()->toDateString().'&sort=completion&direction=asc&page=1&pageSize=5'; + + $this->getJson('/api/v1/courses/'.$version->course_id.'/analytics'.$query) + ->assertOk() + ->assertJsonPath('data.course.versionId', $version->getKey()) + ->assertJsonPath('data.content.meta.pageSize', 5) + ->assertJsonStructure(['data' => ['metrics' => ['learners', 'starts', 'completions', 'averageProgress', 'averageScore', 'dropOff'], 'trend', 'funnel', 'content' => ['items', 'meta'], 'teams', 'insights', 'filters' => ['versions', 'teams']]]); + $this->get('/api/v1/courses/'.$version->course_id.'/analytics/report'.$query) + ->assertOk() + ->assertHeader('content-type', 'text/csv; charset=UTF-8'); + } + + public function test_course_analytics_reports_no_events_without_turning_unknown_metrics_into_zero(): void + { + $this->seed(DatabaseSeeder::class); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + $version = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'published')->firstOrFail(); + DB::table('learning_events')->where('course_version_id', $version->getKey())->delete(); + DB::table('assessment_attempts')->where('course_version_id', $version->getKey())->delete(); + Sanctum::actingAs($designer); + + $this->getJson('/api/v1/courses/'.$version->course_id.'/analytics?version='.$version->getKey()) + ->assertOk() + ->assertJsonPath('data.hasEvents', false) + ->assertJsonPath('data.metrics.starts.value', 0) + ->assertJsonPath('data.metrics.averageScore.value', null) + ->assertJsonPath('data.metrics.averageScore.comparisonAvailable', false); + } + + public function test_course_analytics_can_report_the_draft_version_open_in_the_workspace(): void + { + $this->seed(DatabaseSeeder::class); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + $draft = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'draft')->firstOrFail(); + Sanctum::actingAs($designer); + + $response = $this->getJson('/api/v1/courses/'.$draft->course_id.'/analytics?version='.$draft->getKey()) + ->assertOk() + ->assertJsonPath('data.course.versionId', $draft->getKey()); + + $this->assertContains($draft->getKey(), collect($response->json('data.filters.versions'))->pluck('id')->all()); + } + + public function test_course_analytics_is_permission_and_tenant_scoped(): void + { + $this->seed(DatabaseSeeder::class); + $version = CourseVersion::query()->where('status', 'published')->firstOrFail(); + foreach (['manager@microlearn.test', 'maryam@microlearn.test'] as $email) { + Sanctum::actingAs(User::query()->where('email', $email)->firstOrFail()); + $this->getJson('/api/v1/courses/'.$version->course_id.'/analytics')->assertForbidden(); + } + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + Sanctum::actingAs($designer); + $other = Organization::query()->create(['name' => 'Other analytics', 'slug' => 'other-analytics', 'status' => 'active', 'default_locale' => 'fa', 'timezone' => 'Asia/Tehran']); + $foreignTeam = (string) str()->ulid(); + DB::table('teams')->insert(['id' => $foreignTeam, 'organization_id' => $other->getKey(), 'name' => 'Foreign', 'description' => null, 'created_at' => now(), 'updated_at' => now()]); + $this->getJson('/api/v1/courses/'.$version->course_id.'/analytics?team='.$foreignTeam)->assertNotFound(); + } +} diff --git a/backend/tests/Feature/Api/AuthTest.php b/backend/tests/Feature/Api/AuthTest.php new file mode 100644 index 0000000..7e59759 --- /dev/null +++ b/backend/tests/Feature/Api/AuthTest.php @@ -0,0 +1,106 @@ +create([ + 'email' => 'designer@example.test', + 'password' => 'correct-password', + 'role' => UserRole::CourseDesigner, + ]); + + $login = $this->postJson('/api/v1/auth/login', [ + 'email' => 'designer@example.test', + 'password' => 'correct-password', + 'device_name' => 'test-suite', + ]); + + $login->assertOk() + ->assertJsonPath('data.user.id', $user->getKey()) + ->assertJsonPath('data.user.role', UserRole::CourseDesigner->value) + ->assertJsonPath('data.user.locale', 'fa') + ->assertJsonPath('data.user.deployment.mode', 'saas') + ->assertJsonFragment(['courses.author']) + ->assertJsonStructure(['data' => ['token', 'user']]); + + $this->withToken($login->json('data.token')) + ->getJson('/api/v1/auth/me') + ->assertOk() + ->assertJsonPath('data.organization.id', $user->organization_id); + } + + public function test_every_seeded_workspace_role_can_login_with_the_review_credentials(): void + { + $this->seed(); + + $accounts = [ + 'admin@microlearn.test' => UserRole::SuperAdmin, + 'designer@microlearn.test' => UserRole::CourseDesigner, + 'manager@microlearn.test' => UserRole::Manager, + 'maryam@microlearn.test' => UserRole::Learner, + ]; + + foreach ($accounts as $email => $role) { + $this->flushHeaders(); + $login = $this->postJson('/api/v1/auth/login', [ + 'email' => $email, + 'password' => 'password', + 'device_name' => 'four-role-login-test', + ]); + + $login->assertOk() + ->assertJsonPath('data.user.email', $email) + ->assertJsonPath('data.user.role', $role->value) + ->assertJsonStructure(['data' => ['token', 'user' => ['permissions', 'deployment']]]); + + $this->app['auth']->forgetGuards(); + $this->withToken($login->json('data.token')) + ->getJson('/api/v1/auth/me') + ->assertOk() + ->assertJsonPath('data.role', $role->value); + } + $this->flushHeaders(); + } + + public function test_disabled_user_cannot_login(): void + { + User::factory()->create([ + 'email' => 'disabled@example.test', + 'password' => 'correct-password', + 'status' => AccountStatus::Disabled, + ]); + + $this->postJson('/api/v1/auth/login', [ + 'email' => 'disabled@example.test', + 'password' => 'correct-password', + ])->assertForbidden()->assertJsonPath('error.code', 'account_disabled'); + } + + public function test_invalid_credentials_return_validation_error(): void + { + $this->postJson('/api/v1/auth/login', [ + 'email' => 'missing@example.test', + 'password' => 'incorrect', + ])->assertUnprocessable()->assertJsonValidationErrors('email'); + } + + public function test_user_from_disabled_organization_cannot_login(): void + { + $user = User::factory()->create(['email' => 'inactive-org@example.test', 'password' => 'correct-password']); + $user->organization->update(['status' => 'disabled']); + + $this->postJson('/api/v1/auth/login', ['email' => 'inactive-org@example.test', 'password' => 'correct-password']) + ->assertForbidden()->assertJsonPath('error.code', 'organization_unavailable'); + } +} diff --git a/backend/tests/Feature/Api/HealthTest.php b/backend/tests/Feature/Api/HealthTest.php new file mode 100644 index 0000000..81e2576 --- /dev/null +++ b/backend/tests/Feature/Api/HealthTest.php @@ -0,0 +1,16 @@ +getJson('/api/v1/health') + ->assertOk() + ->assertJsonPath('data.status', 'ok') + ->assertJsonPath('data.deploymentMode', 'saas'); + } +} diff --git a/backend/tests/Feature/Assessments/AssessmentAuthoringTest.php b/backend/tests/Feature/Assessments/AssessmentAuthoringTest.php new file mode 100644 index 0000000..ee367c7 --- /dev/null +++ b/backend/tests/Feature/Assessments/AssessmentAuthoringTest.php @@ -0,0 +1,118 @@ +foundation(); + Sanctum::actingAs($designer); + $bankId = $this->postJson('/api/v1/question-bank', [ + 'type' => 'single_choice', 'prompt' => 'کدام گزینه الزامی است؟', 'topic' => 'ایمنی', + 'difficulty' => 'intermediate', 'tags' => ['PPE'], 'explanation' => 'کلاه ایمنی الزامی است.', + 'configuration' => ['options' => [ + ['id' => 'a', 'text' => 'کلاه ایمنی', 'correct' => true, 'feedback' => 'درست'], + ['id' => 'b', 'text' => 'عینک آفتابی', 'correct' => false, 'feedback' => 'نادرست'], + ]], + ])->assertCreated()->assertJsonPath('data.usageCount', 0)->json('data.id'); + $assessmentId = $this->postJson('/api/v1/assessments', [ + 'courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey(), 'title' => 'آزمون ایمنی', + 'settings' => $this->settings(), + ])->assertCreated()->assertJsonPath('data.settings.passingScore', 80)->json('data.id'); + $this->postJson("/api/v1/assessments/{$assessmentId}/questions", ['sourceQuestionId' => $bankId]) + ->assertCreated()->assertJsonPath('data.sourceQuestionId', $bankId)->assertJsonPath('data.position', 1); + + $this->getJson('/api/v1/question-bank?topic=ایمنی')->assertOk() + ->assertJsonPath('data.0.id', $bankId)->assertJsonPath('data.0.usageCount', 1); + $this->getJson("/api/v1/assessments/{$assessmentId}")->assertOk() + ->assertJsonPath('data.questionCount', 1)->assertJsonPath('data.questions.0.prompt', 'کدام گزینه الزامی است؟'); + } + + public function test_question_schema_rejects_invalid_answers_and_broken_scenario_graph(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + $this->postJson('/api/v1/question-bank', [ + 'type' => 'single_choice', 'prompt' => 'Invalid', + 'configuration' => ['options' => [ + ['id' => 'a', 'text' => 'A', 'correct' => true], ['id' => 'b', 'text' => 'B', 'correct' => true], + ]], + ])->assertUnprocessable()->assertJsonValidationErrors('configuration.options'); + $this->postJson('/api/v1/question-bank', [ + 'type' => 'branching_scenario', 'prompt' => 'Broken graph', + 'configuration' => [ + 'startNodeId' => 'start', + 'nodes' => [ + ['id' => 'start', 'type' => 'scene', 'title' => 'شروع', 'choices' => [['text' => 'ادامه', 'targetNodeId' => 'missing']]], + ['id' => 'result', 'type' => 'result', 'title' => 'پایان', 'choices' => []], + ], + ], + ])->assertUnprocessable()->assertJsonValidationErrors('configuration.nodes'); + } + + public function test_designer_creates_two_level_question_categories_and_filters_bank(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + $categoryId = $this->postJson('/api/v1/question-categories', ['name' => 'مهارت'])->assertCreated()->json('data.id'); + $subcategoryId = $this->postJson('/api/v1/question-categories', ['name' => 'اکسل', 'parentId' => $categoryId])->assertCreated()->assertJsonPath('data.parentId', $categoryId)->json('data.id'); + $questionId = $this->postJson('/api/v1/question-bank', [ + 'type' => 'true_false', 'prompt' => 'تابع SUM برای جمع است.', 'categoryId' => $categoryId, 'subcategoryId' => $subcategoryId, + 'configuration' => ['answer' => true, 'feedback' => 'درست'], + ])->assertCreated()->assertJsonPath('data.categoryName', 'مهارت')->assertJsonPath('data.subcategoryName', 'اکسل')->json('data.id'); + + $this->getJson('/api/v1/question-categories')->assertOk()->assertJsonPath('data.0.children.0.name', 'اکسل'); + $this->getJson('/api/v1/question-bank?categoryId='.$categoryId)->assertOk()->assertJsonPath('data.0.id', $questionId); + $this->getJson('/api/v1/question-bank?subcategoryId='.$subcategoryId)->assertOk()->assertJsonPath('data.0.id', $questionId); + $this->deleteJson('/api/v1/question-categories/'.$subcategoryId)->assertStatus(409); + } + + public function test_published_assessments_are_immutable_and_tenant_scoped(): void + { + [$designer, $version, $lesson] = $this->foundation(CourseVersionStatus::Published); + $assessment = Assessment::query()->create([ + 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), + 'lesson_id' => $lesson->getKey(), 'title' => 'Published', 'settings' => $this->settings(), + ]); + Sanctum::actingAs($designer); + $this->patchJson('/api/v1/assessments/'.$assessment->getKey(), ['title' => 'Changed']) + ->assertUnprocessable()->assertJsonValidationErrors('courseVersionId'); + + $foreignDesigner = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($foreignDesigner); + $this->getJson('/api/v1/assessments/'.$assessment->getKey())->assertNotFound(); + } + + /** @return array{User, CourseVersion, Lesson} */ + private function foundation(CourseVersionStatus $status = CourseVersionStatus::Draft): array + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $course = Course::query()->create(['organization_id' => $designer->organization_id, 'title' => 'Safety', 'slug' => 'safety', 'created_by' => $designer->getKey()]); + $version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => $status, 'title' => 'Safety']); + $module = CourseModule::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'Module', 'position' => 1]); + $lesson = Lesson::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'Lesson', 'position' => 1]); + + return [$designer, $version, $lesson]; + } + + /** @return array */ + private function settings(): array + { + return ['randomSelection' => false, 'questionPoolSize' => null, 'shuffleQuestions' => true, 'shuffleOptions' => true, 'passingScore' => 80, 'attemptLimit' => 2, 'feedbackMode' => 'after_submission', 'timeLimitSeconds' => 600]; + } +} diff --git a/backend/tests/Feature/Assets/AssetApiTest.php b/backend/tests/Feature/Assets/AssetApiTest.php new file mode 100644 index 0000000..bd45724 --- /dev/null +++ b/backend/tests/Feature/Assets/AssetApiTest.php @@ -0,0 +1,109 @@ +create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + + $this->post('/api/v1/assets', ['file' => UploadedFile::fake()->create('training.mp4', 128 * 1024, 'video/mp4')], ['Accept' => 'application/json']) + ->assertCreated() + ->assertJsonPath('data.kind', 'video') + ->assertJsonPath('data.size', 128 * 1024 * 1024); + } + + public function test_designer_uploads_private_asset_lists_it_and_reads_signed_content(): void + { + Storage::fake('local'); + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + + $response = $this->post('/api/v1/assets', ['file' => UploadedFile::fake()->image('safety.png', 800, 600), 'altText' => 'Safety worker'], ['Accept' => 'application/json']) + ->assertCreated()->assertJsonPath('data.kind', 'image')->assertJsonPath('data.usageCount', 0); + $assetId = $response->json('data.id'); + $this->assertStringStartsWith('/api/v1/assets/', $response->json('data.contentUrl')); + $this->assertDatabaseHas('assets', ['id' => $assetId, 'organization_id' => $designer->organization_id, 'original_name' => 'safety.png']); + $this->getJson('/api/v1/assets?kind=image&search=safety')->assertOk()->assertJsonPath('data.0.id', $assetId); + $this->getJson("/api/v1/assets/{$assetId}") + ->assertOk() + ->assertJsonPath('data.id', $assetId) + ->assertJsonPath('data.name', 'safety.png'); + $this->get($response->json('data.contentUrl'))->assertOk()->assertHeader('content-type', 'image/png'); + } + + public function test_duplicate_upload_is_deduplicated_and_used_draft_asset_can_be_detached_and_deleted(): void + { + Storage::fake('local'); + [$designer, $course, $version, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + $file = UploadedFile::fake()->createWithContent('manual.pdf', '%PDF-1.4 same content'); + $assetId = $this->post('/api/v1/assets', ['file' => $file], ['Accept' => 'application/json'])->assertCreated()->json('data.id'); + $this->post('/api/v1/assets', ['file' => UploadedFile::fake()->createWithContent('copy.pdf', '%PDF-1.4 same content')], ['Accept' => 'application/json'])->assertOk()->assertJsonPath('data.id', $assetId); + $this->assertDatabaseCount('assets', 1); + + $blockId = $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/lessons/{$lesson->getKey()}/blocks", [ + 'type' => 'document', 'schemaVersion' => 1, 'data' => ['assetId' => $assetId, 'title' => 'Manual', 'description' => ''], + ])->assertCreated()->json('data.id'); + $this->getJson('/api/v1/assets')->assertOk()->assertJsonPath('data.0.usageCount', 1); + $this->deleteJson("/api/v1/assets/{$assetId}")->assertUnprocessable()->assertJsonValidationErrors('asset'); + $this->deleteJson("/api/v1/assets/{$assetId}?detach=1")->assertNoContent(); + $this->assertDatabaseMissing('assets', ['id' => $assetId]); + $this->assertEquals( + ['title' => 'Manual', 'assetId' => null, 'description' => null], + Block::query()->findOrFail($blockId)->data, + ); + } + + public function test_asset_validation_authorization_and_tenant_references_are_enforced(): void + { + Storage::fake('local'); + [$designer, $course, $version, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + $this->post('/api/v1/assets', ['file' => UploadedFile::fake()->create('payload.exe', 10, 'application/x-msdownload')], ['Accept' => 'application/json'])->assertUnprocessable()->assertJsonValidationErrors('file'); + + $other = User::factory()->create(['role' => UserRole::CourseDesigner]); + $asset = Asset::query()->create(['organization_id' => $other->organization_id, 'uploaded_by' => $other->getKey(), 'kind' => 'image', 'original_name' => 'foreign.png', 'disk' => 'local', 'path' => 'foreign.png', 'mime_type' => 'image/png', 'size' => 10, 'sha256' => str_repeat('a', 64)]); + $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/lessons/{$lesson->getKey()}/blocks", [ + 'type' => 'image', 'schemaVersion' => 1, 'data' => ['assetId' => $asset->getKey(), 'url' => null, 'alt' => 'Foreign', 'caption' => '', 'decorative' => false], + ])->assertUnprocessable()->assertJsonValidationErrors('data.assetId'); + $this->deleteJson("/api/v1/assets/{$asset->getKey()}")->assertNotFound(); + $this->getJson("/api/v1/assets/{$asset->getKey()}")->assertNotFound(); + + $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); + Sanctum::actingAs($manager); + $this->getJson('/api/v1/assets')->assertForbidden(); + } + + /** @return array{User, Course, CourseVersion, Lesson} */ + private function foundation(): array + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $course = Course::query()->create(['organization_id' => $designer->organization_id, 'title' => 'Assets', 'slug' => 'assets', 'created_by' => $designer->getKey()]); + $version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Draft, 'title' => 'Assets']); + $module = CourseModule::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'Module', 'position' => 1]); + $lesson = Lesson::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'Lesson', 'position' => 1]); + + return [$designer, $course, $version, $lesson]; + } +} diff --git a/backend/tests/Feature/Capability/CapabilityScoreEngineTest.php b/backend/tests/Feature/Capability/CapabilityScoreEngineTest.php new file mode 100644 index 0000000..5a00235 --- /dev/null +++ b/backend/tests/Feature/Capability/CapabilityScoreEngineTest.php @@ -0,0 +1,125 @@ +foundation(); + $this->evidence($learner, $version, $node, $mapping, EvidenceType::Exposure, 0.92, 0.10); + + $score = app(CapabilityScoreEngine::class)->recalculate($learner->organization_id, $learner->getKey(), $node->getKey()); + + $this->assertNull($score->score); + $this->assertSame(ConfidenceLevel::Insufficient, $score->confidence_level); + $this->assertLessThanOrEqual(0.30, (float) $score->confidence); + } + + public function test_score_and_confidence_are_separate_and_explainable(): void + { + [$learner, $version, $node, $mapping] = $this->foundation(); + $evidence = $this->evidence($learner, $version, $node, $mapping, EvidenceType::Assessment, 0.82, 0.80); + + $score = app(CapabilityScoreEngine::class)->recalculate($learner->organization_id, $learner->getKey(), $node->getKey()); + + $this->assertSame(82.0, (float) $score->score); + $this->assertNotSame(ConfidenceLevel::High, $score->confidence_level); + $this->assertSame('v1', $score->scoring_model_version); + $this->assertContains($evidence->getKey(), $score->explanation['evidenceRecordIds']); + $this->assertArrayHasKey('effectiveEvidence', $score->explanation); + } + + public function test_recalculation_is_idempotent_and_does_not_duplicate_snapshot(): void + { + [$learner, $version, $node, $mapping] = $this->foundation(); + $this->evidence($learner, $version, $node, $mapping, EvidenceType::Assessment, 0.75, 0.80); + $engine = app(CapabilityScoreEngine::class); + + $engine->recalculate($learner->organization_id, $learner->getKey(), $node->getKey()); + $engine->recalculate($learner->organization_id, $learner->getKey(), $node->getKey()); + + $this->assertDatabaseCount('capability_scores', 1); + $this->assertDatabaseCount('capability_snapshots', 1); + } + + private function foundation(): array + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $learner = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); + $course = Course::query()->create([ + 'organization_id' => $designer->organization_id, 'title' => 'Leadership', + 'slug' => 'leadership', 'created_by' => $designer->getKey(), + ]); + $version = CourseVersion::query()->create([ + 'organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), + 'version_number' => 1, 'status' => CourseVersionStatus::Draft, 'title' => 'Leadership', + ]); + $type = TaxonomyType::query()->create([ + 'organization_id' => $designer->organization_id, 'key' => 'skill', 'name' => 'Skill', + 'status' => TaxonomyStatus::Active, + ]); + $node = TaxonomyNode::query()->create([ + 'organization_id' => $designer->organization_id, 'taxonomy_type_id' => $type->getKey(), + 'name' => 'Giving feedback', 'status' => TaxonomyStatus::Active, + ]); + $mapping = ContentTaxonomyMapping::query()->create([ + 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), + 'mappable_type' => MappableType::Course, 'mappable_id' => $version->getKey(), + 'taxonomy_node_id' => $node->getKey(), 'mapping_type' => MappingType::Assesses, + 'weight' => 1, 'source' => MappingSource::Manual, + 'confirmation_status' => MappingConfirmationStatus::Confirmed, 'confirmed_by' => $designer->getKey(), + ]); + + return [$learner, $version, $node, $mapping]; + } + + private function evidence( + User $learner, + CourseVersion $version, + TaxonomyNode $node, + ContentTaxonomyMapping $mapping, + EvidenceType $type, + float $value, + float $strength, + ): EvidenceRecord { + return EvidenceRecord::query()->create([ + 'organization_id' => $learner->organization_id, + 'learner_id' => $learner->getKey(), + 'taxonomy_node_id' => $node->getKey(), + 'source_type' => 'assessment_result', + 'source_id' => (string) Str::ulid(), + 'evidence_type' => $type, + 'raw_value' => ['score' => $value], + 'normalized_value' => $value, + 'strength' => $strength, + 'mapping_weight' => 1, + 'occurred_at' => now(), + 'course_version_id' => $version->getKey(), + 'content_mapping_id' => $mapping->getKey(), + ]); + } +} diff --git a/backend/tests/Feature/Collaboration/CollaborationTest.php b/backend/tests/Feature/Collaboration/CollaborationTest.php new file mode 100644 index 0000000..44b4e1d --- /dev/null +++ b/backend/tests/Feature/Collaboration/CollaborationTest.php @@ -0,0 +1,103 @@ +seed(DatabaseSeeder::class); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + $peer = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::CourseDesigner, 'status' => AccountStatus::Active]); + $block = Block::query()->where('organization_id', $designer->organization_id)->firstOrFail(); + DB::table('course_versions')->where('id', $block->course_version_id)->update(['status' => 'draft']); + $version = CourseVersion::query()->findOrFail($block->course_version_id); + $session = fake()->uuid(); + + Sanctum::actingAs($designer); + $this->getJson('/api/v1/course-versions/'.$version->getKey().'/collaboration?clientSessionId='.$session.'&lessonId='.$block->lesson_id) + ->assertOk()->assertJsonPath('data.presence.0.id', $designer->getKey())->assertJsonPath('data.transport.mode', 'change-feed'); + $lock = $this->postJson('/api/v1/blocks/'.$block->getKey().'/soft-lock', ['clientSessionId' => $session])->assertOk()->json('data'); + + Sanctum::actingAs($peer); + $this->postJson('/api/v1/blocks/'.$block->getKey().'/soft-lock', ['clientSessionId' => fake()->uuid()])->assertUnprocessable()->assertJsonValidationErrors('lock'); + $this->getJson('/api/v1/notifications')->assertOk()->assertJsonPath('data.items.0.type', 'collaboration.lock_conflict'); + $this->patchJson('/api/v1/blocks/'.$block->getKey(), ['expectedRevision' => $block->revision, 'data' => $block->data])->assertUnprocessable()->assertJsonValidationErrors('lock'); + + DB::table('block_soft_locks')->where('block_id', $block->getKey())->update(['expires_at' => now()->subSecond()]); + $this->postJson('/api/v1/blocks/'.$block->getKey().'/soft-lock', ['clientSessionId' => fake()->uuid()])->assertOk(); + + Sanctum::actingAs($designer); + $this->deleteJson('/api/v1/blocks/'.$block->getKey().'/soft-lock', ['lockToken' => $lock['token']])->assertOk()->assertJsonPath('data.released', true); + } + + public function test_review_threads_mentions_replies_reactions_resolution_and_notifications_work(): void + { + $this->seed(DatabaseSeeder::class); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + $peer = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::CourseDesigner, 'status' => AccountStatus::Active, 'name' => 'Peer Designer']); + $block = Block::query()->where('organization_id', $designer->organization_id)->firstOrFail(); + DB::table('course_versions')->where('id', $block->course_version_id)->update(['status' => 'draft']); + $version = CourseVersion::query()->findOrFail($block->course_version_id); + $mutationId = fake()->uuid(); + + Sanctum::actingAs($designer); + $payload = ['body' => 'Please review @Peer', 'lessonId' => $block->lesson_id, 'blockId' => $block->getKey(), 'mentionUserIds' => [$peer->getKey()], 'clientMutationId' => $mutationId]; + $thread = $this->postJson('/api/v1/course-versions/'.$version->getKey().'/review-threads', $payload) + ->assertCreated()->assertJsonPath('data.status', 'open')->json('data'); + $this->postJson('/api/v1/course-versions/'.$version->getKey().'/review-threads', $payload)->assertOk()->assertJsonPath('data.id', $thread['id']); + $this->assertDatabaseCount('review_threads', 1); + + Sanctum::actingAs($peer); + $this->getJson('/api/v1/notifications')->assertOk()->assertJsonPath('data.unread', 1)->assertJsonPath('data.items.0.type', 'review.mentioned'); + $asset = Asset::query()->create(['organization_id' => $designer->organization_id, 'uploaded_by' => $peer->getKey(), 'kind' => 'document', 'original_name' => 'review.pdf', 'disk' => 'local', 'path' => 'tests/review.pdf', 'mime_type' => 'application/pdf', 'size' => 1200, 'sha256' => hash('sha256', fake()->uuid()), 'alt_text' => null, 'metadata' => null]); + $reply = $this->postJson('/api/v1/review-threads/'.$thread['id'].'/replies', ['body' => 'Reviewed and replied.', 'attachmentAssetIds' => [$asset->getKey()]])->assertCreated()->assertJsonPath('data.attachments.0.name', 'review.pdf')->json('data'); + $this->postJson('/api/v1/review-threads/'.$thread['id'].'/reactions', ['reaction' => 'helpful'])->assertOk()->assertJsonPath('data.active', true); + $this->patchJson('/api/v1/review-threads/'.$thread['id'].'/resolution', ['resolved' => true])->assertOk()->assertJsonPath('data.status', 'resolved'); + $this->patchJson('/api/v1/review-messages/reply/'.$reply['id'], ['body' => 'Edited review.'])->assertOk()->assertJsonPath('data.body', 'Edited review.'); + $this->patchJson('/api/v1/review-threads/'.$thread['id'].'/assignees', ['userIds' => [$designer->getKey()]])->assertOk()->assertJsonPath('data.assignees.0.name', $designer->name); + $this->getJson('/api/v1/review-center?status=mine&sort=activity')->assertOk() + ->assertJsonPath('data.summary.resolved', 1) + ->assertJsonPath('data.items.0.replies.0.authorName', 'Peer Designer') + ->assertJsonPath('data.items.0.messages.1.body', 'Edited review.') + ->assertJsonPath('data.items.0.reactions.helpful', 1) + ->assertJsonPath('data.items.0.assignees.0.id', $designer->getKey()); + $this->postJson('/api/v1/review-threads/'.$thread['id'].'/read')->assertOk()->assertJsonPath('data.read', true); + + Sanctum::actingAs($designer); + $this->patchJson('/api/v1/review-messages/reply/'.$reply['id'], ['body' => 'Unauthorized edit'])->assertForbidden(); + } + + public function test_collaboration_rejects_other_roles_and_cross_tenant_targets(): void + { + $this->seed(DatabaseSeeder::class); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + $manager = User::query()->where('email', 'manager@microlearn.test')->firstOrFail(); + $version = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'draft')->firstOrFail(); + $other = User::factory()->create(['organization_id' => null, 'role' => UserRole::SuperAdmin, 'status' => AccountStatus::Active]); + + Sanctum::actingAs($designer); + $thread = $this->postJson('/api/v1/course-versions/'.$version->getKey().'/review-threads', ['body' => 'Permission check'])->assertCreated()->json('data'); + $this->postJson('/api/v1/course-versions/'.$version->getKey().'/review-threads', ['body' => 'Invalid mention', 'mentionUserIds' => [$other->getKey()]])->assertUnprocessable()->assertJsonValidationErrors('mentionUserIds'); + + Sanctum::actingAs($manager); + $this->getJson('/api/v1/course-versions/'.$version->getKey().'/collaboration?clientSessionId='.fake()->uuid())->assertForbidden(); + $this->getJson('/api/v1/review-center')->assertOk()->assertJsonPath('data.items.0.id', $thread['id']); + $this->postJson('/api/v1/review-threads/'.$thread['id'].'/replies', ['body' => 'Manager review'])->assertCreated(); + $this->patchJson('/api/v1/review-threads/'.$thread['id'].'/resolution', ['resolved' => true])->assertOk()->assertJsonPath('data.status', 'resolved'); + } +} diff --git a/backend/tests/Feature/Courses/CourseBuilderTest.php b/backend/tests/Feature/Courses/CourseBuilderTest.php new file mode 100644 index 0000000..22f54d3 --- /dev/null +++ b/backend/tests/Feature/Courses/CourseBuilderTest.php @@ -0,0 +1,220 @@ +foundation(); + Sanctum::actingAs($designer); + + $this->getJson('/api/v1/block-registry')->assertOk()->assertJsonFragment(['type' => 'single_choice']); + + $this->postJson($this->blocksUrl($course, $version, $lesson), [ + 'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => 'Welcome', 'level' => 2], + ])->assertCreated()->assertJsonPath('data.revision', 1)->assertJsonPath('data.position', 1); + } + + public function test_registry_rejects_unknown_type_and_invalid_payload(): void + { + [$designer, $course, $version, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + + $this->postJson($this->blocksUrl($course, $version, $lesson), [ + 'type' => 'unknown', 'schemaVersion' => 1, 'data' => ['value' => true], + ])->assertUnprocessable()->assertJsonValidationErrors('type'); + + $this->postJson($this->blocksUrl($course, $version, $lesson), [ + 'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => '', 'level' => 9], + ])->assertUnprocessable()->assertJsonValidationErrors(['text', 'level']); + + $this->postJson($this->blocksUrl($course, $version, $lesson), [ + 'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => 'Valid', 'level' => 2, 'script' => 'unknown'], + ])->assertUnprocessable()->assertJsonValidationErrors('data.script'); + } + + public function test_registry_exposes_complete_phase_six_contract_and_defaults_validate(): void + { + [$designer, $course, $version, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + $registry = $this->getJson('/api/v1/block-registry')->assertOk()->json('data'); + $required = ['heading', 'text', 'quote', 'key_point', 'divider', 'button', 'image', 'gallery', 'video', 'audio', 'document', 'embed', 'flashcard', 'accordion', 'tabs', 'timeline', 'steps', 'process', 'checklist', 'section', 'columns', 'controlled_grid']; + $this->assertEqualsCanonicalizing($required, collect($registry)->whereNotIn('category', ['assessment'])->pluck('type')->all()); + + foreach ($registry as $definition) { + $this->assertArrayHasKey('webBehavior', $definition); + $this->assertArrayHasKey('exportCompatibility', $definition); + $this->postJson($this->blocksUrl($course, $version, $lesson), [ + 'type' => $definition['type'], 'schemaVersion' => $definition['schemaVersion'], 'data' => $definition['defaultData'], + ])->assertCreated(); + } + } + + public function test_block_contract_tokens_persist_and_lock_prevents_mutation_until_unlock(): void + { + [$designer, $course, $version, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + $block = $this->postJson($this->blocksUrl($course, $version, $lesson), [ + 'type' => 'quote', 'schemaVersion' => 1, 'data' => ['text' => 'Safe work', 'cite' => 'Team'], + 'style' => ['alignment' => 'center', 'width' => 'narrow', 'spacing' => 'l', 'background' => 'accent', 'border' => 'subtle', 'radius' => 'l', 'fontSize' => 18, 'fontWeight' => 600, 'textColor' => '#1f2937', 'lineHeight' => 1.8, 'maxWidth' => 760, 'aspectRatio' => '16/9', 'objectFit' => 'cover'], + 'behavior' => ['hidden' => false, 'completion' => 'view', 'animation' => 'fade', 'locked' => true], + 'responsive' => ['mobileStack' => true, 'mobileOrder' => 'logical'], + 'accessibility' => ['label' => 'Safety quote', 'decorative' => false], + ])->assertCreated()->assertJsonPath('data.style.width', 'narrow')->assertJsonPath('data.behavior.locked', true)->json('data'); + + $this->patchJson("/api/v1/blocks/{$block['id']}", ['expectedRevision' => 1, 'data' => ['text' => 'Changed', 'cite' => 'Team']])->assertUnprocessable()->assertJsonValidationErrors('locked'); + $this->putJson("/api/v1/lessons/{$lesson->getKey()}/blocks/order", ['blockIds' => [$block['id']]])->assertUnprocessable()->assertJsonValidationErrors('locked'); + $this->patchJson("/api/v1/blocks/{$block['id']}", [ + 'expectedRevision' => 1, 'data' => $block['data'], 'style' => $block['style'], + 'behavior' => [...$block['behavior'], 'locked' => false], 'responsive' => $block['responsive'], 'accessibility' => $block['accessibility'], + ])->assertOk()->assertJsonPath('data.behavior.locked', false); + } + + public function test_autosave_uses_optimistic_revision_control(): void + { + [$designer, $course, $version, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + $blockId = $this->postJson($this->blocksUrl($course, $version, $lesson), [ + 'type' => 'text', 'schemaVersion' => 1, 'data' => ['html' => '

        One

        '], + ])->json('data.id'); + + $this->patchJson("/api/v1/blocks/{$blockId}", [ + 'expectedRevision' => 1, 'data' => ['html' => '

        Two

        '], + ])->assertOk()->assertJsonPath('data.revision', 2); + + $this->patchJson("/api/v1/blocks/{$blockId}", [ + 'expectedRevision' => 1, 'data' => ['html' => '

        Stale

        '], + ])->assertConflict()->assertJsonPath('error.code', 'revision_conflict'); + } + + public function test_rich_text_is_sanitized_before_storage_without_losing_supported_formatting(): void + { + [$designer, $course, $version, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + + $html = '

        Guide

        Use safe steps.

        badgood'; + $response = $this->postJson($this->blocksUrl($course, $version, $lesson), [ + 'type' => 'text', 'schemaVersion' => 1, 'data' => ['html' => $html], + ])->assertCreated(); + + $stored = $response->json('data.data.html'); + $this->assertStringContainsString('

        Guide

        ', $stored); + $this->assertStringContainsString('safe', $stored); + $this->assertStringContainsString('href="https://example.test"', $stored); + $this->assertStringNotContainsString('onclick', $stored); + $this->assertStringNotContainsString('javascript:', $stored); + $this->assertStringNotContainsString('foundation(); + Sanctum::actingAs($designer); + $first = $this->createBlock($course, $version, $lesson, 'First'); + $second = $this->createBlock($course, $version, $lesson, 'Second'); + + $this->putJson("/api/v1/lessons/{$lesson->getKey()}/blocks/order", ['blockIds' => [$second, $first]]) + ->assertOk()->assertJsonPath('data.blockIds.0', $second); + $this->assertSame([$second, $first], Block::query()->orderBy('position')->pluck('id')->all()); + + $this->putJson("/api/v1/lessons/{$lesson->getKey()}/blocks/order", ['blockIds' => [$first]]) + ->assertUnprocessable()->assertJsonValidationErrors('blockIds'); + } + + public function test_blocks_can_be_inserted_and_duplicated_without_position_gaps(): void + { + [$designer, $course, $version, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + $first = $this->createBlock($course, $version, $lesson, 'First'); + $third = $this->createBlock($course, $version, $lesson, 'Third'); + + $second = $this->postJson($this->blocksUrl($course, $version, $lesson), [ + 'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => 'Second', 'level' => 2], 'insertionPosition' => 2, + ])->assertCreated()->assertJsonPath('data.position', 2)->json('data.id'); + + $copy = $this->postJson("/api/v1/blocks/{$second}/duplicate") + ->assertCreated()->assertJsonPath('data.position', 3)->assertJsonPath('data.revision', 1)->json('data.id'); + + $this->assertSame([$first, $second, $copy, $third], Block::query()->where('lesson_id', $lesson->getKey())->orderBy('position')->pluck('id')->all()); + $this->assertSame([1, 2, 3, 4], Block::query()->where('lesson_id', $lesson->getKey())->orderBy('position')->pluck('position')->all()); + } + + public function test_structure_locks_protect_block_mutations_until_unlocked(): void + { + [$designer, $course, $version, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + $block = $this->createBlock($course, $version, $lesson, 'Locked'); + + $this->patchJson("/api/v1/lessons/{$lesson->getKey()}", ['locked' => true])->assertOk()->assertJsonPath('data.locked', true); + $this->patchJson("/api/v1/blocks/{$block}", ['expectedRevision' => 1, 'data' => ['text' => 'No', 'level' => 2]])->assertUnprocessable()->assertJsonValidationErrors('locked'); + $this->postJson("/api/v1/blocks/{$block}/duplicate")->assertUnprocessable()->assertJsonValidationErrors('locked'); + $this->deleteJson("/api/v1/blocks/{$block}")->assertUnprocessable()->assertJsonValidationErrors('locked'); + + $this->patchJson("/api/v1/lessons/{$lesson->getKey()}", ['locked' => false])->assertOk()->assertJsonPath('data.locked', false); + $this->postJson("/api/v1/blocks/{$block}/duplicate")->assertCreated(); + } + + public function test_published_versions_are_readable_but_not_editable(): void + { + [$designer, $course, $version, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + $version->update(['status' => CourseVersionStatus::Published, 'published_at' => now()]); + + $this->getJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/lessons/{$lesson->getKey()}/builder") + ->assertOk()->assertJsonPath('data.version.status', 'published'); + $this->postJson($this->blocksUrl($course, $version, $lesson), [ + 'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => 'No', 'level' => 2], + ])->assertUnprocessable()->assertJsonValidationErrors('version'); + } + + public function test_builder_resources_are_tenant_isolated_and_manager_cannot_author(): void + { + [$designer, $course, $version, $lesson] = $this->foundation(); + $other = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($other); + $this->getJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/lessons/{$lesson->getKey()}/builder")->assertNotFound(); + + $manager = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Manager]); + Sanctum::actingAs($manager); + $this->getJson('/api/v1/block-registry')->assertForbidden(); + } + + private function createBlock(Course $course, CourseVersion $version, Lesson $lesson, string $text): string + { + return $this->postJson($this->blocksUrl($course, $version, $lesson), [ + 'type' => 'heading', 'schemaVersion' => 1, 'data' => ['text' => $text, 'level' => 2], + ])->assertCreated()->json('data.id'); + } + + private function blocksUrl(Course $course, CourseVersion $version, Lesson $lesson): string + { + return "/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/lessons/{$lesson->getKey()}/blocks"; + } + + /** @return array{User, Course, CourseVersion, Lesson} */ + private function foundation(): array + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $course = Course::query()->create(['organization_id' => $designer->organization_id, 'title' => 'Safety', 'slug' => 'safety', 'created_by' => $designer->getKey()]); + $version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Draft, 'title' => 'Safety']); + $module = CourseModule::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'Basics', 'position' => 1]); + $lesson = Lesson::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'PPE', 'position' => 1]); + + return [$designer, $course, $version, $lesson]; + } +} diff --git a/backend/tests/Feature/Courses/CourseManagementTest.php b/backend/tests/Feature/Courses/CourseManagementTest.php new file mode 100644 index 0000000..7ad1a57 --- /dev/null +++ b/backend/tests/Feature/Courses/CourseManagementTest.php @@ -0,0 +1,228 @@ +foundation(); + Sanctum::actingAs($designer); + + $assetId = $this->post('/api/v1/assets', ['file' => UploadedFile::fake()->image('cover.jpg', 800, 450)], ['Accept' => 'application/json']) + ->assertCreated()->json('data.id'); + $this->patchJson('/api/v1/courses/'.$course->getKey(), ['coverAssetId' => $assetId]) + ->assertOk()->assertJsonPath('data.cover.assetId', $assetId); + $this->getJson('/api/v1/courses')->assertOk()->assertJsonPath('data.0.cover.assetId', $assetId); + $this->getJson('/api/v1/courses/'.$course->getKey())->assertOk()->assertJsonPath('data.course.cover.assetId', $assetId); + $this->deleteJson('/api/v1/assets/'.$assetId)->assertUnprocessable()->assertJsonValidationErrors('asset'); + } + + public function test_blank_course_creation_is_atomic_and_list_is_searchable_filterable_and_safe(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + + $courseId = $this->postJson('/api/v1/courses', [ + 'title' => 'ایمنی محیط کار', 'description' => 'دوره پایه', 'language' => 'fa', 'difficulty' => 'beginner', + ])->assertCreated()->json('data.id'); + + $version = CourseVersion::query()->where('course_id', $courseId)->firstOrFail(); + $this->assertSame(1, $version->version_number); + $this->assertSame(CourseVersionStatus::Draft, $version->status); + $this->assertDatabaseCount('course_modules', 1); + $this->assertDatabaseCount('lessons', 1); + + $response = $this->getJson('/api/v1/courses?search=ایمنی&status=draft&sort=title&direction=asc') + ->assertOk()->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.id', $courseId) + ->assertJsonPath('data.0.versionNumber', 1)->assertJsonPath('data.0.moduleCount', 1) + ->assertJsonPath('data.0.lessonCount', 1); + $this->assertArrayNotHasKey('settings', $response->json('data.0')); + } + + public function test_workspace_exposes_ordered_structure_history_and_draft_metadata_updates(): void + { + [$designer, $course, $version, $module, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + + $this->patchJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}", [ + 'title' => 'Safety Updated', 'description' => 'New description', 'language' => 'en', + ])->assertOk()->assertJsonPath('data.title', 'Safety Updated')->assertJsonPath('data.settings.language', 'en'); + $lesson->update(['settings' => ['durationMinutes' => 12, 'description' => 'PPE basics', 'status' => 'ready', 'prerequisites' => ['Introduction']]]); + Block::query()->create([ + 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson->getKey(), + 'type' => 'video', 'schema_version' => 1, 'data' => ['assetId' => 'video-1'], 'position' => 1, 'revision' => 1, + ]); + + $this->getJson('/api/v1/courses/'.$course->getKey()) + ->assertOk()->assertJsonPath('data.course.title', 'Safety Updated') + ->assertJsonPath('data.version.id', $version->getKey()) + ->assertJsonPath('data.modules.0.id', $module->getKey()) + ->assertJsonPath('data.modules.0.lessons.0.id', $lesson->getKey()) + ->assertJsonPath('data.modules.0.lessons.0.contentType', 'video') + ->assertJsonPath('data.modules.0.lessons.0.durationMinutes', 12) + ->assertJsonPath('data.modules.0.lessons.0.description', 'PPE basics') + ->assertJsonPath('data.modules.0.lessons.0.assetCount', 1) + ->assertJsonPath('data.modules.0.lessons.0.prerequisites.0', 'Introduction') + ->assertJsonPath('data.versions.0.number', 1); + } + + public function test_designer_can_bookmark_and_archive_a_course(): void + { + [$designer, $course, $version] = $this->foundation(); + Sanctum::actingAs($designer); + + $this->postJson('/api/v1/courses/'.$course->getKey().'/bookmark')->assertOk()->assertJsonPath('data.bookmarked', true); + $this->getJson('/api/v1/courses')->assertOk()->assertJsonPath('data.0.bookmarked', true)->assertJsonPath('data.0.versionId', $version->getKey()); + $this->postJson('/api/v1/courses/'.$course->getKey().'/bookmark')->assertOk()->assertJsonPath('data.bookmarked', false); + $this->postJson('/api/v1/courses/'.$course->getKey().'/archive')->assertOk()->assertJsonPath('data.status', 'archived'); + $this->getJson('/api/v1/courses')->assertOk()->assertJsonCount(0, 'data'); + $this->getJson('/api/v1/courses?status=archived')->assertOk()->assertJsonPath('data.0.id', $course->getKey()); + } + + public function test_designer_can_manage_and_reorder_modules_and_lessons_with_contiguous_positions(): void + { + [$designer, , $version, $firstModule, $firstLesson] = $this->foundation(); + Sanctum::actingAs($designer); + $secondModule = $this->postJson("/api/v1/course-versions/{$version->getKey()}/modules", ['title' => 'Advanced'])->assertCreated()->json('data.id'); + $secondLesson = $this->postJson("/api/v1/course-modules/{$firstModule->getKey()}/lessons", ['title' => 'Hazards'])->assertCreated()->json('data.id'); + + $this->putJson("/api/v1/course-versions/{$version->getKey()}/modules/order", ['ids' => [$secondModule, $firstModule->getKey()]])->assertOk(); + $this->putJson("/api/v1/course-modules/{$firstModule->getKey()}/lessons/order", ['ids' => [$secondLesson, $firstLesson->getKey()]])->assertOk(); + $this->patchJson('/api/v1/lessons/'.$secondLesson, ['moduleId' => $secondModule, 'title' => 'Moved hazards'])->assertOk()->assertJsonPath('data.position', 1); + + $this->assertSame([1, 2], CourseModule::query()->where('course_version_id', $version->getKey())->orderBy('position')->pluck('position')->all()); + $this->assertSame([1], Lesson::query()->where('course_module_id', $firstModule->getKey())->pluck('position')->all()); + $this->deleteJson('/api/v1/lessons/'.$firstLesson->getKey())->assertNoContent(); + $this->deleteJson('/api/v1/course-modules/'.$firstModule->getKey())->assertNoContent(); + $this->assertSame([1], CourseModule::query()->where('course_version_id', $version->getKey())->pluck('position')->all()); + } + + public function test_published_structure_is_immutable_and_fork_copies_content_once(): void + { + [$designer, $course, $version, $module, $lesson] = $this->foundation(); + Block::query()->create([ + 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson->getKey(), + 'type' => 'heading', 'schema_version' => 1, 'data' => ['text' => 'Hello', 'level' => 2], 'position' => 1, 'revision' => 4, + ]); + $version->update(['status' => CourseVersionStatus::Published, 'published_at' => now()]); + Sanctum::actingAs($designer); + + $this->patchJson('/api/v1/course-modules/'.$module->getKey(), ['title' => 'Forbidden'])->assertUnprocessable()->assertJsonValidationErrors('version'); + $draftId = $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/fork") + ->assertCreated()->assertJsonPath('data.number', 2)->assertJsonPath('data.status', 'draft')->json('data.id'); + $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/fork") + ->assertCreated()->assertJsonPath('data.id', $draftId); + + $this->assertDatabaseCount('course_versions', 2); + $this->assertDatabaseHas('course_versions', ['id' => $draftId, 'source_version_id' => $version->getKey()]); + $this->assertSame(1, CourseModule::query()->where('course_version_id', $draftId)->count()); + $this->assertSame(1, Lesson::query()->where('course_version_id', $draftId)->count()); + $copied = Block::query()->where('course_version_id', $draftId)->firstOrFail(); + $this->assertSame(1, $copied->revision); + $this->assertNotSame($lesson->getKey(), $copied->lesson_id); + } + + public function test_version_history_is_paginated_comparable_and_keeps_published_versions_read_only(): void + { + [$designer, $course, $version, $module] = $this->foundation(); + $version->update(['status' => CourseVersionStatus::Published, 'published_at' => now(), 'published_by' => $designer->getKey()]); + Sanctum::actingAs($designer); + $draftId = $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/fork")->assertCreated()->json('data.id'); + CourseModule::query()->where('course_version_id', $draftId)->where('position', $module->position)->update(['title' => 'Updated basics']); + + $this->getJson("/api/v1/courses/{$course->getKey()}/versions?perPage=1") + ->assertOk()->assertJsonPath('data.summary.total', 2)->assertJsonPath('data.summary.published', 1) + ->assertJsonPath('data.summary.drafts', 1)->assertJsonPath('data.items.0.id', $draftId) + ->assertJsonPath('data.items.0.actor.name', $designer->name)->assertJsonPath('data.meta.lastPage', 2); + $this->getJson("/api/v1/courses/{$course->getKey()}/versions/compare?ids[]={$version->getKey()}&ids[]={$draftId}") + ->assertOk()->assertJsonPath('data.compatible', true)->assertJsonPath('data.categories.0.key', 'structure') + ->assertJsonPath('data.categories.0.changed', true); + $this->patchJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}", ['title' => 'Forbidden'])->assertUnprocessable(); + } + + public function test_module_and_lesson_duplication_copy_nested_draft_content(): void + { + [$designer, , $version, $module, $lesson] = $this->foundation(); + Block::query()->create([ + 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson->getKey(), + 'type' => 'heading', 'schema_version' => 1, 'data' => ['text' => 'Source', 'level' => 2], 'position' => 1, 'revision' => 7, + ]); + Sanctum::actingAs($designer); + + $moduleCopy = $this->postJson("/api/v1/course-modules/{$module->getKey()}/duplicate") + ->assertCreated()->assertJsonPath('data.position', 2)->json('data.id'); + $this->assertSame(1, Lesson::query()->where('course_module_id', $moduleCopy)->count()); + $copiedLessonIds = Lesson::query()->where('course_module_id', $moduleCopy)->pluck('id'); + $this->assertSame(1, Block::query()->whereIn('lesson_id', $copiedLessonIds)->count()); + + $lessonCopy = $this->postJson("/api/v1/lessons/{$lesson->getKey()}/duplicate") + ->assertCreated()->assertJsonPath('data.position', 2)->json('data.id'); + $copiedBlock = Block::query()->where('lesson_id', $lessonCopy)->firstOrFail(); + $this->assertSame(1, $copiedBlock->revision); + $this->assertSame(['text' => 'Source', 'level' => 2], $copiedBlock->data); + } + + public function test_locked_structure_rejects_mutation_and_can_be_explicitly_unlocked(): void + { + [$designer, , $version, $module, $lesson] = $this->foundation(); + Sanctum::actingAs($designer); + + $this->patchJson("/api/v1/course-modules/{$module->getKey()}", ['locked' => true])->assertOk()->assertJsonPath('data.locked', true); + $this->postJson("/api/v1/course-modules/{$module->getKey()}/lessons", ['title' => 'No'])->assertUnprocessable()->assertJsonValidationErrors('locked'); + $this->patchJson("/api/v1/lessons/{$lesson->getKey()}", ['title' => 'No'])->assertUnprocessable()->assertJsonValidationErrors('locked'); + $this->postJson("/api/v1/course-modules/{$module->getKey()}/duplicate")->assertUnprocessable()->assertJsonValidationErrors('locked'); + $this->putJson("/api/v1/course-versions/{$version->getKey()}/modules/order", ['ids' => [$module->getKey()]])->assertUnprocessable()->assertJsonValidationErrors('locked'); + $this->deleteJson("/api/v1/course-modules/{$module->getKey()}")->assertUnprocessable()->assertJsonValidationErrors('locked'); + + $this->patchJson("/api/v1/course-modules/{$module->getKey()}", ['locked' => false])->assertOk()->assertJsonPath('data.locked', false); + $this->patchJson("/api/v1/lessons/{$lesson->getKey()}", ['locked' => true])->assertOk(); + $this->deleteJson("/api/v1/lessons/{$lesson->getKey()}")->assertUnprocessable()->assertJsonValidationErrors('locked'); + } + + public function test_course_resources_are_tenant_scoped_and_manager_cannot_author(): void + { + [$designer, $course, $version, $module, $lesson] = $this->foundation(); + $foreignDesigner = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($foreignDesigner); + $this->getJson('/api/v1/courses/'.$course->getKey())->assertNotFound(); + $this->patchJson('/api/v1/course-modules/'.$module->getKey(), ['title' => 'Foreign'])->assertNotFound(); + $this->patchJson('/api/v1/lessons/'.$lesson->getKey(), ['title' => 'Foreign'])->assertNotFound(); + $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/fork")->assertNotFound(); + + $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); + Sanctum::actingAs($manager); + $this->getJson('/api/v1/courses')->assertForbidden(); + $this->getJson("/api/v1/courses/{$course->getKey()}/versions")->assertForbidden(); + $this->postJson('/api/v1/courses', ['title' => 'No', 'language' => 'fa'])->assertForbidden(); + } + + /** @return array{User, Course, CourseVersion, CourseModule, Lesson} */ + private function foundation(): array + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $course = Course::query()->create(['organization_id' => $designer->organization_id, 'title' => 'Safety', 'slug' => 'safety', 'status' => 'draft', 'created_by' => $designer->getKey()]); + $version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'created_by' => $designer->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Draft, 'title' => 'Safety', 'settings' => ['language' => 'fa']]); + $module = CourseModule::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'Basics', 'position' => 1]); + $lesson = Lesson::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'PPE', 'position' => 1]); + + return [$designer, $course, $version, $module, $lesson]; + } +} diff --git a/backend/tests/Feature/Evidence/EvidenceGenerationTest.php b/backend/tests/Feature/Evidence/EvidenceGenerationTest.php new file mode 100644 index 0000000..316f707 --- /dev/null +++ b/backend/tests/Feature/Evidence/EvidenceGenerationTest.php @@ -0,0 +1,130 @@ +foundation(); + $service = app(EvidenceGenerationService::class); + + $evidence = $service->fromQuestionResult($result); + $service->fromQuestionResult($result); + + $this->assertCount(2, $evidence); + $this->assertDatabaseCount('evidence_records', 2); + $this->assertEqualsCanonicalizing([0.7, 0.3], $evidence->map(fn ($item) => (float) $item->mapping_weight)->all()); + $this->assertSame(0.8, (float) $evidence->first()->strength); + $this->assertSame(0.84, (float) $evidence->first()->normalized_value); + $this->assertEqualsCanonicalizing($nodes, $evidence->pluck('taxonomy_node_id')->all()); + Event::assertDispatchedTimes(AssessmentEvidenceCreated::class, 2); + } + + public function test_unconfirmed_ai_mapping_does_not_generate_evidence(): void + { + [$result] = $this->foundation(MappingConfirmationStatus::Draft); + + $this->assertCount(0, app(EvidenceGenerationService::class)->fromQuestionResult($result)); + $this->assertDatabaseCount('evidence_records', 0); + } + + public function test_evidence_record_is_immutable(): void + { + [$result] = $this->foundation(); + $evidence = app(EvidenceGenerationService::class)->fromQuestionResult($result)->firstOrFail(); + + $this->expectException(DomainException::class); + $evidence->update(['normalized_value' => 0.1]); + } + + private function foundation(MappingConfirmationStatus $confirmation = MappingConfirmationStatus::Confirmed): array + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $learner = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); + $course = Course::query()->create([ + 'organization_id' => $designer->organization_id, 'title' => 'Leadership', + 'slug' => 'leadership', 'created_by' => $designer->getKey(), + ]); + $version = CourseVersion::query()->create([ + 'organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), + 'version_number' => 1, 'status' => CourseVersionStatus::Draft, 'title' => 'Leadership', + ]); + $module = CourseModule::query()->create([ + 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), + 'title' => 'Communication', 'position' => 1, + ]); + $lesson = Lesson::query()->create([ + 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), + 'course_module_id' => $module->getKey(), 'title' => 'Feedback', 'position' => 1, + ]); + $assessment = Assessment::query()->create([ + 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), + 'lesson_id' => $lesson->getKey(), 'title' => 'Feedback check', + ]); + $question = Question::query()->create([ + 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), + 'assessment_id' => $assessment->getKey(), 'type' => 'scenario', 'prompt' => 'Choose a response', + 'configuration' => ['choices' => []], 'position' => 1, + ]); + $type = TaxonomyType::query()->create([ + 'organization_id' => $designer->organization_id, 'key' => 'skill', 'name' => 'Skill', + 'status' => TaxonomyStatus::Active, + ]); + $nodes = collect(['Giving feedback', 'Conflict handling'])->map(fn ($name) => TaxonomyNode::query()->create([ + 'organization_id' => $designer->organization_id, 'taxonomy_type_id' => $type->getKey(), + 'name' => $name, 'status' => TaxonomyStatus::Active, + ])); + foreach ($nodes->values() as $index => $node) { + ContentTaxonomyMapping::query()->create([ + 'organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), + 'mappable_type' => MappableType::Question, 'mappable_id' => $question->getKey(), + 'taxonomy_node_id' => $node->getKey(), 'mapping_type' => MappingType::Assesses, + 'weight' => $index === 0 ? 0.7 : 0.3, 'source' => MappingSource::Manual, + 'confirmation_status' => $confirmation, 'confirmed_by' => $confirmation === MappingConfirmationStatus::Confirmed ? $designer->getKey() : null, + ]); + } + $attempt = AssessmentAttempt::query()->create([ + 'organization_id' => $designer->organization_id, 'learner_id' => $learner->getKey(), + 'course_version_id' => $version->getKey(), 'assessment_id' => $assessment->getKey(), + 'attempt_number' => 1, 'status' => 'completed', 'score' => 0.84, + 'started_at' => now()->subMinute(), 'completed_at' => now(), + ]); + $result = QuestionResult::query()->create([ + 'organization_id' => $designer->organization_id, 'assessment_attempt_id' => $attempt->getKey(), + 'question_id' => $question->getKey(), 'raw_value' => ['choice' => 'a'], + 'normalized_value' => 0.84, 'is_correct' => true, 'occurred_at' => now(), + ]); + + return [$result, $nodes->pluck('id')->all()]; + } +} diff --git a/backend/tests/Feature/ExampleTest.php b/backend/tests/Feature/ExampleTest.php new file mode 100644 index 0000000..8364a84 --- /dev/null +++ b/backend/tests/Feature/ExampleTest.php @@ -0,0 +1,19 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/backend/tests/Feature/Identity/InvitationAndPasswordTest.php b/backend/tests/Feature/Identity/InvitationAndPasswordTest.php new file mode 100644 index 0000000..dcab67f --- /dev/null +++ b/backend/tests/Feature/Identity/InvitationAndPasswordTest.php @@ -0,0 +1,110 @@ +create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + + $response = $this->postJson('/api/v1/user-invitations', [ + 'email' => 'new.learner@example.test', + 'role' => UserRole::Learner->value, + ])->assertCreated()->assertJsonMissing(['token']); + + $token = null; + Notification::assertSentOnDemand(UserInvited::class, function (UserInvited $notification) use (&$token) { + $token = $notification->token; + + return true; + }); + $this->assertNotNull($token); + + $this->postJson('/api/v1/auth/invitations/accept', [ + 'token' => $token, + 'name' => 'New Learner', + 'password' => 'a-secure-password', + 'password_confirmation' => 'a-secure-password', + ])->assertOk()->assertJsonStructure(['data' => ['token', 'userId']]); + + $user = User::query()->where('email', 'new.learner@example.test')->firstOrFail(); + $this->assertSame($designer->organization_id, $user->organization_id); + $this->assertSame(UserRole::Learner, $user->role); + $this->assertNotNull(UserInvitation::query()->find($response->json('data.id'))->accepted_at); + } + + public function test_seat_limit_blocks_new_invitation(): void + { + Notification::fake(); + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Subscription::query()->create([ + 'organization_id' => $designer->organization_id, + 'plan_key' => 'limited', + 'status' => 'active', + 'starts_at' => now()->subDay(), + 'seat_limit' => 1, + ]); + Sanctum::actingAs($designer); + + $this->postJson('/api/v1/user-invitations', [ + 'email' => 'over.limit@example.test', + 'role' => UserRole::Learner->value, + ])->assertUnprocessable()->assertJsonValidationErrors('email'); + + Notification::assertNothingSent(); + } + + public function test_manager_cannot_invite_users(): void + { + $manager = User::factory()->create(['role' => UserRole::Manager]); + Sanctum::actingAs($manager); + + $this->postJson('/api/v1/user-invitations', [ + 'email' => 'learner@example.test', 'role' => UserRole::Learner->value, + ])->assertForbidden(); + } + + public function test_password_reset_changes_password_and_revokes_existing_tokens(): void + { + Notification::fake(); + $user = User::factory()->create(['password' => 'old-password']); + $user->createToken('existing'); + + $this->postJson('/api/v1/auth/forgot-password', ['email' => $user->email])->assertStatus(202); + Notification::assertSentTo($user, ResetPassword::class); + $token = Password::createToken($user); + + $this->postJson('/api/v1/auth/reset-password', [ + 'email' => $user->email, + 'token' => $token, + 'password' => 'new-secure-password', + 'password_confirmation' => 'new-secure-password', + ])->assertOk()->assertJsonPath('data.reset', true); + + $this->assertTrue(Hash::check('new-secure-password', $user->fresh()->password)); + $this->assertDatabaseCount('personal_access_tokens', 0); + } + + public function test_forgot_password_does_not_reveal_unknown_email(): void + { + $this->postJson('/api/v1/auth/forgot-password', ['email' => 'unknown@example.test'])->assertStatus(202); + } +} diff --git a/backend/tests/Feature/Identity/UserManagementTest.php b/backend/tests/Feature/Identity/UserManagementTest.php new file mode 100644 index 0000000..e891f72 --- /dev/null +++ b/backend/tests/Feature/Identity/UserManagementTest.php @@ -0,0 +1,159 @@ +create(['role' => UserRole::CourseDesigner]); + $match = User::factory()->for($designer->organization)->create([ + 'name' => 'Sara Manager', + 'role' => UserRole::Manager, + 'status' => AccountStatus::Active, + ]); + User::factory()->for($designer->organization)->create(['name' => 'Other Learner', 'role' => UserRole::Learner]); + $foreign = User::factory()->create(['name' => 'Sara Manager', 'role' => UserRole::Manager]); + Sanctum::actingAs($designer); + + $response = $this->getJson('/api/v1/users?search=Sara&role=manager&status=active&perPage=10') + ->assertOk() + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.id', $match->getKey()) + ->assertJsonMissing(['id' => $foreign->getKey()]); + + $this->assertArrayNotHasKey('password', $response->json('data.0')); + $this->assertArrayNotHasKey('remember_token', $response->json('data.0')); + } + + public function test_designer_can_update_same_tenant_user_and_disabling_revokes_tokens(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $target = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); + $target->createToken('existing'); + Sanctum::actingAs($designer); + + $this->patchJson('/api/v1/users/'.$target->getKey(), [ + 'role' => UserRole::Manager->value, + 'status' => AccountStatus::Disabled->value, + ])->assertOk() + ->assertJsonPath('data.role', UserRole::Manager->value) + ->assertJsonPath('data.status', AccountStatus::Disabled->value); + + $this->assertDatabaseMissing('personal_access_tokens', ['tokenable_id' => $target->getKey()]); + } + + public function test_designer_can_edit_workforce_profile_and_download_real_xlsx_template(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager, 'job_level' => 'manager']); + $target = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); + Sanctum::actingAs($designer); + + $this->patchJson('/api/v1/users/'.$target->getKey(), [ + 'firstName' => 'مریم', 'lastName' => 'احمدی', 'email' => 'maryam.updated@example.test', + 'department' => 'عملیات', 'jobLevel' => 'specialist', 'directManagerId' => $manager->getKey(), + ])->assertOk() + ->assertJsonPath('data.name', 'مریم احمدی') + ->assertJsonPath('data.email', 'maryam.updated@example.test') + ->assertJsonPath('data.directManager.id', $manager->getKey()); + + $this->assertDatabaseHas('users', ['id' => $target->getKey(), 'department' => 'عملیات', 'direct_manager_id' => $manager->getKey()]); + $template = $this->get('/api/v1/users/import-template')->assertOk() + ->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $this->assertStringStartsWith('PK', $template->streamedContent()); + } + + public function test_workforce_edit_rejects_self_manager_and_hierarchy_cycles(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $first = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager, 'job_level' => 'manager']); + $second = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager, 'job_level' => 'manager', 'direct_manager_id' => $first->getKey()]); + Sanctum::actingAs($designer); + + $this->patchJson('/api/v1/users/'.$first->getKey(), ['directManagerId' => $first->getKey()]) + ->assertUnprocessable()->assertJsonValidationErrors('directManagerId'); + $this->patchJson('/api/v1/users/'.$first->getKey(), ['directManagerId' => $second->getKey()]) + ->assertUnprocessable()->assertJsonValidationErrors('directManagerId'); + } + + public function test_last_active_designer_cannot_be_disabled_or_demoted(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + + $this->patchJson('/api/v1/users/'.$designer->getKey(), ['status' => AccountStatus::Disabled->value]) + ->assertUnprocessable() + ->assertJsonValidationErrors('role'); + $this->patchJson('/api/v1/users/'.$designer->getKey(), ['role' => UserRole::Manager->value]) + ->assertUnprocessable() + ->assertJsonValidationErrors('role'); + } + + public function test_user_mutation_is_tenant_scoped_and_manager_is_read_only(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $foreign = User::factory()->create(); + Sanctum::actingAs($designer); + $this->patchJson('/api/v1/users/'.$foreign->getKey(), ['status' => AccountStatus::Disabled->value])->assertNotFound(); + + $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); + Sanctum::actingAs($manager); + $this->patchJson('/api/v1/users/'.$designer->getKey(), ['status' => AccountStatus::Disabled->value])->assertForbidden(); + } + + public function test_seat_limit_blocks_reactivation_of_a_disabled_user(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $disabled = User::factory()->for($designer->organization)->create(['status' => AccountStatus::Disabled]); + Subscription::query()->create([ + 'organization_id' => $designer->organization_id, + 'plan_key' => 'limited', + 'status' => 'active', + 'starts_at' => now()->subDay(), + 'seat_limit' => 1, + ]); + Sanctum::actingAs($designer); + + $this->patchJson('/api/v1/users/'.$disabled->getKey(), ['status' => AccountStatus::Active->value]) + ->assertUnprocessable() + ->assertJsonValidationErrors('status'); + } + + public function test_designer_can_list_resend_and_revoke_invitation_without_token_leakage(): void + { + Notification::fake(); + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + $id = $this->postJson('/api/v1/user-invitations', [ + 'email' => 'phase3@example.test', + 'role' => UserRole::Learner->value, + ])->assertCreated()->json('data.id'); + + $this->getJson('/api/v1/user-invitations') + ->assertOk() + ->assertJsonPath('data.0.status', 'pending') + ->assertJsonMissing(['token_hash']); + $this->postJson('/api/v1/user-invitations/'.$id.'/resend') + ->assertOk() + ->assertJsonPath('data.status', 'pending') + ->assertJsonMissing(['token']); + $this->deleteJson('/api/v1/user-invitations/'.$id)->assertOk()->assertJsonPath('data.revoked', true); + + $this->assertNotNull(UserInvitation::query()->findOrFail($id)->revoked_at); + Notification::assertSentOnDemandTimes(UserInvited::class, 2); + } +} diff --git a/backend/tests/Feature/Identity/WorkforceImportTest.php b/backend/tests/Feature/Identity/WorkforceImportTest.php new file mode 100644 index 0000000..3f1d0b7 --- /dev/null +++ b/backend/tests/Feature/Identity/WorkforceImportTest.php @@ -0,0 +1,76 @@ +create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + $file = $this->xlsx([ + ['نام', 'نام خانوادگی', 'واحد یا دپارتمان', 'سمت', 'نام مدیر مستقیم', 'آدرس ایمیل'], + ['علی', 'رضایی', 'عملیات', 'مدیر', '', 'ali.manager@example.test'], + ['مریم', 'احمدی', 'عملیات', 'کارشناس', 'علی رضایی', 'maryam.staff@example.test'], + ['سارا', 'مرادی', 'راهبری', 'مدیر ارشد', '', 'sara.senior@example.test'], + ]); + + $this->post('/api/v1/users/import', ['file' => $file], ['Accept' => 'application/json']) + ->assertCreated() + ->assertJsonPath('data.created', 3) + ->assertJsonPath('data.reportsLinked', 1); + + $manager = User::query()->where('email', 'ali.manager@example.test')->firstOrFail(); + $this->assertSame(UserRole::Manager, $manager->role); + $this->assertDatabaseHas('users', [ + 'email' => 'maryam.staff@example.test', + 'first_name' => 'مریم', + 'last_name' => 'احمدی', + 'department' => 'عملیات', + 'job_level' => 'specialist', + 'direct_manager_id' => $manager->getKey(), + ]); + } + + public function test_import_rejects_invalid_job_level_and_missing_manager_atomically(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + + $csv = "نام,نام خانوادگی,واحد یا دپارتمان,سمت,نام مدیر مستقیم,آدرس ایمیل\nمریم,احمدی,عملیات,سرپرست,مدیر ناشناس,maryam@example.test"; + $this->post('/api/v1/users/import', ['file' => UploadedFile::fake()->createWithContent('employees.csv', $csv)], ['Accept' => 'application/json']) + ->assertUnprocessable() + ->assertJsonValidationErrors('rows.2'); + $this->assertDatabaseMissing('users', ['email' => 'maryam@example.test']); + } + + /** @param list> $rows */ + private function xlsx(array $rows): UploadedFile + { + $path = tempnam(sys_get_temp_dir(), 'workforce-xlsx-'); + $zip = new ZipArchive; + $zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE); + $zip->addFromString('[Content_Types].xml', ''); + $zip->addFromString('_rels/.rels', ''); + $zip->addFromString('xl/workbook.xml', ''); + $xmlRows = collect($rows)->map(function (array $row, int $index): string { + $cells = collect($row)->map(fn (string $value, int $column): string => ''.htmlspecialchars($value, ENT_XML1).'')->implode(''); + + return ''.$cells.''; + })->implode(''); + $zip->addFromString('xl/worksheets/sheet1.xml', ''.$xmlRows.''); + $zip->close(); + + return new UploadedFile($path, 'employees.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', null, true); + } +} diff --git a/backend/tests/Feature/Learner/LearnerPlayerTest.php b/backend/tests/Feature/Learner/LearnerPlayerTest.php new file mode 100644 index 0000000..28cdcd5 --- /dev/null +++ b/backend/tests/Feature/Learner/LearnerPlayerTest.php @@ -0,0 +1,151 @@ +foundation(); + Sanctum::actingAs($learner); + + $this->getJson('/api/v1/learner/home')->assertOk()->assertJsonCount(1, 'data.assigned')->assertJsonPath('data.assigned.0.id', $assignment->getKey()) + ->assertJsonPath('data.assigned.0.lessonCount', 1)->assertJsonPath('data.assigned.0.remainingMinutes', 10) + ->assertJsonPath('data.assigned.0.currentLessonTitle', 'PPE')->assertJsonPath('data.assigned.0.favorite', false); + $this->getJson("/api/v1/learner/assignments/{$assignment->getKey()}?courseVersionId={$version->getKey()}&lessonId={$lesson->getKey()}") + ->assertOk()->assertJsonPath('data.lesson.presentationMode', 'flow')->assertJsonPath('data.blocks.0.id', $block->getKey()); + + $outsider = User::factory()->create(['organization_id' => $learner->organization_id, 'role' => UserRole::Learner]); + Sanctum::actingAs($outsider); + $this->getJson('/api/v1/learner/assignments/'.$assignment->getKey())->assertNotFound(); + } + + public function test_offline_event_sync_is_idempotent_and_completion_updates_assignment(): void + { + [$learner, $assignment, $version, $lesson, $block] = $this->foundation(); + Sanctum::actingAs($learner); + $eventId = Str::uuid()->toString(); + $event = ['id' => $eventId, 'type' => 'block.completed', 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey(), 'blockId' => $block->getKey(), 'occurredAt' => now()->toISOString()]; + + $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', false); + $event['type'] = 'lesson.completed'; + $event['id'] = Str::uuid()->toString(); + $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.progress', 100)->assertJsonPath('data.events.0.completed', true); + $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', true); + $this->assertDatabaseCount('learning_events', 2); + $this->assertDatabaseHas('assignment_users', ['assignment_id' => $assignment->getKey(), 'user_id' => $learner->getKey(), 'status' => 'completed', 'progress' => 100]); + } + + public function test_notes_and_bookmarks_are_private_and_tenant_scoped(): void + { + [$learner, $assignment, $version, $lesson, $block] = $this->foundation(); + Sanctum::actingAs($learner); + $input = ['courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey()]; + $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/notes', [...$input, 'body' => 'نکته شخصی'])->assertCreated(); + $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/bookmarks', [...$input, 'blockId' => $block->getKey()])->assertOk()->assertJsonPath('data.bookmarked', true); + $this->getJson("/api/v1/learner/assignments/{$assignment->getKey()}?courseVersionId={$version->getKey()}&lessonId={$lesson->getKey()}")->assertJsonPath('data.notes.0.body', 'نکته شخصی')->assertJsonPath('data.bookmarks.0', $block->getKey()); + } + + public function test_highlights_favorites_discussion_replies_and_reactions_are_real_and_scoped(): void + { + [$learner, $assignment, $version, $lesson, $block] = $this->foundation(); + Sanctum::actingAs($learner); + $context = ['courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey()]; + $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/highlights', [...$context, 'blockId' => $block->getKey(), 'quote' => 'Safety first', 'color' => 'yellow'])->assertCreated(); + $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/favorite')->assertOk()->assertJsonPath('data.favorite', true); + $comment = $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/discussions', [...$context, 'body' => 'پرسش من'])->assertCreated()->json('data.id'); + $this->postJson('/api/v1/learner/assignments/'.$assignment->getKey().'/discussions', [...$context, 'body' => 'پاسخ من', 'parentId' => $comment])->assertCreated(); + $this->postJson('/api/v1/learner/discussions/'.$comment.'/reactions', ['reaction' => 'helpful'])->assertOk()->assertJsonPath('data.active', true); + $this->getJson("/api/v1/learner/assignments/{$assignment->getKey()}?courseVersionId={$version->getKey()}&lessonId={$lesson->getKey()}")->assertJsonPath('data.favorite', true)->assertJsonPath('data.highlights.0.quote', 'Safety first')->assertJsonCount(2, 'data.discussions'); + } + + public function test_offline_note_event_is_idempotent(): void + { + [$learner, $assignment, $version, $lesson] = $this->foundation(); + Sanctum::actingAs($learner); + $event = ['id' => Str::uuid()->toString(), 'type' => 'note.created', 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey(), 'payload' => ['body' => 'یادداشت آفلاین'], 'occurredAt' => now()->toISOString()]; + + $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', false); + $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk()->assertJsonPath('data.events.0.duplicate', true); + $this->assertDatabaseCount('learner_notes', 1); + $this->assertDatabaseHas('learner_notes', ['learner_id' => $learner->getKey(), 'body' => 'یادداشت آفلاین']); + } + + public function test_assessment_score_is_calculated_server_side(): void + { + [$learner, $assignment, $version, $lesson, $block] = $this->foundation(); + $block->update(['type' => 'single_choice', 'data' => ['prompt' => 'پاسخ؟', 'options' => ['صحیح', 'غلط'], 'answerIndex' => 0]]); + Sanctum::actingAs($learner); + $event = ['id' => Str::uuid()->toString(), 'type' => 'block.interacted', 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), 'lessonId' => $lesson->getKey(), 'blockId' => $block->getKey(), 'payload' => ['response' => [1], 'score' => 1], 'occurredAt' => now()->toISOString()]; + + $this->postJson('/api/v1/learner/events/sync', ['events' => [$event]])->assertOk(); + $this->assertDatabaseHas('block_progress', ['block_id' => $block->getKey(), 'learner_id' => $learner->getKey(), 'score' => 0]); + } + + public function test_progress_report_uses_real_assessments_events_and_capability_scores(): void + { + [$learner, $assignment, $version] = $this->foundation(); + $assessment = Assessment::query()->create(['organization_id' => $learner->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'آزمون ایمنی عمومی', 'settings' => ['passScore' => 70, 'maxAttempts' => 3]]); + AssessmentAttempt::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'assessment_id' => $assessment->getKey(), 'attempt_number' => 1, 'status' => 'completed', 'score' => .75, 'started_at' => now()->subWeek()->subHour(), 'completed_at' => now()->subWeek()]); + AssessmentAttempt::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'assessment_id' => $assessment->getKey(), 'attempt_number' => 2, 'status' => 'completed', 'score' => .85, 'started_at' => now()->subHour(), 'completed_at' => now()]); + $this->learningEvent($learner, $assignment, $version, now()->subWeek(), 1800, 'previous-session'); + $this->learningEvent($learner, $assignment, $version, now(), 3600, 'current-session'); + + $taxonomyType = (string) Str::ulid(); $taxonomyNode = (string) Str::ulid(); + DB::table('taxonomy_types')->insert(['id' => $taxonomyType, 'organization_id' => $learner->organization_id, 'key' => 'skill', 'name' => 'مهارت', 'status' => 'active', 'created_at' => now(), 'updated_at' => now()]); + DB::table('taxonomy_nodes')->insert(['id' => $taxonomyNode, 'organization_id' => $learner->organization_id, 'taxonomy_type_id' => $taxonomyType, 'name' => 'ایمنی عملی', 'status' => 'active', 'created_at' => now(), 'updated_at' => now()]); + DB::table('capability_scores')->insert(['id' => (string) Str::ulid(), 'organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'taxonomy_node_id' => $taxonomyNode, 'score' => 78, 'confidence' => .9, 'confidence_level' => 'high', 'evidence_count' => 4, 'last_evidence_at' => now(), 'trend' => 3, 'scoring_model_version' => 'test-v1', 'explanation' => json_encode(['source' => 'test']), 'calculated_at' => now(), 'created_at' => now(), 'updated_at' => now()]); + + Sanctum::actingAs($learner); + $this->getJson('/api/v1/learner/progress')->assertOk() + ->assertJsonPath('data.summary.averageAssessment', 80) + ->assertJsonPath('data.summary.assessmentDelta', 10) + ->assertJsonPath('data.summary.learningMinutes', 60) + ->assertJsonPath('data.summary.learningMinutesDelta', 100) + ->assertJsonPath('data.skills.0.name', 'ایمنی عملی') + ->assertJsonPath('data.skills.0.score', 78) + ->assertJsonPath('data.recentAssessments.0.score', 85) + ->assertJsonPath('data.insight.source', 'capability_scores'); + } + + private function learningEvent(User $learner, Assignment $assignment, CourseVersion $version, mixed $occurredAt, int $durationSeconds, string $session): void + { + LearningEvent::query()->create(['organization_id' => $learner->organization_id, 'learner_id' => $learner->getKey(), 'client_event_id' => Str::uuid()->toString(), 'event_type' => 'course.opened', 'schema_version' => 1, 'session_id' => $session, 'assignment_id' => $assignment->getKey(), 'course_version_id' => $version->getKey(), 'payload' => ['durationSeconds' => $durationSeconds], 'occurred_at' => $occurredAt, 'received_at' => $occurredAt]); + } + + private function foundation(): array + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $learner = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Learner]); + $course = Course::query()->create(['organization_id' => $designer->organization_id, 'title' => 'Safety', 'slug' => 'safety', 'status' => 'published', 'created_by' => $designer->getKey()]); + $version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Published, 'title' => 'Safety', 'description' => 'Safety course', 'settings' => ['language' => 'fa'], 'completion_rules' => ['mode' => 'all', 'rules' => [['type' => 'all_required_lessons']]], 'published_at' => now()]); + $module = CourseModule::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'Basics', 'position' => 1]); + $lesson = Lesson::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'PPE', 'position' => 1, 'presentation_mode' => 'flow', 'settings' => ['durationMinutes' => 10]]); + $block = Block::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson->getKey(), 'type' => 'heading', 'schema_version' => 1, 'data' => ['text' => 'Safety first', 'level' => 2], 'position' => 1, 'revision' => 1]); + $assignment = Assignment::query()->create(['organization_id' => $designer->organization_id, 'assignable_type' => 'course', 'assignable_id' => $version->getKey(), 'target_type' => 'individual', 'target_id' => $learner->getKey(), 'status' => 'active', 'mandatory' => true, 'assigned_by' => $designer->getKey()]); + app(AssignmentResolver::class)->sync($assignment); + + return [$learner, $assignment, $version, $lesson, $block]; + } +} diff --git a/backend/tests/Feature/Manager/ManagerAssignmentTest.php b/backend/tests/Feature/Manager/ManagerAssignmentTest.php new file mode 100644 index 0000000..dd907eb --- /dev/null +++ b/backend/tests/Feature/Manager/ManagerAssignmentTest.php @@ -0,0 +1,121 @@ +teamContext(); + [, $published] = $this->course($manager, CourseVersionStatus::Published, 'ایمنی منتشرشده'); + $this->course($manager, CourseVersionStatus::Draft, 'پیش‌نویس خصوصی'); + Sanctum::actingAs($manager); + + $this->getJson('/api/v1/manager/assignment-contexts')->assertOk() + ->assertJsonPath('data.content.0.id', $published->getKey()) + ->assertJsonPath('data.teams.0.id', $team->getKey()) + ->assertJsonPath('data.members.0.id', $member->getKey()) + ->assertJsonMissing(['id' => $outsider->getKey()]) + ->assertJsonMissing(['title' => 'پیش‌نویس خصوصی']); + } + + public function test_manager_assignment_skips_duplicate_recipients_and_schedules_idempotent_notifications(): void + { + [$manager, $team, $member] = $this->teamContext(); + $second = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Learner, 'name' => 'عضو دوم']); + $team->members()->attach($second); + [, $version] = $this->course($manager, CourseVersionStatus::Published, 'ایمنی'); + $existing = Assignment::query()->create(['organization_id' => $manager->organization_id, 'assignable_type' => 'course', 'assignable_id' => $version->getKey(), 'target_type' => 'individual', 'target_id' => $member->getKey(), 'status' => 'active', 'mandatory' => true, 'assigned_by' => $manager->getKey()]); + $existing->users()->attach($member, ['status' => 'assigned', 'assigned_at' => now(), 'progress' => 0, 'created_at' => now(), 'updated_at' => now()]); + Sanctum::actingAs($manager); + + $assignment = $this->postJson('/api/v1/manager/assignments', [ + 'assignableType' => 'course', 'assignableId' => $version->getKey(), 'audienceType' => 'team', 'teamId' => $team->getKey(), + 'mandatory' => true, 'dueAt' => now()->addDays(10)->toISOString(), 'notifyNow' => true, + 'reminderDays' => 3, 'reminderOnDeadline' => true, 'onlyIfNotStarted' => true, + ])->assertCreated()->assertJsonPath('data.result.assigned', 1)->assertJsonPath('data.result.duplicatesSkipped', 1) + ->assertJsonPath('data.result.notificationsSent', 1)->assertJsonPath('data.result.notificationsScheduled', 2)->json('data.assignment.id'); + + $this->assertDatabaseHas('assignment_users', ['assignment_id' => $assignment, 'user_id' => $second->getKey()]); + $this->assertDatabaseMissing('assignment_users', ['assignment_id' => $assignment, 'user_id' => $member->getKey()]); + $this->assertDatabaseHas('in_app_notifications', ['recipient_id' => $second->getKey(), 'type' => 'learning.assigned']); + $this->assertDatabaseCount('notification_schedules', 2); + $this->assertDatabaseHas('audit_logs', ['actor_id' => $manager->getKey(), 'action' => 'manager.assignment.created', 'entity_id' => $assignment]); + } + + public function test_manager_cannot_target_or_modify_people_outside_their_scope(): void + { + [$manager, , , $outsider] = $this->teamContext(); + [, $version] = $this->course($manager, CourseVersionStatus::Published, 'ایمنی'); + Sanctum::actingAs($manager); + $this->postJson('/api/v1/manager/assignments', [ + 'assignableType' => 'course', 'assignableId' => $version->getKey(), 'audienceType' => 'employees', 'userIds' => [$outsider->getKey()], + 'mandatory' => false, 'notifyNow' => false, 'reminderOnDeadline' => false, 'onlyIfNotStarted' => false, + ])->assertUnprocessable()->assertJsonValidationErrors('userIds'); + + $otherManager = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Manager]); + $foreign = Assignment::query()->create(['organization_id' => $manager->organization_id, 'assignable_type' => 'course', 'assignable_id' => $version->getKey(), 'target_type' => 'individual', 'target_id' => $outsider->getKey(), 'status' => 'active', 'mandatory' => false, 'assigned_by' => $otherManager->getKey()]); + $foreign->users()->attach($outsider, ['status' => 'assigned', 'assigned_at' => now(), 'progress' => 0, 'created_at' => now(), 'updated_at' => now()]); + $this->patchJson('/api/v1/manager/assignments/'.$foreign->getKey().'/deadline', ['dueAt' => now()->addWeek()->toISOString()])->assertNotFound(); + $this->postJson('/api/v1/manager/assignments/'.$foreign->getKey().'/reminders')->assertNotFound(); + } + + public function test_deadline_removal_cancels_schedules_and_manual_reminder_has_daily_cooldown_and_preferences(): void + { + [$manager, , $member] = $this->teamContext(); + [, $version] = $this->course($manager, CourseVersionStatus::Published, 'ایمنی'); + Sanctum::actingAs($manager); + $assignment = $this->postJson('/api/v1/manager/assignments', [ + 'assignableType' => 'course', 'assignableId' => $version->getKey(), 'audienceType' => 'employees', 'userIds' => [$member->getKey()], + 'mandatory' => false, 'dueAt' => now()->addDays(8)->toISOString(), 'notifyNow' => false, + 'reminderDays' => 2, 'reminderOnDeadline' => true, 'onlyIfNotStarted' => false, + ])->assertCreated()->json('data.assignment.id'); + + $this->patchJson('/api/v1/manager/assignments/'.$assignment.'/deadline', ['dueAt' => null]) + ->assertOk()->assertJsonPath('data.assignment.dueAt', null)->assertJsonPath('data.notificationsScheduled', 0); + $this->assertDatabaseHas('notification_schedules', ['entity_id' => $assignment, 'status' => 'cancelled']); + $this->postJson('/api/v1/manager/assignments/'.$assignment.'/reminders')->assertOk()->assertJsonPath('data.sent', 1); + $this->postJson('/api/v1/manager/assignments/'.$assignment.'/reminders')->assertOk()->assertJsonPath('data.sent', 0)->assertJsonPath('data.skipped', 1); + + DB::table('user_preferences')->insert(['id' => (string) str()->ulid(), 'user_id' => $member->getKey(), 'preferences' => json_encode(['deadlineReminders' => false]), 'created_at' => now(), 'updated_at' => now()]); + DB::table('in_app_notifications')->where('recipient_id', $member->getKey())->delete(); + $this->travel(1)->day(); + $this->postJson('/api/v1/manager/assignments/'.$assignment.'/reminders')->assertOk()->assertJsonPath('data.sent', 0)->assertJsonPath('data.skipped', 1); + } + + /** @return array{User, Team, User, User} */ + private function teamContext(): array + { + $manager = User::factory()->create(['role' => UserRole::Manager]); + $member = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Learner, 'name' => 'عضو مجاز']); + $outsider = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Learner, 'name' => 'عضو خارج محدوده']); + $team = Team::query()->create(['organization_id' => $manager->organization_id, 'name' => 'عملیات', 'status' => 'active', 'created_by' => $manager->getKey()]); + $team->managers()->attach($manager); + $team->members()->attach($member); + + return [$manager, $team, $member, $outsider]; + } + + /** @return array{Course, CourseVersion} */ + private function course(User $owner, CourseVersionStatus $status, string $title): array + { + $course = Course::query()->create(['organization_id' => $owner->organization_id, 'title' => $title, 'slug' => str()->slug($title).'-'.str()->lower(str()->random(5)), 'status' => $status === CourseVersionStatus::Published ? 'published' : 'draft', 'created_by' => $owner->getKey()]); + $version = CourseVersion::query()->create(['organization_id' => $owner->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => $status, 'title' => $title, 'settings' => [], 'completion_rules' => [], 'published_at' => $status === CourseVersionStatus::Published ? now() : null]); + + return [$course, $version]; + } +} diff --git a/backend/tests/Feature/Manager/ManagerWorkspaceTest.php b/backend/tests/Feature/Manager/ManagerWorkspaceTest.php new file mode 100644 index 0000000..3d03928 --- /dev/null +++ b/backend/tests/Feature/Manager/ManagerWorkspaceTest.php @@ -0,0 +1,126 @@ +create(['role' => UserRole::Manager]); + $learner = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Learner, 'name' => 'عضو تیم من']); + $otherManager = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Manager]); + $outsider = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Learner, 'name' => 'عضو تیم دیگر']); + $team = Team::query()->create(['organization_id' => $manager->organization_id, 'name' => 'عملیات', 'status' => 'active', 'created_by' => $manager->getKey()]); + $team->managers()->attach($manager); + $team->members()->attach($learner); + $otherTeam = Team::query()->create(['organization_id' => $manager->organization_id, 'name' => 'فروش', 'status' => 'active', 'created_by' => $otherManager->getKey()]); + $otherTeam->managers()->attach($otherManager); + $otherTeam->members()->attach($outsider); + [$version, $assessment] = $this->content($manager); + $assignment = Assignment::query()->create(['organization_id' => $manager->organization_id, 'assignable_type' => 'course', 'assignable_id' => $version->getKey(), 'target_type' => 'team', 'target_id' => $team->getKey(), 'status' => 'active', 'mandatory' => true, 'assigned_by' => $manager->getKey()]); + $assignment->users()->attach($learner, ['status' => 'in_progress', 'assigned_at' => now(), 'due_at' => now()->addDays(2), 'progress' => 25, 'created_at' => now(), 'updated_at' => now()]); + $otherAssignment = Assignment::query()->create(['organization_id' => $manager->organization_id, 'assignable_type' => 'course', 'assignable_id' => $version->getKey(), 'target_type' => 'team', 'target_id' => $otherTeam->getKey(), 'status' => 'active', 'mandatory' => true, 'assigned_by' => $otherManager->getKey()]); + $otherAssignment->users()->attach($outsider, ['status' => 'completed', 'assigned_at' => now(), 'completed_at' => now(), 'progress' => 100, 'created_at' => now(), 'updated_at' => now()]); + LearningEvent::query()->create(['organization_id' => $manager->organization_id, 'learner_id' => $learner->getKey(), 'client_event_id' => Str::uuid(), 'event_type' => 'lesson.started', 'assignment_id' => $assignment->getKey(), 'course_version_id' => $version->getKey(), 'payload' => [], 'occurred_at' => now(), 'received_at' => now()]); + LearningEvent::query()->create(['organization_id' => $manager->organization_id, 'learner_id' => $outsider->getKey(), 'client_event_id' => Str::uuid(), 'event_type' => 'course.completed', 'assignment_id' => $otherAssignment->getKey(), 'course_version_id' => $version->getKey(), 'payload' => [], 'occurred_at' => now()->addSecond(), 'received_at' => now()]); + AssessmentAttempt::query()->create(['organization_id' => $manager->organization_id, 'learner_id' => $learner->getKey(), 'course_version_id' => $version->getKey(), 'assessment_id' => $assessment->getKey(), 'attempt_number' => 1, 'status' => 'completed', 'score' => .8, 'started_at' => now()->subMinute(), 'completed_at' => now()]); + AssessmentAttempt::query()->create(['organization_id' => $manager->organization_id, 'learner_id' => $outsider->getKey(), 'course_version_id' => $version->getKey(), 'assessment_id' => $assessment->getKey(), 'attempt_number' => 1, 'status' => 'completed', 'score' => .95, 'started_at' => now()->subMinute(), 'completed_at' => now()]); + + Sanctum::actingAs($manager); + $this->getJson('/api/v1/manager/workspace')->assertOk() + ->assertJsonPath('data.overview.teamCount', 1)->assertJsonPath('data.overview.memberCount', 1) + ->assertJsonPath('data.overview.activeLearners', 1)->assertJsonPath('data.overview.dueSoon', 1) + ->assertJsonPath('data.overview.engagement', 100)->assertJsonPath('data.overview.assessment', 80) + ->assertJsonPath('data.overview.atRisk', 1)->assertJsonPath('data.members.0.name', 'عضو تیم من') + ->assertJsonPath('data.recentActivity.0.learner', 'عضو تیم من') + ->assertJsonCount(1, 'data.assessments') + ->assertJsonPath('data.assessments.0.learnerId', $learner->getKey()) + ->assertJsonMissing(['learner' => 'عضو تیم دیگر']) + ->assertJsonMissing(['name' => 'عضو تیم دیگر']); + } + + public function test_manager_endpoint_rejects_other_roles_and_manager_mutations_remain_forbidden(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + $this->getJson('/api/v1/manager/workspace')->assertForbidden(); + + $manager = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Manager]); + Sanctum::actingAs($manager); + $this->postJson('/api/v1/assignments', [])->assertForbidden(); + $this->postJson('/api/v1/courses', [])->assertForbidden(); + } + + public function test_manager_can_view_only_learning_assigned_to_their_own_account(): void + { + $manager = User::factory()->create(['role' => UserRole::Manager]); + $teamMember = User::factory()->create(['organization_id' => $manager->organization_id, 'role' => UserRole::Learner]); + [$version] = $this->content($manager); + $managerAssignment = Assignment::query()->create([ + 'organization_id' => $manager->organization_id, + 'assignable_type' => 'course', + 'assignable_id' => $version->getKey(), + 'target_type' => 'individual', + 'target_id' => $manager->getKey(), + 'status' => 'active', + 'mandatory' => true, + 'assigned_by' => $manager->getKey(), + ]); + $managerAssignment->users()->attach($manager, [ + 'status' => 'assigned', 'assigned_at' => now(), 'progress' => 0, + 'created_at' => now(), 'updated_at' => now(), + ]); + $memberAssignment = Assignment::query()->create([ + 'organization_id' => $manager->organization_id, + 'assignable_type' => 'course', + 'assignable_id' => $version->getKey(), + 'target_type' => 'individual', + 'target_id' => $teamMember->getKey(), + 'status' => 'active', + 'mandatory' => true, + 'assigned_by' => $manager->getKey(), + ]); + $memberAssignment->users()->attach($teamMember, [ + 'status' => 'assigned', 'assigned_at' => now(), 'progress' => 0, + 'created_at' => now(), 'updated_at' => now(), + ]); + + Sanctum::actingAs($manager); + + $this->getJson('/api/v1/auth/me') + ->assertOk() + ->assertJsonFragment(['learning.personal.view']); + $this->getJson('/api/v1/learner/home') + ->assertOk() + ->assertJsonPath('data.summary.total', 1) + ->assertJsonPath('data.assigned.0.id', $managerAssignment->getKey()) + ->assertJsonMissing(['id' => $memberAssignment->getKey()]); + $this->getJson('/api/v1/learner/assignments/'.$memberAssignment->getKey())->assertNotFound(); + } + + private function content(User $owner): array + { + $course = Course::query()->create(['organization_id' => $owner->organization_id, 'title' => 'ایمنی', 'slug' => 'manager-safety', 'status' => 'published', 'created_by' => $owner->getKey()]); + $version = CourseVersion::query()->create(['organization_id' => $owner->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => CourseVersionStatus::Published, 'title' => 'ایمنی محیط کار', 'settings' => [], 'completion_rules' => [], 'published_at' => now()]); + $assessment = Assessment::query()->create(['organization_id' => $owner->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'ارزیابی ایمنی', 'settings' => []]); + + return [$version, $assessment]; + } +} diff --git a/backend/tests/Feature/Monitoring/MonitoringPipelineTest.php b/backend/tests/Feature/Monitoring/MonitoringPipelineTest.php new file mode 100644 index 0000000..f6d4cd3 --- /dev/null +++ b/backend/tests/Feature/Monitoring/MonitoringPipelineTest.php @@ -0,0 +1,99 @@ +seed(DatabaseSeeder::class); + $learner = User::query()->where('email', 'maryam@microlearn.test')->firstOrFail(); + $assignment = Assignment::query()->firstOrFail(); + $version = CourseVersion::query()->where('status', 'published')->firstOrFail(); + $block = Block::query()->where('course_version_id', $version->getKey())->firstOrFail(); + Sanctum::actingAs($learner); + $id = fake()->uuid(); + $payload = ['events' => [[ + 'id' => $id, 'type' => 'video.progressed', 'schemaVersion' => 1, + 'sessionId' => 'session-1', 'correlationId' => fake()->uuid(), + 'deviceContext' => ['platform' => 'web', 'formFactor' => 'phone', 'online' => false, 'secret' => 'discard'], + 'assignmentId' => $assignment->getKey(), 'courseVersionId' => $version->getKey(), + 'lessonId' => $block->lesson_id, 'blockId' => $block->getKey(), + 'payload' => ['progressPercent' => 50, 'positionSeconds' => 30, 'durationSeconds' => 60], + 'occurredAt' => now()->subDays(5)->toISOString(), + ]]]; + + $this->postJson('/api/v1/learner/events/sync', $payload)->assertOk()->assertJsonPath('data.events.0.duplicate', false); + $this->postJson('/api/v1/learner/events/sync', $payload)->assertOk()->assertJsonPath('data.events.0.duplicate', true); + $event = LearningEvent::query()->where('client_event_id', $id)->firstOrFail(); + $this->assertSame(1, $event->schema_version); + $this->assertSame('session-1', $event->session_id); + $this->assertArrayNotHasKey('secret', $event->device_context); + $this->assertTrue($event->received_at->isAfter($event->occurred_at)); + + $analytics = app(AnalyticsProjectionService::class); + $analytics->process($event); + $analytics->process($event); + $this->assertDatabaseHas('analytics_event_projections', ['learning_event_id' => $event->getKey(), 'processor' => 'daily-metrics', 'processor_version' => 1]); + $this->assertSame(1, DB::table('analytics_event_projections')->where('learning_event_id', $event->getKey())->count()); + $this->assertDatabaseHas('analytics_daily_metrics', ['organization_id' => $learner->organization_id, 'metric_date' => $event->occurred_at->toDateString()]); + } + + public function test_raw_learning_events_are_immutable_after_ingestion(): void + { + $this->seed(DatabaseSeeder::class); + $event = LearningEvent::query()->firstOrFail(); + + $this->expectException(\DomainException::class); + $event->update(['event_type' => 'course.completed']); + } + + public function test_monitoring_is_real_tenant_scoped_explainable_and_rebuildable(): void + { + $this->seed(DatabaseSeeder::class); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + Sanctum::actingAs($designer); + + $this->getJson('/api/v1/monitoring')->assertOk() + ->assertJsonPath('data.definitions.risk', 'Explainable heuristic based on inactivity, deadline, progress, and assessment factors; it is not an ML probability.') + ->assertJsonStructure(['data' => ['overview' => ['learningHealth', 'factors', 'weights', 'completion', 'engagement', 'averageRisk'], 'trend', 'courses', 'learners', 'assessments', 'video', 'skills', 'attention']]); + $this->postJson('/api/v1/monitoring/rebuild')->assertOk()->assertJsonStructure(['data' => ['eventsProcessed', 'learnersRecalculated', 'rebuiltAt']]); + + $other = Organization::query()->create(['name' => 'Other', 'slug' => 'other', 'status' => 'active', 'default_locale' => 'en', 'timezone' => 'UTC']); + LearningEvent::query()->create(['organization_id' => $other->getKey(), 'learner_id' => User::factory()->create(['organization_id' => $other->getKey(), 'role' => UserRole::Learner])->getKey(), 'client_event_id' => fake()->uuid(), 'event_type' => 'course.opened', 'schema_version' => 1, 'payload' => [], 'occurred_at' => now(), 'received_at' => now()]); + $response = $this->getJson('/api/v1/monitoring')->assertOk(); + $this->assertSame(3, count($response->json('data.learners'))); + + $version = CourseVersion::query()->where('status', 'published')->firstOrFail(); + $this->getJson('/api/v1/monitoring?course='.$version->getKey().'&page=1&pageSize=10&sort=progress&direction=desc') + ->assertOk() + ->assertJsonStructure(['data' => ['learnerSummary' => ['total', 'learning', 'completed', 'followUp', 'invited'], 'learnerMeta' => ['page', 'pageSize', 'total', 'lastPage'], 'learners' => [['id', 'name', 'email', 'teams', 'status', 'needsFollowUp', 'progress', 'completion', 'assignedAt', 'startedAt', 'lastActivityAt', 'hasAssessment', 'assessmentScore', 'attempts', 'totalTimeMinutes', 'overdueItems']]]]) + ->assertJsonPath('data.learnerMeta.page', 1) + ->assertJsonPath('data.learnerMeta.pageSize', 10); + } + + public function test_monitoring_rejects_platform_manager_and_learner_roles(): void + { + $this->seed(DatabaseSeeder::class); + foreach (['admin@microlearn.test', 'manager@microlearn.test', 'maryam@microlearn.test'] as $email) { + Sanctum::actingAs(User::query()->where('email', $email)->firstOrFail()); + $this->getJson('/api/v1/monitoring')->assertForbidden(); + } + } +} diff --git a/backend/tests/Feature/Organizations/OrganizationAdministrationTest.php b/backend/tests/Feature/Organizations/OrganizationAdministrationTest.php new file mode 100644 index 0000000..f79dae6 --- /dev/null +++ b/backend/tests/Feature/Organizations/OrganizationAdministrationTest.php @@ -0,0 +1,101 @@ +create(['role' => UserRole::SuperAdmin])); + + $id = $this->postJson('/api/v1/organizations', [ + 'name' => 'Acme Learning', 'slug' => 'acme-learning', 'defaultLocale' => 'fa', 'timezone' => 'Asia/Tehran', + ])->assertCreated()->assertJsonMissingPath('data.learningAnalytics')->json('data.id'); + + $this->getJson('/api/v1/organizations')->assertOk()->assertJsonPath('meta.total', 2)->assertJsonFragment(['slug' => 'acme-learning']); + $this->patchJson("/api/v1/organizations/{$id}", ['status' => 'disabled'])->assertOk()->assertJsonPath('data.status', 'disabled'); + } + + public function test_tenant_roles_cannot_access_platform_organization_administration(): void + { + Sanctum::actingAs(User::factory()->create(['role' => UserRole::CourseDesigner])); + $this->getJson('/api/v1/organizations')->assertForbidden(); + } + + public function test_on_premise_mode_hides_multi_organization_endpoints(): void + { + config()->set('deployment.mode', 'on_premise'); + $this->app->forgetInstance(DeploymentCapabilities::class); + Sanctum::actingAs(User::factory()->create(['role' => UserRole::SuperAdmin])); + $this->getJson('/api/v1/organizations')->assertNotFound(); + } + + public function test_organization_slug_is_unique(): void + { + Organization::factory()->create(['slug' => 'taken']); + Sanctum::actingAs(User::factory()->create(['role' => UserRole::SuperAdmin])); + $this->postJson('/api/v1/organizations', ['name' => 'Duplicate', 'slug' => 'taken', 'defaultLocale' => 'en', 'timezone' => 'UTC']) + ->assertUnprocessable()->assertJsonValidationErrors('slug'); + } + + public function test_platform_list_supports_search_filters_sorting_and_complete_pagination_metadata(): void + { + $alpha = Organization::factory()->create(['name' => 'Alpha Learning', 'slug' => 'alpha', 'status' => 'active']); + Organization::factory()->create(['name' => 'Beta Academy', 'slug' => 'beta', 'status' => 'disabled']); + User::factory()->count(2)->for($alpha)->create(); + Subscription::query()->create(['organization_id' => $alpha->getKey(), 'plan_key' => 'enterprise', 'status' => 'active', 'starts_at' => now()->subDay(), 'expires_at' => now()->addMonth(), 'seat_limit' => 50, 'storage_quota_bytes' => 1000, 'ai_credit_quota' => 500]); + DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'organization_id' => $alpha->getKey(), 'actor_id' => null, 'action' => 'organization.activity', 'entity_type' => 'organization', 'entity_id' => $alpha->getKey(), 'metadata' => json_encode(['schemaVersion' => 1]), 'ip_address' => '127.0.0.1', 'created_at' => now()]); + Sanctum::actingAs(User::factory()->create(['organization_id' => null, 'role' => UserRole::SuperAdmin])); + + $this->getJson('/api/v1/organizations?search=alpha&status=active&plan=enterprise&sort=users&direction=desc&perPage=1') + ->assertOk() + ->assertJsonPath('meta.currentPage', 1) + ->assertJsonPath('meta.lastPage', 1) + ->assertJsonPath('meta.perPage', 1) + ->assertJsonPath('meta.total', 1) + ->assertJsonPath('data.0.id', $alpha->getKey()) + ->assertJsonPath('data.0.planKey', 'enterprise') + ->assertJsonPath('data.0.usersCount', 2) + ->assertJsonPath('data.0.aiCreditsUsed', 0) + ->assertJsonStructure(['data' => [['lastActivityAt', 'subscriptionExpiresAt', 'storageUsedBytes', 'storageQuotaBytes']]]); + } + + public function test_organization_detail_is_platform_scoped_and_exposes_real_operational_metadata(): void + { + $organization = Organization::factory()->create(['name' => 'Detail Org']); + User::factory()->for($organization)->create(['name' => 'Member One']); + Subscription::query()->create(['organization_id' => $organization->getKey(), 'plan_key' => 'growth', 'status' => 'active', 'starts_at' => now()->subDay(), 'seat_limit' => 20, 'storage_quota_bytes' => 2048, 'storage_used_bytes' => 512, 'ai_credit_quota' => 100, 'ai_credits_used' => 12, 'enabled_features' => ['exports']]); + DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'organization_id' => $organization->getKey(), 'actor_id' => null, 'action' => 'organization.seeded', 'entity_type' => 'organization', 'entity_id' => $organization->getKey(), 'metadata' => json_encode(['schemaVersion' => 1]), 'ip_address' => '127.0.0.1', 'created_at' => now()]); + Sanctum::actingAs(User::factory()->create(['organization_id' => null, 'role' => UserRole::SuperAdmin])); + + $this->getJson('/api/v1/organizations/'.$organization->getKey()) + ->assertOk() + ->assertJsonPath('data.subscription.planKey', 'growth') + ->assertJsonPath('data.subscription.aiCreditsUsed', 12) + ->assertJsonPath('data.recentUsers.0.name', 'Member One') + ->assertJsonPath('data.recentActivity.0.action', 'organization.seeded') + ->assertJsonStructure(['data' => ['storageUsedBytes', 'aiJobsCount', 'failedAiJobsCount', 'recentUsers', 'recentActivity']]); + } + + public function test_organization_lifecycle_changes_are_audited(): void + { + Sanctum::actingAs(User::factory()->create(['organization_id' => null, 'role' => UserRole::SuperAdmin])); + $id = $this->postJson('/api/v1/organizations', ['name' => 'Audited Org', 'slug' => 'audited-org', 'defaultLocale' => 'fa', 'timezone' => 'Asia/Tehran'])->assertCreated()->json('data.id'); + $this->patchJson('/api/v1/organizations/'.$id, ['status' => 'disabled'])->assertOk(); + + $this->assertDatabaseHas('audit_logs', ['organization_id' => $id, 'action' => 'organization.created', 'entity_id' => $id]); + $this->assertDatabaseHas('audit_logs', ['organization_id' => $id, 'action' => 'organization.updated', 'entity_id' => $id]); + } +} diff --git a/backend/tests/Feature/Phase8/PublishingAssignmentsLearningPathsTest.php b/backend/tests/Feature/Phase8/PublishingAssignmentsLearningPathsTest.php new file mode 100644 index 0000000..296fa6b --- /dev/null +++ b/backend/tests/Feature/Phase8/PublishingAssignmentsLearningPathsTest.php @@ -0,0 +1,149 @@ +readyCourse(); + Sanctum::actingAs($designer); + + $this->putJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/completion-rules", [ + 'mode' => 'all', 'rules' => [['type' => 'all_required_lessons']], + ])->assertOk(); + $this->getJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/readiness") + ->assertOk()->assertJsonPath('data.ready', true)->assertJsonCount(6, 'data.checks'); + $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/review")->assertOk()->assertJsonPath('data.status', 'in_review'); + $this->postJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/publish", ['reassignMode' => 'none']) + ->assertOk()->assertJsonPath('data.status', 'published')->assertJsonPath('data.taxonomySnapshotCount', 0); + $this->patchJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}", ['title' => 'Changed'])->assertUnprocessable(); + } + + public function test_readiness_points_the_designer_to_the_exact_empty_lesson(): void + { + [$designer, $course, $version] = $this->readyCourse(); + $lesson = $version->lessons()->firstOrFail(); + $version->blocks()->delete(); + Sanctum::actingAs($designer); + + $this->getJson("/api/v1/courses/{$course->getKey()}/versions/{$version->getKey()}/readiness") + ->assertOk() + ->assertJsonPath('data.ready', false) + ->assertJsonPath('data.checks.2.key', 'lesson_content') + ->assertJsonPath('data.checks.2.status', 'error') + ->assertJsonPath('data.checks.2.target.type', 'lesson') + ->assertJsonPath('data.checks.2.target.lessonId', $lesson->getKey()); + } + + public function test_team_assignment_resolves_members_and_syncs_a_new_member(): void + { + [$designer, , $version] = $this->readyCourse(CourseVersionStatus::Published); + $learner = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Learner]); + $newLearner = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Learner]); + $team = Team::query()->create(['organization_id' => $designer->organization_id, 'name' => 'Operations', 'status' => 'active', 'created_by' => $designer->getKey()]); + $team->members()->attach($learner); + Sanctum::actingAs($designer); + + $assignment = $this->postJson('/api/v1/assignments', [ + 'assignableType' => 'course', 'assignableId' => $version->getKey(), 'targetType' => 'team', + 'targetId' => $team->getKey(), 'mandatory' => true, 'reminderDays' => 3, 'escalationEnabled' => true, + ])->assertCreated()->assertJsonPath('data.recipientCount', 1)->json('data.id'); + + $startsAt = now()->addDay()->startOfMinute(); + $dueAt = now()->addDays(10)->startOfMinute(); + $this->patchJson('/api/v1/assignments/'.$assignment, [ + 'mandatory' => false, 'startsAt' => $startsAt->toISOString(), 'dueAt' => $dueAt->toISOString(), + 'reminderDays' => 5, 'escalationEnabled' => false, + ])->assertOk()->assertJsonPath('data.mandatory', false)->assertJsonPath('data.reminderDays', 5); + $this->assertDatabaseHas('assignments', ['id' => $assignment, 'mandatory' => false, 'reminder_days' => 5]); + $this->assertDatabaseHas('assignment_users', ['assignment_id' => $assignment, 'user_id' => $learner->getKey(), 'starts_at' => $startsAt, 'due_at' => $dueAt]); + + $this->putJson("/api/v1/teams/{$team->getKey()}/members", ['userId' => $newLearner->getKey()])->assertOk(); + $this->assertDatabaseHas('assignment_users', ['assignment_id' => $assignment, 'user_id' => $newLearner->getKey(), 'status' => 'assigned']); + } + + public function test_course_workspace_assignments_expose_summary_progress_details_and_bulk_actions(): void + { + [$designer, , $version] = $this->readyCourse(CourseVersionStatus::Published); + $learner = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Learner]); + $team = Team::query()->create(['organization_id' => $designer->organization_id, 'name' => 'Operations', 'status' => 'active', 'created_by' => $designer->getKey()]); + $team->members()->attach($learner); + Sanctum::actingAs($designer); + + $assignment = $this->postJson('/api/v1/assignments', [ + 'assignableType' => 'course', 'assignableId' => $version->getKey(), 'targetType' => 'team', + 'targetId' => $team->getKey(), 'mandatory' => true, 'dueAt' => now()->addDays(5)->toISOString(), + ])->assertCreated()->json('data.id'); + DB::table('assignment_users')->where('assignment_id', $assignment)->update(['progress' => 40]); + + $this->getJson('/api/v1/assignments?workspace=1&courseVersionId='.$version->getKey()) + ->assertOk()->assertJsonPath('data.summary.total', 1)->assertJsonPath('data.summary.active', 1) + ->assertJsonPath('data.summary.dueSoon', 1)->assertJsonPath('data.items.0.progress', 40) + ->assertJsonPath('data.items.0.targetLabel', 'Operations'); + $this->getJson('/api/v1/assignments/'.$assignment) + ->assertOk()->assertJsonPath('data.id', $assignment)->assertJsonPath('data.reminderHistory', []); + + $newDueAt = now()->addDays(12)->startOfMinute(); + $this->postJson('/api/v1/assignments/bulk', ['ids' => [$assignment], 'action' => 'extend', 'dueAt' => $newDueAt->toISOString()]) + ->assertOk()->assertJsonPath('data.affected', 1); + $this->assertDatabaseHas('assignments', ['id' => $assignment, 'due_at' => $newDueAt]); + } + + public function test_learning_path_orders_published_courses_then_versions_without_mutating_history(): void + { + [$designer, , $courseVersion] = $this->readyCourse(CourseVersionStatus::Published); + Sanctum::actingAs($designer); + $path = $this->postJson('/api/v1/learning-paths', ['title' => 'مسیر ایمنی', 'description' => 'مسیر پایه', 'enforceOrder' => true])->assertCreated()->json('data'); + $item = $this->postJson("/api/v1/learning-paths/{$path['id']}/versions/{$path['versionId']}/items", [ + 'courseVersionId' => $courseVersion->getKey(), 'completionType' => 'minimum_score', 'minimumScore' => 70, + ])->assertCreated()->assertJsonPath('data.position', 1)->json('data.id'); + $this->postJson("/api/v1/learning-paths/{$path['id']}/versions/{$path['versionId']}/review")->assertOk(); + $this->postJson("/api/v1/learning-paths/{$path['id']}/versions/{$path['versionId']}/publish")->assertOk()->assertJsonPath('data.status', 'published'); + $fork = $this->postJson("/api/v1/learning-paths/{$path['id']}/versions/{$path['versionId']}/fork")->assertCreated()->json('data'); + $this->assertSame(2, $fork['number']); + $this->assertSame($item, $this->getJson('/api/v1/learning-paths/'.$path['id'].'?versionId='.$path['versionId'])->json('data.version.items.0.id')); + } + + public function test_bulk_assignment_audience_can_be_resolved_from_csv_without_cross_tenant_users(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $learner = User::factory()->create(['organization_id' => $designer->organization_id, 'role' => UserRole::Learner]); + $outsider = User::factory()->create(['role' => UserRole::Learner]); + Sanctum::actingAs($designer); + $file = UploadedFile::fake()->createWithContent('audience.csv', "Email\n{$learner->email}\n{$outsider->email}\nmissing@example.test\n"); + + $this->post('/api/v1/assignment-audience-import', ['file' => $file], ['Accept' => 'application/json']) + ->assertOk()->assertJsonPath('data.matched', 1)->assertJsonPath('data.userIds.0', $learner->getKey()) + ->assertJsonCount(2, 'data.missingEmails'); + } + + private function readyCourse(CourseVersionStatus $status = CourseVersionStatus::Draft): array + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $course = Course::query()->create(['organization_id' => $designer->organization_id, 'title' => 'Safety', 'slug' => 'safety', 'status' => $status === CourseVersionStatus::Published ? 'published' : 'draft', 'created_by' => $designer->getKey()]); + $version = CourseVersion::query()->create(['organization_id' => $designer->organization_id, 'course_id' => $course->getKey(), 'version_number' => 1, 'status' => $status, 'title' => 'Safety', 'description' => 'Complete safety course', 'settings' => ['language' => 'fa'], 'completion_rules' => ['mode' => 'all', 'rules' => [['type' => 'all_required_lessons']]], 'published_at' => $status === CourseVersionStatus::Published ? now() : null]); + $module = CourseModule::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'title' => 'Basics', 'position' => 1]); + $lesson = Lesson::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'course_module_id' => $module->getKey(), 'title' => 'PPE', 'position' => 1]); + Block::query()->create(['organization_id' => $designer->organization_id, 'course_version_id' => $version->getKey(), 'lesson_id' => $lesson->getKey(), 'type' => 'heading', 'schema_version' => 1, 'data' => ['text' => 'Safety first', 'level' => 2], 'position' => 1, 'revision' => 1]); + + return [$designer, $course, $version]; + } +} diff --git a/backend/tests/Feature/Product/FinalPhaseTest.php b/backend/tests/Feature/Product/FinalPhaseTest.php new file mode 100644 index 0000000..764b096 --- /dev/null +++ b/backend/tests/Feature/Product/FinalPhaseTest.php @@ -0,0 +1,197 @@ +seed(DatabaseSeeder::class); + config(['queue.default' => 'sync', 'exports.disk' => 'local']); + Storage::fake('local'); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + $version = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'published')->firstOrFail(); + Sanctum::actingAs($designer); + + $this->postJson('/api/v1/exports/compatibility', ['courseVersionId' => $version->id, 'format' => 'scorm_12']) + ->assertOk() + ->assertJsonPath('data.blockCount', 2); + + $id = $this->postJson('/api/v1/exports', ['courseVersionId' => $version->id, 'format' => 'scorm_12', 'confirmWarnings' => true, 'settings' => ['language' => 'fa', 'trackProgress' => true, 'trackScore' => false, 'freeNavigation' => true]]) + ->assertStatus(202) + ->json('data.id'); + + $record = DB::table('export_jobs')->find($id); + $this->assertSame('completed', $record->status); + $this->assertSame(100, (int) $record->progress); + Storage::disk('local')->assertExists($record->path); + $archive = new ZipArchive; + $this->assertTrue($archive->open(Storage::disk('local')->path($record->path)) === true); + $this->assertStringContainsString('adlcp:scormtype="sco"', (string) $archive->getFromName('imsmanifest.xml')); + $this->assertStringContainsString('"schemaVersion": 1', (string) $archive->getFromName('course.json')); + $this->assertStringContainsString('cmi.core.lesson_location', (string) $archive->getFromName('index.html')); + $archive->close(); + $this->get('/api/v1/exports/'.$id.'/download')->assertOk(); + + $pdfId = $this->postJson('/api/v1/exports', ['courseVersionId' => $version->id, 'format' => 'pdf_workbook', 'confirmWarnings' => true]) + ->assertStatus(202) + ->json('data.id'); + $pdf = DB::table('export_jobs')->find($pdfId); + $this->assertSame('completed', $pdf->status); + $pdfContents = Storage::disk('local')->get($pdf->path); + $this->assertStringStartsWith('%PDF-', $pdfContents); + $pdfText = (new Parser)->parseContent($pdfContents)->getText(); + $this->assertStringContainsString($version->title, $pdfText); + $this->assertStringNotContainsString('نمایش ایستای محتوای تعاملی', $pdfText); + + $pptxId = $this->postJson('/api/v1/exports', ['courseVersionId' => $version->id, 'format' => 'pptx', 'confirmWarnings' => true]) + ->assertStatus(202) + ->json('data.id'); + $pptx = DB::table('export_jobs')->find($pptxId); + $this->assertSame('completed', $pptx->status); + $presentation = new ZipArchive; + $this->assertTrue($presentation->open(Storage::disk('local')->path($pptx->path)) === true); + $this->assertNotFalse($presentation->getFromName('ppt/presentation.xml')); + $this->assertNotFalse($presentation->getFromName('ppt/slides/slide1.xml')); + $presentation->close(); + + $docxId = $this->postJson('/api/v1/exports', ['courseVersionId' => $version->id, 'format' => 'docx', 'confirmWarnings' => true]) + ->assertStatus(202) + ->json('data.id'); + $docx = DB::table('export_jobs')->find($docxId); + $this->assertSame('completed', $docx->status); + $document = new ZipArchive; + $this->assertTrue($document->open(Storage::disk('local')->path($docx->path)) === true); + $this->assertStringContainsString('wordprocessingml/2006/main', (string) $document->getFromName('word/document.xml')); + $this->assertStringContainsString($version->title, (string) $document->getFromName('word/document.xml')); + $document->close(); + + if ((new RenderCourseExport)->supports('mp4')) { + $mp4Id = $this->postJson('/api/v1/exports', ['courseVersionId' => $version->id, 'format' => 'mp4', 'confirmWarnings' => true]) + ->assertStatus(202) + ->json('data.id'); + $mp4 = DB::table('export_jobs')->find($mp4Id); + $this->assertSame('completed', $mp4->status, (string) $mp4->error); + $this->assertSame('ftyp', substr(Storage::disk('local')->get($mp4->path), 4, 4)); + } + + $this->get('/api/v1/exports/data/users') + ->assertOk() + ->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $this->get('/api/v1/exports/data/courses')->assertOk(); + $this->get('/api/v1/exports/data/assignments')->assertOk(); + } + + public function test_export_center_exposes_real_capabilities_and_prevents_invalid_or_duplicate_jobs(): void + { + $this->seed(DatabaseSeeder::class); + Queue::fake(); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + $version = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'published')->firstOrFail(); + Sanctum::actingAs($designer); + + $response = $this->getJson('/api/v1/exports?courseId='.$version->course_id) + ->assertOk() + ->assertJsonPath('data.courses.0.courseId', $version->course_id); + $this->assertContains('docx', $response->json('data.formats')); + if ((new RenderCourseExport)->supports('mp4')) { + $this->assertContains('mp4', $response->json('data.formats')); + } + + $this->postJson('/api/v1/exports', [ + 'courseVersionId' => $version->id, + 'format' => 'scorm_2004', + 'confirmWarnings' => true, + 'settings' => ['language' => 'fa', 'trackProgress' => true, 'trackScore' => true, 'freeNavigation' => true], + ])->assertUnprocessable()->assertJsonPath('error.code', 'unsupported_score_tracking'); + + $id = $this->postJson('/api/v1/exports', [ + 'courseVersionId' => $version->id, + 'format' => 'scorm_2004', + 'confirmWarnings' => true, + 'settings' => ['language' => 'en', 'trackProgress' => true, 'trackScore' => false, 'freeNavigation' => true], + ])->assertStatus(202)->json('data.id'); + $this->assertSame('en', json_decode(DB::table('export_jobs')->find($id)->filters, true)['language']); + $this->postJson('/api/v1/exports', ['courseVersionId' => $version->id, 'format' => 'scorm_2004', 'confirmWarnings' => true]) + ->assertStatus(409) + ->assertJsonPath('error.code', 'duplicate_export_job'); + } + + public function test_certificate_is_idempotent_downloadable_verifiable_and_revocable(): void + { + $this->seed(DatabaseSeeder::class); + config(['exports.disk' => 'local', 'app.frontend_url' => 'https://learning.example.test']); + Storage::fake('local'); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + $learner = User::query()->where('email', 'amir@microlearn.test')->firstOrFail(); + $version = CourseVersion::query()->where('organization_id', $designer->organization_id)->where('status', 'published')->firstOrFail(); + Sanctum::actingAs($designer); + + $templateId = $this->postJson('/api/v1/certificate-templates', [ + 'name' => 'قالب رسمی', + 'isDefault' => true, + 'canvas' => ['schemaVersion' => 1, 'primaryColor' => '#5b3fd3'], + ])->assertCreated()->json('data.id'); + + $certificateId = $this->postJson('/api/v1/certificates/issue', [ + 'userId' => $learner->id, + 'courseVersionId' => $version->id, + 'templateId' => $templateId, + ])->assertCreated()->json('data.id'); + + $duplicateId = $this->postJson('/api/v1/certificates/issue', [ + 'userId' => $learner->id, + 'courseVersionId' => $version->id, + ])->assertCreated()->json('data.id'); + $this->assertSame($certificateId, $duplicateId); + $this->assertSame(1, DB::table('certificates')->count()); + + $certificate = DB::table('certificates')->find($certificateId); + Storage::disk('local')->assertExists($certificate->path); + $this->get('/api/v1/certificates/'.$certificateId.'/download')->assertOk(); + + Sanctum::actingAs($learner); + $this->getJson('/api/v1/certificates/verify/'.$certificate->verification_code) + ->assertOk() + ->assertJsonPath('data.status', 'valid') + ->assertJsonPath('data.learnerName', $learner->name); + + DB::table('certificates')->where('id', $certificateId)->update(['expires_at' => now()->subMinute()]); + $this->getJson('/api/v1/certificates/verify/'.$certificate->verification_code) + ->assertOk() + ->assertJsonPath('data.status', 'expired'); + + Sanctum::actingAs($designer); + $this->postJson('/api/v1/certificates/'.$certificateId.'/revoke', ['reason' => 'صدور آزمایشی']) + ->assertOk() + ->assertJsonPath('data.revoked', true); + $this->getJson('/api/v1/certificates/verify/'.$certificate->verification_code) + ->assertOk() + ->assertJsonPath('data.status', 'revoked') + ->assertJsonPath('data.revocationReason', 'صدور آزمایشی'); + } + + public function test_tenant_roles_cannot_cross_export_or_certificate_boundaries(): void + { + $this->seed(DatabaseSeeder::class); + $learner = User::query()->where('email', 'maryam@microlearn.test')->firstOrFail(); + Sanctum::actingAs($learner); + $this->getJson('/api/v1/exports')->assertForbidden(); + $this->getJson('/api/v1/certificates')->assertForbidden(); + } +} diff --git a/backend/tests/Feature/Product/OrganizationSettingsTest.php b/backend/tests/Feature/Product/OrganizationSettingsTest.php new file mode 100644 index 0000000..150b508 --- /dev/null +++ b/backend/tests/Feature/Product/OrganizationSettingsTest.php @@ -0,0 +1,45 @@ +seed(DatabaseSeeder::class); + $designer = User::query()->where('email', 'designer@microlearn.test')->firstOrFail(); + Sanctum::actingAs($designer); + + $settings = $this->getJson('/api/v1/organization-settings') + ->assertOk() + ->assertJsonPath('data.settings.general.density', 'standard') + ->assertJsonPath('data.settings.general.focusGuide', true) + ->json('data.settings'); + + $settings['general'] = [ + ...$settings['general'], + 'theme' => 'dark', + 'density' => 'compact', + 'fontScale' => 'large', + 'highContrast' => true, + 'focusGuide' => true, + 'reduceMotion' => true, + ]; + + $this->patchJson('/api/v1/organization-settings', ['settings' => $settings]) + ->assertOk() + ->assertJsonPath('data.settings.general.theme', 'dark') + ->assertJsonPath('data.settings.general.density', 'compact') + ->assertJsonPath('data.settings.general.fontScale', 'large') + ->assertJsonPath('data.settings.general.highContrast', true) + ->assertJsonPath('data.settings.general.reduceMotion', true); + } +} diff --git a/backend/tests/Feature/Product/PlatformGovernanceTest.php b/backend/tests/Feature/Product/PlatformGovernanceTest.php new file mode 100644 index 0000000..9b4bdf8 --- /dev/null +++ b/backend/tests/Feature/Product/PlatformGovernanceTest.php @@ -0,0 +1,32 @@ +create(['role' => UserRole::SuperAdmin]); + Sanctum::actingAs($admin); + DB::table('audit_logs')->insert(['id' => (string) str()->ulid(), 'actor_id' => $admin->id, 'action' => 'platform.test', 'metadata' => json_encode(['token' => 'secret']), 'created_at' => now()]); + + $this->getJson('/api/v1/platform/audit')->assertOk()->assertJsonPath('data.meta.total', 1)->assertJsonPath('data.items.0.metadata.token', '[REDACTED]'); + $this->getJson('/api/v1/platform/administrators')->assertOk()->assertJsonPath('data.items.0.email', $admin->email); + } + + public function test_tenant_user_cannot_read_platform_governance(): void + { + Sanctum::actingAs(User::factory()->create(['role' => UserRole::CourseDesigner])); + $this->getJson('/api/v1/platform/audit')->assertForbidden(); + $this->getJson('/api/v1/platform/administrators')->assertForbidden(); + } +} diff --git a/backend/tests/Feature/Product/PlatformOperationsTest.php b/backend/tests/Feature/Product/PlatformOperationsTest.php new file mode 100644 index 0000000..a414e5b --- /dev/null +++ b/backend/tests/Feature/Product/PlatformOperationsTest.php @@ -0,0 +1,35 @@ +create(['role' => UserRole::SuperAdmin])); + + foreach (['usage', 'storage', 'ai', 'health', 'jobs', 'backups'] as $surface) { + $response = $this->getJson('/api/v1/platform/'.$surface)->assertOk(); + $this->assertArrayHasKey('data', $response->json()); + $this->assertStringNotContainsString('apiKey', json_encode($response->json())); + $this->assertStringNotContainsString('password', json_encode($response->json())); + } + } + + public function test_tenant_user_cannot_read_platform_operations(): void + { + Sanctum::actingAs(User::factory()->create(['role' => UserRole::CourseDesigner])); + + foreach (['usage', 'storage', 'ai', 'health', 'jobs', 'backups'] as $surface) { + $this->getJson('/api/v1/platform/'.$surface)->assertForbidden(); + } + } +} diff --git a/backend/tests/Feature/Product/ProductCompletionTest.php b/backend/tests/Feature/Product/ProductCompletionTest.php new file mode 100644 index 0000000..5ee622c --- /dev/null +++ b/backend/tests/Feature/Product/ProductCompletionTest.php @@ -0,0 +1,84 @@ + 'sync']); + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + + $jobId = $this->postJson('/api/v1/ai-studio/jobs', ['topic' => 'ایمنی محیط کار', 'objective' => 'کاهش خطای انسانی', 'lessonCount' => 2, 'language' => 'fa']) + ->assertStatus(202)->json('data.id'); + $job = $this->getJson('/api/v1/ai-studio/jobs/'.$jobId)->assertOk()->assertJsonPath('data.status', 'completed'); + $suggestionId = $job->json('data.suggestion.id'); + $courseId = $this->postJson('/api/v1/ai-studio/suggestions/'.$suggestionId.'/accept')->assertCreated()->assertJsonPath('data.status', 'draft')->json('data.courseId'); + + $this->assertDatabaseHas('courses', ['id' => $courseId, 'status' => 'draft']); + $this->assertDatabaseHas('course_versions', ['course_id' => $courseId, 'status' => 'draft']); + $this->assertDatabaseHas('ai_suggestions', ['id' => $suggestionId, 'status' => 'accepted', 'entity_id' => $courseId]); + } + + public function test_role_preferences_are_scoped_and_notifications_can_be_dismissed(): void + { + $learner = User::factory()->create(['role' => UserRole::Learner]); + Sanctum::actingAs($learner); + $this->getJson('/api/v1/preferences')->assertOk()->assertJsonPath('data.role', 'learner')->assertJsonPath('data.preferences.dailyReminder', true); + $this->patchJson('/api/v1/preferences', ['preferences' => ['dailyReminder' => false, 'operationsDigest' => true]])->assertOk()->assertJsonPath('data.preferences.dailyReminder', false)->assertJsonMissingPath('data.preferences.operationsDigest'); + + $id = (string) str()->ulid(); + DB::table('in_app_notifications')->insert(['id' => $id, 'organization_id' => $learner->organization_id, 'recipient_id' => $learner->id, 'type' => 'test', 'title' => 'اعلان', 'body' => 'متن', 'target_url' => '/learn/home', 'entity_type' => 'test', 'data' => json_encode([]), 'created_at' => now(), 'updated_at' => now()]); + $this->deleteJson('/api/v1/notifications/'.$id)->assertOk(); + $this->getJson('/api/v1/notifications')->assertOk()->assertJsonPath('data.items', []); + } + + public function test_super_admin_reads_platform_operations_but_tenant_role_cannot(): void + { + $admin = User::factory()->create(['organization_id' => null, 'role' => UserRole::SuperAdmin]); + Sanctum::actingAs($admin); + $this->getJson('/api/v1/platform/overview')->assertOk()->assertJsonStructure(['data' => ['organizations', 'users', 'storageBytes', 'aiJobs', 'system']]); + $this->getJson('/api/v1/platform/overview')->assertJsonStructure(['data' => ['dashboard' => ['kpis', 'attention', 'health', 'activity', 'lastUpdatedAt']]]); + $this->getJson('/api/v1/platform/settings')->assertOk()->assertJsonStructure(['data' => ['externalAiEnabled', 'defaultStorageQuotaGb']]); + $this->patchJson('/api/v1/platform/settings', ['settings' => ['externalAiEnabled' => false, 'defaultStorageQuotaGb' => 24, 'maintenanceBanner' => null]]) + ->assertOk() + ->assertJsonPath('data.defaultStorageQuotaGb', 24); + + Sanctum::actingAs(User::factory()->create(['role' => UserRole::Manager])); + $this->getJson('/api/v1/platform/overview')->assertForbidden(); + $this->getJson('/api/v1/platform/settings')->assertForbidden(); + $this->patchJson('/api/v1/platform/settings', ['settings' => ['externalAiEnabled' => true]])->assertForbidden(); + } + + public function test_ai_quota_is_enforced_and_template_creates_canonical_draft(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + Subscription::create(['organization_id' => $designer->organization_id, 'plan_key' => 'test', 'status' => 'active', 'starts_at' => now()->subDay(), 'ai_credit_quota' => 1, 'ai_credits_used' => 1]); + Sanctum::actingAs($designer); + $this->postJson('/api/v1/ai-studio/jobs', ['topic' => 'موضوع'])->assertUnprocessable(); + + $templateId = $this->postJson('/api/v1/templates', ['title' => 'قالب عملیات', 'category' => 'custom', 'modules' => [['title' => 'ماژول', 'lessons' => ['درس']]]])->assertCreated()->json('data.id'); + $this->patchJson('/api/v1/templates/'.$templateId, ['title' => 'قالب عملیات به‌روز', 'description' => 'ساختار استاندارد', 'category' => 'compliance', 'modules' => [['title' => 'ماژول جدید', 'lessons' => ['درس جدید']]]])->assertOk(); + $courseId = $this->postJson('/api/v1/templates/'.$templateId.'/instantiate', ['title' => 'دوره سفارشی عملیات'])->assertCreated()->json('data.courseId'); + $this->getJson('/api/v1/templates') + ->assertOk() + ->assertJsonPath('data.0.id', $templateId) + ->assertJsonPath('data.0.status', 'active') + ->assertJsonPath('data.0.level', 'intermediate') + ->assertJsonPath('data.0.usageCount', 1) + ->assertJsonPath('data.0.creatorName', $designer->name); + $this->assertDatabaseHas('courses', ['id' => $courseId, 'title' => 'دوره سفارشی عملیات', 'status' => 'draft']); + $this->assertDatabaseHas('lessons', ['title' => 'درس جدید']); + } +} diff --git a/backend/tests/Feature/Subscriptions/PlatformPlanTest.php b/backend/tests/Feature/Subscriptions/PlatformPlanTest.php new file mode 100644 index 0000000..e3fe005 --- /dev/null +++ b/backend/tests/Feature/Subscriptions/PlatformPlanTest.php @@ -0,0 +1,40 @@ +create(['role' => UserRole::SuperAdmin])); + $plan = $this->postJson('/api/v1/platform/plans', ['key' => 'growth', 'name' => 'Growth', 'description' => 'برای سازمان‌های در حال رشد', 'seatLimit' => 100, 'storageQuotaBytes' => 500000, 'aiCreditQuota' => 2000, 'enabledFeatures' => ['reports']])->assertCreated()->assertJsonPath('data.key', 'growth')->assertJsonPath('data.seatLimit', 100)->json('data.id'); + $this->getJson('/api/v1/platform/plans')->assertOk()->assertJsonPath('data.0.id', $plan)->assertJsonPath('data.0.enabledFeatures.0', 'reports'); + $this->assertDatabaseHas('audit_logs', ['action' => 'plan.created', 'entity_id' => $plan]); + } + + public function test_tenant_user_cannot_manage_plans(): void + { + Sanctum::actingAs(User::factory()->create(['role' => UserRole::CourseDesigner])); + $this->getJson('/api/v1/platform/plans')->assertForbidden(); + $this->postJson('/api/v1/platform/plans', ['key' => 'starter', 'name' => 'Starter'])->assertForbidden(); + } + + public function test_super_admin_can_assign_plan_to_organization_and_audit_it(): void + { + Sanctum::actingAs(User::factory()->create(['role' => UserRole::SuperAdmin])); + $organization = Organization::factory()->create(); + $planId = $this->postJson('/api/v1/platform/plans', ['key' => 'pro', 'name' => 'Pro'])->json('data.id'); + $this->patchJson('/api/v1/platform/organizations/'.$organization->id.'/plan', ['planId' => $planId])->assertOk()->assertJsonPath('data.planKey', 'pro'); + $this->assertDatabaseHas('subscriptions', ['organization_id' => $organization->id, 'plan_key' => 'pro']); + $this->assertDatabaseHas('audit_logs', ['action' => 'organization.plan_assigned', 'organization_id' => $organization->id]); + } +} diff --git a/backend/tests/Feature/Subscriptions/SubscriptionAuthorizationTest.php b/backend/tests/Feature/Subscriptions/SubscriptionAuthorizationTest.php new file mode 100644 index 0000000..50441d3 --- /dev/null +++ b/backend/tests/Feature/Subscriptions/SubscriptionAuthorizationTest.php @@ -0,0 +1,93 @@ +create(['role' => UserRole::CourseDesigner]); + $current = Subscription::query()->create([ + 'organization_id' => $designer->organization_id, + 'plan_key' => 'enterprise', + 'status' => 'active', + 'starts_at' => now()->subDay(), + 'seat_limit' => 250, + 'storage_quota_bytes' => 1000, + 'storage_used_bytes' => 400, + 'ai_credit_quota' => 500, + 'ai_credits_used' => 125, + 'enabled_features' => ['taxonomy', 'exports'], + ]); + $otherDesigner = User::factory()->create(['role' => UserRole::CourseDesigner]); + Subscription::query()->create([ + 'organization_id' => $otherDesigner->organization_id, + 'plan_key' => 'private-plan', + 'status' => 'active', + 'starts_at' => now()->subDay(), + ]); + Sanctum::actingAs($designer); + + $this->getJson('/api/v1/subscription?organization_id='.$otherDesigner->organization_id) + ->assertOk() + ->assertJsonPath('data.id', $current->getKey()) + ->assertJsonPath('data.seatLimit', 250) + ->assertJsonPath('data.seatUsed', 1) + ->assertJsonPath('data.storageUsedBytes', 400) + ->assertJsonPath('data.aiCreditsUsed', 125) + ->assertJsonPath('data.enabledFeatures.0', 'taxonomy') + ->assertJsonMissing(['planKey' => 'private-plan']); + } + + public function test_manager_cannot_view_subscription_configuration(): void + { + $manager = User::factory()->create(['role' => UserRole::Manager]); + Sanctum::actingAs($manager); + + $this->getJson('/api/v1/subscription')->assertForbidden(); + } + + public function test_super_admin_can_list_create_and_update_platform_subscriptions_with_audit(): void + { + $organization = Organization::factory()->create(['name' => 'Acme Platform']); + Sanctum::actingAs(User::factory()->create(['organization_id' => null, 'role' => UserRole::SuperAdmin])); + + $id = $this->postJson('/api/v1/platform/subscriptions', [ + 'organizationId' => $organization->getKey(), 'planKey' => 'enterprise', 'status' => 'active', + 'startsAt' => now()->subDay()->toISOString(), 'expiresAt' => now()->addMonth()->toISOString(), + 'seatLimit' => 100, 'storageQuotaBytes' => 100000, 'aiCreditQuota' => 2000, 'enabledFeatures' => ['exports', 'taxonomy'], + ])->assertCreated()->assertJsonPath('data.organization.name', 'Acme Platform')->json('data.id'); + + $this->getJson('/api/v1/platform/subscriptions?search=acme&status=active&plan=enterprise&perPage=10') + ->assertOk()->assertJsonPath('meta.total', 1)->assertJsonPath('data.0.id', $id)->assertJsonPath('data.0.seatLimit', 100); + + $this->patchJson('/api/v1/platform/subscriptions/'.$id, ['planKey' => 'enterprise-plus', 'seatLimit' => 150, 'expiresAt' => null, 'status' => 'suspended']) + ->assertOk()->assertJsonPath('data.planKey', 'enterprise-plus')->assertJsonPath('data.status', 'suspended')->assertJsonPath('data.seatLimit', 150); + $this->assertDatabaseHas('subscriptions', ['id' => $id, 'expires_at' => null]); + + $this->assertDatabaseHas('audit_logs', ['organization_id' => $organization->getKey(), 'action' => 'subscription.created', 'entity_id' => $id]); + $this->assertDatabaseHas('audit_logs', ['organization_id' => $organization->getKey(), 'action' => 'subscription.updated', 'entity_id' => $id]); + } + + public function test_tenant_roles_and_unsupported_deployments_cannot_manage_platform_subscriptions(): void + { + Sanctum::actingAs(User::factory()->create(['role' => UserRole::CourseDesigner])); + $this->getJson('/api/v1/platform/subscriptions')->assertForbidden(); + + config()->set('deployment.mode', 'on_premise'); + $this->app->forgetInstance(DeploymentCapabilities::class); + Sanctum::actingAs(User::factory()->create(['organization_id' => null, 'role' => UserRole::SuperAdmin])); + $this->getJson('/api/v1/platform/subscriptions')->assertNotFound(); + } +} diff --git a/backend/tests/Feature/Taxonomy/ContentMappingTest.php b/backend/tests/Feature/Taxonomy/ContentMappingTest.php new file mode 100644 index 0000000..036c2c9 --- /dev/null +++ b/backend/tests/Feature/Taxonomy/ContentMappingTest.php @@ -0,0 +1,176 @@ +foundation(); + Sanctum::actingAs($designer); + + $this->postJson('/api/v1/content-mappings', [ + 'courseVersionId' => $version->getKey(), + 'mappableType' => 'lesson', + 'mappableId' => $lesson->getKey(), + 'taxonomyNodeId' => $node->getKey(), + 'mappingType' => 'develops', + 'masteryLevel' => 'applied', + 'weight' => 0.7, + 'source' => 'manual', + ])->assertCreated() + ->assertJsonPath('data.weight', 0.7) + ->assertJsonPath('data.masteryLevel', 'applied') + ->assertJsonPath('data.confirmationStatus', 'confirmed') + ->assertJsonPath('data.direct', true); + } + + public function test_ai_mapping_stays_draft_until_designer_confirmation(): void + { + [$designer, $version, $lesson, $node] = $this->foundation(); + Sanctum::actingAs($designer); + + $this->postJson('/api/v1/content-mappings', [ + 'courseVersionId' => $version->getKey(), + 'mappableType' => 'lesson', + 'mappableId' => $lesson->getKey(), + 'taxonomyNodeId' => $node->getKey(), + 'mappingType' => 'related', + 'weight' => 0.5, + 'source' => 'ai_suggested', + 'confidence' => 0.82, + ])->assertCreated() + ->assertJsonPath('data.confirmationStatus', 'draft') + ->assertJsonPath('data.confidence', 0.82); + } + + public function test_designer_can_remove_draft_mapping_but_not_published_mapping(): void + { + [$designer, $version, $lesson, $node] = $this->foundation(); + Sanctum::actingAs($designer); + $payload = ['courseVersionId' => $version->getKey(), 'mappableType' => 'lesson', 'mappableId' => $lesson->getKey(), 'taxonomyNodeId' => $node->getKey(), 'mappingType' => 'develops', 'weight' => 1, 'source' => 'manual']; + $mapping = $this->postJson('/api/v1/content-mappings', $payload)->assertCreated()->json('data.id'); + $this->deleteJson("/api/v1/content-mappings/{$mapping}")->assertNoContent(); + + $mapping = $this->postJson('/api/v1/content-mappings', $payload)->assertCreated()->json('data.id'); + $version->update(['status' => CourseVersionStatus::Published, 'published_at' => now()]); + $this->deleteJson("/api/v1/content-mappings/{$mapping}")->assertUnprocessable()->assertJsonValidationErrors('courseVersionId'); + } + + public function test_mapping_target_must_belong_to_selected_course_version(): void + { + [$designer, $version, , $node] = $this->foundation(); + [, , $otherLesson] = $this->courseStructure($designer, 2); + Sanctum::actingAs($designer); + + $this->postJson('/api/v1/content-mappings', [ + 'courseVersionId' => $version->getKey(), + 'mappableType' => 'lesson', + 'mappableId' => $otherLesson->getKey(), + 'taxonomyNodeId' => $node->getKey(), + 'mappingType' => 'develops', + 'weight' => 1, + 'source' => 'manual', + ])->assertUnprocessable()->assertJsonValidationErrors('mappableId'); + } + + public function test_published_version_and_its_existing_mappings_are_immutable(): void + { + [$designer, $version, $lesson, $node] = $this->foundation(); + Sanctum::actingAs($designer); + $mappingId = $this->postJson('/api/v1/content-mappings', [ + 'courseVersionId' => $version->getKey(), + 'mappableType' => 'lesson', + 'mappableId' => $lesson->getKey(), + 'taxonomyNodeId' => $node->getKey(), + 'mappingType' => 'develops', + 'weight' => 1, + 'source' => 'manual', + ])->assertCreated()->json('data.id'); + + $version->update(['status' => CourseVersionStatus::Published, 'published_at' => now()]); + + $this->postJson('/api/v1/content-mappings', [ + 'courseVersionId' => $version->getKey(), + 'mappableType' => 'lesson', + 'mappableId' => $lesson->getKey(), + 'taxonomyNodeId' => $node->getKey(), + 'mappingType' => 'practices', + 'weight' => 1, + 'source' => 'manual', + ])->assertUnprocessable()->assertJsonValidationErrors('courseVersionId'); + + $this->expectException(DomainException::class); + ContentTaxonomyMapping::query()->findOrFail($mappingId)->update(['weight' => 0.5]); + } + + private function foundation(): array + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + [$course, $version, $lesson] = $this->courseStructure($designer, 1); + $type = TaxonomyType::query()->create([ + 'organization_id' => $designer->organization_id, + 'key' => 'skill', + 'name' => 'Skill', + 'status' => TaxonomyStatus::Active, + ]); + $node = TaxonomyNode::query()->create([ + 'organization_id' => $designer->organization_id, + 'taxonomy_type_id' => $type->getKey(), + 'name' => 'Giving feedback', + 'status' => TaxonomyStatus::Active, + ]); + + return [$designer, $version, $lesson, $node, $course]; + } + + private function courseStructure(User $designer, int $versionNumber): array + { + $course = Course::query()->create([ + 'organization_id' => $designer->organization_id, + 'title' => 'Leadership', + 'slug' => 'leadership-'.$versionNumber, + 'created_by' => $designer->getKey(), + ]); + $version = CourseVersion::query()->create([ + 'organization_id' => $designer->organization_id, + 'course_id' => $course->getKey(), + 'version_number' => $versionNumber, + 'status' => CourseVersionStatus::Draft, + 'title' => 'Leadership', + ]); + $module = CourseModule::query()->create([ + 'organization_id' => $designer->organization_id, + 'course_version_id' => $version->getKey(), + 'title' => 'Communication', + 'position' => 1, + ]); + $lesson = Lesson::query()->create([ + 'organization_id' => $designer->organization_id, + 'course_version_id' => $version->getKey(), + 'course_module_id' => $module->getKey(), + 'title' => 'Feedback', + 'position' => 1, + ]); + + return [$course, $version, $lesson]; + } +} diff --git a/backend/tests/Feature/Taxonomy/TaxonomyApiTest.php b/backend/tests/Feature/Taxonomy/TaxonomyApiTest.php new file mode 100644 index 0000000..ec8ac10 --- /dev/null +++ b/backend/tests/Feature/Taxonomy/TaxonomyApiTest.php @@ -0,0 +1,121 @@ +create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($designer); + + $typeId = $this->postJson('/api/v1/taxonomy-types', [ + 'key' => 'skill', + 'name' => 'مهارت', + ])->assertCreated()->json('data.id'); + + $parentId = $this->postJson('/api/v1/taxonomy-nodes', [ + 'taxonomyTypeId' => $typeId, + 'name' => 'ارتباطات', + 'code' => 'COMMUNICATION', + ])->assertCreated()->json('data.id'); + + $this->postJson('/api/v1/taxonomy-nodes', [ + 'taxonomyTypeId' => $typeId, + 'parentId' => $parentId, + 'name' => 'بازخورد مؤثر', + 'code' => 'GIVING_FEEDBACK', + ])->assertCreated()->assertJsonPath('data.parentId', $parentId); + } + + public function test_circular_hierarchy_is_rejected(): void + { + [$designer, $type] = $this->designerAndType(); + $root = $this->node($designer->organization_id, $type->getKey(), 'Leadership'); + $child = $this->node($designer->organization_id, $type->getKey(), 'Coaching', $root->getKey()); + Sanctum::actingAs($designer); + + $this->patchJson('/api/v1/taxonomy-nodes/'.$root->getKey(), ['parentId' => $child->getKey()]) + ->assertUnprocessable() + ->assertJsonValidationErrors('parentId'); + } + + public function test_taxonomy_listing_is_tenant_isolated_even_with_client_organization_input(): void + { + [$designer, $type] = $this->designerAndType(); + $ownNode = $this->node($designer->organization_id, $type->getKey(), 'Own skill'); + $otherOrganization = Organization::factory()->create(); + $otherType = TaxonomyType::query()->create([ + 'organization_id' => $otherOrganization->getKey(), + 'key' => 'skill', + 'name' => 'Skill', + 'status' => TaxonomyStatus::Active, + ]); + $otherNode = $this->node($otherOrganization->getKey(), $otherType->getKey(), 'Private skill'); + Sanctum::actingAs($designer); + + $response = $this->getJson('/api/v1/taxonomy-nodes?organization_id='.$otherOrganization->getKey())->assertOk(); + $response->assertJsonFragment(['id' => $ownNode->getKey()]); + $response->assertJsonMissing(['id' => $otherNode->getKey()]); + } + + public function test_manager_may_read_but_cannot_manage_taxonomy(): void + { + $manager = User::factory()->create(['role' => UserRole::Manager]); + Sanctum::actingAs($manager); + + $this->getJson('/api/v1/taxonomy-types')->assertOk(); + $this->postJson('/api/v1/taxonomy-types', ['key' => 'skill', 'name' => 'Skill'])->assertForbidden(); + } + + public function test_archived_node_remains_available_for_history(): void + { + [$designer, $type] = $this->designerAndType(); + $node = $this->node($designer->organization_id, $type->getKey(), 'Historical skill'); + Sanctum::actingAs($designer); + + $this->patchJson('/api/v1/taxonomy-nodes/'.$node->getKey(), ['status' => 'archived']) + ->assertOk() + ->assertJsonPath('data.status', 'archived'); + + $this->getJson('/api/v1/taxonomy-nodes?status=archived') + ->assertOk() + ->assertJsonFragment(['id' => $node->getKey(), 'status' => 'archived']); + } + + private function designerAndType(): array + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $type = TaxonomyType::query()->create([ + 'organization_id' => $designer->organization_id, + 'key' => 'skill', + 'name' => 'Skill', + 'status' => TaxonomyStatus::Active, + ]); + + return [$designer, $type]; + } + + private function node(string $organizationId, string $typeId, string $name, ?string $parentId = null): TaxonomyNode + { + return TaxonomyNode::query()->create([ + 'organization_id' => $organizationId, + 'taxonomy_type_id' => $typeId, + 'parent_id' => $parentId, + 'name' => $name, + 'status' => TaxonomyStatus::Active, + ]); + } +} diff --git a/backend/tests/Feature/Taxonomy/TaxonomyWorkspaceTest.php b/backend/tests/Feature/Taxonomy/TaxonomyWorkspaceTest.php new file mode 100644 index 0000000..6f6bc4e --- /dev/null +++ b/backend/tests/Feature/Taxonomy/TaxonomyWorkspaceTest.php @@ -0,0 +1,71 @@ +create(['role' => UserRole::CourseDesigner]); + $type = TaxonomyType::query()->create([ + 'organization_id' => $designer->organization_id, + 'key' => 'skill', + 'name' => 'مهارت', + 'status' => TaxonomyStatus::Active, + ]); + $node = TaxonomyNode::query()->create([ + 'organization_id' => $designer->organization_id, + 'taxonomy_type_id' => $type->getKey(), + 'name' => 'حل مسئله', + 'status' => TaxonomyStatus::Active, + ]); + Sanctum::actingAs($designer); + + $frameworkId = $this->postJson('/api/v1/competency-frameworks', [ + 'name' => 'مدیران خط اول', + 'description' => 'انتظارات نقش مدیریتی', + 'audienceType' => 'organization', + 'audienceValue' => null, + ])->assertCreated()->assertJsonPath('data.name', 'مدیران خط اول')->json('data.id'); + + $this->putJson('/api/v1/competency-frameworks/'.$frameworkId.'/items', [ + 'items' => [[ + 'taxonomyNodeId' => $node->getKey(), + 'masteryLevel' => 'advanced', + 'required' => true, + ]], + ])->assertOk() + ->assertJsonPath('data.items.0.name', 'حل مسئله') + ->assertJsonPath('data.items.0.masteryLevel', 'advanced'); + + $this->getJson('/api/v1/taxonomy-workspace')->assertOk() + ->assertJsonPath('data.overview.active', 1) + ->assertJsonPath('data.overview.uncovered', 1) + ->assertJsonPath('data.frameworks.0.id', $frameworkId) + ->assertJsonPath('data.coverage.0.nodeId', $node->getKey()) + ->assertJsonPath('data.coverage.0.courseCount', 0); + } + + public function test_manager_can_view_workspace_but_cannot_mutate_frameworks(): void + { + $manager = User::factory()->create(['role' => UserRole::Manager]); + Sanctum::actingAs($manager); + + $this->getJson('/api/v1/taxonomy-workspace')->assertOk(); + $this->postJson('/api/v1/competency-frameworks', [ + 'name' => 'Restricted', + 'audienceType' => 'organization', + ])->assertForbidden(); + } +} diff --git a/backend/tests/Feature/Teams/TeamAuthorizationTest.php b/backend/tests/Feature/Teams/TeamAuthorizationTest.php new file mode 100644 index 0000000..83fabaf --- /dev/null +++ b/backend/tests/Feature/Teams/TeamAuthorizationTest.php @@ -0,0 +1,149 @@ +create(['role' => UserRole::Manager]); + $otherManager = User::factory()->for($manager->organization)->create(['role' => UserRole::Manager]); + $ownLearner = User::factory()->for($manager->organization)->create(['role' => UserRole::Learner]); + $privateLearner = User::factory()->for($manager->organization)->create(['role' => UserRole::Learner]); + $ownTeam = $this->team($manager, 'Own team'); + $privateTeam = $this->team($manager, 'Private team'); + $ownTeam->managers()->attach($manager); + $ownTeam->members()->attach($ownLearner); + $privateTeam->managers()->attach($otherManager); + $privateTeam->members()->attach($privateLearner); + Sanctum::actingAs($manager); + + $response = $this->getJson('/api/v1/users')->assertOk(); + $response->assertJsonFragment(['id' => $ownLearner->getKey()]); + $response->assertJsonMissing(['id' => $privateLearner->getKey()]); + + $teams = $this->getJson('/api/v1/teams')->assertOk(); + $teams->assertJsonFragment(['id' => $ownTeam->getKey()]); + $teams->assertJsonMissing(['id' => $privateTeam->getKey()]); + } + + public function test_designer_can_create_team_and_attach_same_tenant_member_and_manager(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); + $learner = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); + Sanctum::actingAs($designer); + + $teamId = $this->postJson('/api/v1/teams', ['name' => 'Sales'])->assertCreated()->json('data.id'); + $this->putJson('/api/v1/teams/'.$teamId.'/members', ['userId' => $learner->getKey()])->assertOk(); + $this->putJson('/api/v1/teams/'.$teamId.'/managers', ['userId' => $manager->getKey()])->assertOk(); + + $this->assertDatabaseHas('team_memberships', ['team_id' => $teamId, 'user_id' => $learner->getKey()]); + $this->assertDatabaseHas('team_managers', ['team_id' => $teamId, 'user_id' => $manager->getKey()]); + } + + public function test_selecting_manager_automatically_attaches_active_direct_reports(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); + $report = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner, 'direct_manager_id' => $manager->getKey()]); + $other = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); + Sanctum::actingAs($designer); + + $teamId = $this->postJson('/api/v1/teams', ['name' => 'Automatic', 'managerId' => $manager->getKey()]) + ->assertCreated() + ->assertJsonPath('data.managerCount', 1) + ->assertJsonPath('data.memberCount', 1) + ->json('data.id'); + + $this->assertDatabaseHas('team_managers', ['team_id' => $teamId, 'user_id' => $manager->getKey()]); + $this->assertDatabaseHas('team_memberships', ['team_id' => $teamId, 'user_id' => $report->getKey()]); + $this->assertDatabaseMissing('team_memberships', ['team_id' => $teamId, 'user_id' => $other->getKey()]); + } + + public function test_manager_cannot_mutate_team_membership(): void + { + $manager = User::factory()->create(['role' => UserRole::Manager]); + $learner = User::factory()->for($manager->organization)->create(); + $team = $this->team($manager, 'Managed team'); + $team->managers()->attach($manager); + Sanctum::actingAs($manager); + + $this->putJson('/api/v1/teams/'.$team->getKey().'/members', ['userId' => $learner->getKey()])->assertForbidden(); + } + + public function test_cross_tenant_user_cannot_be_attached_to_team(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $otherUser = User::factory()->create(); + $team = $this->team($designer, 'Local team'); + Sanctum::actingAs($designer); + + $this->putJson('/api/v1/teams/'.$team->getKey().'/members', ['userId' => $otherUser->getKey()])->assertNotFound(); + } + + public function test_designer_can_read_update_and_detach_team_assignments(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $manager = User::factory()->for($designer->organization)->create(['role' => UserRole::Manager]); + $learner = User::factory()->for($designer->organization)->create(['role' => UserRole::Learner]); + $team = $this->team($designer, 'Support'); + $team->managers()->attach($manager); + $team->members()->attach($learner); + Sanctum::actingAs($designer); + + $this->getJson('/api/v1/teams/'.$team->getKey()) + ->assertOk() + ->assertJsonPath('data.memberCount', 1) + ->assertJsonPath('data.managerCount', 1) + ->assertJsonPath('data.members.0.id', $learner->getKey()) + ->assertJsonMissing(['pivot']); + $this->patchJson('/api/v1/teams/'.$team->getKey(), ['name' => 'Support', 'description' => 'Updated']) + ->assertOk() + ->assertJsonPath('data.description', 'Updated'); + $this->deleteJson('/api/v1/teams/'.$team->getKey().'/members/'.$learner->getKey())->assertOk(); + $this->deleteJson('/api/v1/teams/'.$team->getKey().'/managers/'.$manager->getKey())->assertOk(); + $this->assertDatabaseMissing('team_memberships', ['team_id' => $team->getKey(), 'user_id' => $learner->getKey()]); + $this->assertDatabaseMissing('team_managers', ['team_id' => $team->getKey(), 'user_id' => $manager->getKey()]); + } + + public function test_manager_cannot_read_an_unmanaged_team_detail(): void + { + $manager = User::factory()->create(['role' => UserRole::Manager]); + $unmanaged = $this->team($manager, 'Unmanaged'); + Sanctum::actingAs($manager); + + $this->getJson('/api/v1/teams/'.$unmanaged->getKey())->assertNotFound(); + } + + public function test_cross_tenant_team_and_detach_targets_are_not_disclosed(): void + { + $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); + $foreignDesigner = User::factory()->create(['role' => UserRole::CourseDesigner]); + $foreignTeam = $this->team($foreignDesigner, 'Foreign'); + $localTeam = $this->team($designer, 'Local'); + Sanctum::actingAs($designer); + + $this->getJson('/api/v1/teams/'.$foreignTeam->getKey())->assertNotFound(); + $this->deleteJson('/api/v1/teams/'.$localTeam->getKey().'/members/'.$foreignDesigner->getKey())->assertNotFound(); + } + + private function team(User $creator, string $name): Team + { + return Team::query()->create([ + 'organization_id' => $creator->organization_id, + 'name' => $name, + 'status' => 'active', + 'created_by' => $creator->getKey(), + ]); + } +} diff --git a/backend/tests/Feature/Tenancy/TenantContextTest.php b/backend/tests/Feature/Tenancy/TenantContextTest.php new file mode 100644 index 0000000..c58f0ab --- /dev/null +++ b/backend/tests/Feature/Tenancy/TenantContextTest.php @@ -0,0 +1,56 @@ +create(); + $otherOrganization = Organization::factory()->create(); + $user = User::factory()->for($organization)->create(['role' => UserRole::CourseDesigner]); + Sanctum::actingAs($user); + + $this->getJson('/api/v1/organization-context?organization_id='.$otherOrganization->getKey()) + ->assertOk() + ->assertJsonPath('data.id', $organization->getKey()) + ->assertJsonMissing(['id' => $otherOrganization->getKey()]); + } + + public function test_platform_super_admin_cannot_enter_tenant_route_implicitly(): void + { + $superAdmin = User::factory()->create([ + 'organization_id' => null, + 'role' => UserRole::SuperAdmin, + ]); + Sanctum::actingAs($superAdmin); + + $this->getJson('/api/v1/organization-context') + ->assertForbidden() + ->assertJsonPath('error.code', 'tenant_context_not_applicable'); + + $this->getJson('/api/v1/notifications') + ->assertForbidden() + ->assertJsonPath('error.code', 'tenant_context_not_applicable'); + } + + public function test_inactive_organization_is_rejected(): void + { + $organization = Organization::factory()->create(['status' => 'suspended']); + $user = User::factory()->for($organization)->create(); + Sanctum::actingAs($user); + + $this->getJson('/api/v1/organization-context') + ->assertForbidden() + ->assertJsonPath('error.code', 'organization_unavailable'); + } +} diff --git a/backend/tests/TestCase.php b/backend/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/backend/tests/TestCase.php @@ -0,0 +1,10 @@ +assertAllowed('ollama', 'local', 'http://127.0.0.1:11434/v1'); + + $this->expectException(InvalidArgumentException::class); + $policy->assertAllowed('ollama', 'local', 'http://169.254.169.254:11434/v1'); + } + + public function test_custom_online_provider_requires_an_exact_allowlisted_host(): void + { + $policy = new AiEndpointPolicy(customProviderHosts: ['allowed.example.test']); + + $policy->assertAllowed('openai_compatible', 'online', 'https://allowed.example.test/v1'); + + $this->expectException(InvalidArgumentException::class); + $policy->assertAllowed('openai_compatible', 'online', 'https://other.example.test/v1'); + } + + public function test_dns_results_fail_closed_if_any_address_is_not_public(): void + { + $policy = new AiEndpointPolicy( + resolver: fn (string $host): array => ['93.184.216.34', '127.0.0.1'], + customProviderHosts: ['allowed.example.test'], + ); + + $this->expectException(InvalidArgumentException::class); + $policy->requestOptions('openai_compatible', 'online', 'https://allowed.example.test/v1'); + } + + public function test_outbound_requests_pin_dns_and_disable_redirects(): void + { + $policy = new AiEndpointPolicy( + resolver: fn (string $host): array => ['93.184.216.34'], + customProviderHosts: ['allowed.example.test'], + ); + + $options = $policy->requestOptions('openai_compatible', 'online', 'https://allowed.example.test/v1'); + + $this->assertFalse($options['allow_redirects']); + $this->assertSame( + ['allowed.example.test:443:93.184.216.34'], + $options['curl'][constant('CURLOPT_RESOLVE')], + ); + } +} diff --git a/backend/tests/Unit/AI/OpenAiCompatibleProviderTest.php b/backend/tests/Unit/AI/OpenAiCompatibleProviderTest.php new file mode 100644 index 0000000..e9972b1 --- /dev/null +++ b/backend/tests/Unit/AI/OpenAiCompatibleProviderTest.php @@ -0,0 +1,91 @@ +assertNull(config('ai.provider')); + $this->assertNull(config('ai.ollama')); + $this->assertIsString(config('ai.openai.base_url')); + $this->assertIsArray(config('ai.openai.models')); + $this->assertArrayHasKey('fast', config('ai.openai.models')); + $this->assertArrayHasKey('balanced', config('ai.openai.models')); + $this->assertArrayHasKey('advanced', config('ai.openai.models')); + $this->assertIsInt(config('ai.openai.timeout')); + } + + public function test_ollama_reachable_and_unavailable_health_states_are_reported(): void + { + Http::fakeSequence() + ->push(['data' => [['id' => 'qwen']]]) + ->push([], 503); + + $this->assertTrue($this->ollama()->health()['connected']); + $this->assertFalse($this->ollama()->health()['connected']); + } + + public function test_invalid_model_fails_without_silent_fallback(): void + { + Http::fake(['*/chat/completions' => Http::response(['error' => 'model not found'], 404)]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('404'); + $this->ollama()->chat('hello'); + } + + public function test_provider_timeout_is_not_silently_swallowed(): void + { + Http::fake(fn () => throw new ConnectionException('timeout')); + + $this->expectException(ConnectionException::class); + $this->ollama()->chat('hello'); + } + + public function test_openai_compatible_assist_and_invalid_credentials_are_handled(): void + { + $provider = $this->online(); + Http::fake(['*/chat/completions' => Http::response(['choices' => [['message' => ['content' => '{"result":"ok"}']]]])]); + $this->assertSame('ok', $provider->assist('rewrite', 'content', [])['proposal']['result']); + + Http::fake(['*/models' => Http::response(['error' => 'unauthorized'], 401)]); + $this->assertFalse($provider->health()['connected']); + } + + private function ollama(): OpenAiCompatibleProvider + { + return new OpenAiCompatibleProvider(new AiEndpointPolicy(customProviderHosts: []), [ + 'id' => 'ollama', + 'provider' => 'ollama', + 'mode' => 'local', + 'base_url' => 'http://127.0.0.1:11434/v1', + 'api_key' => '', + 'default_model' => 'qwen', + 'timeout_seconds' => 10, + ]); + } + + private function online(): OpenAiCompatibleProvider + { + return new OpenAiCompatibleProvider(new AiEndpointPolicy( + resolver: fn (string $host): array => ['93.184.216.34'], + customProviderHosts: ['ai.example.test'], + ), [ + 'id' => 'online', + 'provider' => 'openai_compatible', + 'mode' => 'online', + 'base_url' => 'https://ai.example.test/v1', + 'api_key' => 'secret', + 'default_model' => 'model-a', + 'timeout_seconds' => 10, + ]); + } +} diff --git a/backend/tests/Unit/Courses/BlockDefinitionTest.php b/backend/tests/Unit/Courses/BlockDefinitionTest.php new file mode 100644 index 0000000..932b327 --- /dev/null +++ b/backend/tests/Unit/Courses/BlockDefinitionTest.php @@ -0,0 +1,44 @@ + ''], + ['text' => ['required', 'string']], + migrations: [ + 1 => fn (array $data): array => ['text' => $data['title']], + 2 => fn (array $data): array => [...$data, 'format' => 'plain'], + ], + ); + $persisted = ['title' => 'Original']; + + $result = $definition->migrate(1, $persisted); + + $this->assertSame(['version' => 3, 'data' => ['text' => 'Original', 'format' => 'plain']], $result); + $this->assertSame(['title' => 'Original'], $persisted); + } + + #[Test] + public function missing_schema_migration_fails_loudly(): void + { + $definition = new BlockDefinition('example', 'basic', 'Example', 'square', 2, [], []); + + $this->expectException(LogicException::class); + $definition->migrate(1, []); + } +} diff --git a/backend/tests/Unit/ExampleTest.php b/backend/tests/Unit/ExampleTest.php new file mode 100644 index 0000000..5773b0c --- /dev/null +++ b/backend/tests/Unit/ExampleTest.php @@ -0,0 +1,16 @@ +assertTrue(true); + } +} diff --git a/backend/tests/Unit/Identity/RolePermissionsTest.php b/backend/tests/Unit/Identity/RolePermissionsTest.php new file mode 100644 index 0000000..3dfcf7e --- /dev/null +++ b/backend/tests/Unit/Identity/RolePermissionsTest.php @@ -0,0 +1,36 @@ +assertSame($expected, (new RolePermissions)->allows($role, $permission)); + } + + public static function permissionCases(): array + { + return [ + 'designer authors' => [UserRole::CourseDesigner, Permission::CoursesAuthor, true], + 'designer assigns' => [UserRole::CourseDesigner, Permission::AssignmentsManage, true], + 'designer manages users' => [UserRole::CourseDesigner, Permission::UsersManage, true], + 'manager cannot author' => [UserRole::Manager, Permission::CoursesAuthor, false], + 'manager cannot assign' => [UserRole::Manager, Permission::AssignmentsManage, false], + 'manager cannot manage users' => [UserRole::Manager, Permission::UsersManage, false], + 'manager views team analytics' => [UserRole::Manager, Permission::TeamAnalyticsView, true], + 'manager views personal assigned learning' => [UserRole::Manager, Permission::PersonalLearningView, true], + 'learner personal learning only' => [UserRole::Learner, Permission::PersonalLearningView, true], + 'learner cannot view users' => [UserRole::Learner, Permission::UsersView, false], + 'super admin sees operations' => [UserRole::SuperAdmin, Permission::PlatformViewOperations, true], + 'super admin lacks organization analytics' => [UserRole::SuperAdmin, Permission::OrganizationAnalyticsView, false], + ]; + } +} diff --git a/backend/vite.config.js b/backend/vite.config.js new file mode 100644 index 0000000..29fbfe9 --- /dev/null +++ b/backend/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/app.css', 'resources/js/app.js'], + refresh: true, + }), + tailwindcss(), + ], +}); diff --git a/builder-preview-review.png b/builder-preview-review.png new file mode 100644 index 0000000..cfc2ff5 Binary files /dev/null and b/builder-preview-review.png differ diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..3f2411b --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,86 @@ +name: microlearn +services: + app: + build: + context: . + dockerfile: infrastructure/docker/php/Dockerfile + env_file: ${MICROLEARN_ENV_FILE:-infrastructure/production/.env.production} + depends_on: + mysql: { condition: service_healthy } + redis: { condition: service_healthy } + minio: { condition: service_healthy } + volumes: + - app-storage:/var/www/backend/storage/app + restart: unless-stopped + healthcheck: + test: ["CMD", "php", "artisan", "about", "--only=environment"] + interval: 30s + timeout: 10s + retries: 3 + worker: + build: + context: . + dockerfile: infrastructure/docker/php/Dockerfile + command: ["php", "artisan", "queue:work", "--sleep=2", "--tries=3", "--timeout=600", "--max-time=3600"] + env_file: ${MICROLEARN_ENV_FILE:-infrastructure/production/.env.production} + depends_on: { app: { condition: service_healthy } } + volumes: ["app-storage:/var/www/backend/storage/app"] + restart: unless-stopped + scheduler: + build: + context: . + dockerfile: infrastructure/docker/php/Dockerfile + command: ["php", "artisan", "schedule:work"] + env_file: ${MICROLEARN_ENV_FILE:-infrastructure/production/.env.production} + depends_on: { app: { condition: service_healthy } } + volumes: ["app-storage:/var/www/backend/storage/app"] + restart: unless-stopped + web: + build: + context: . + dockerfile: infrastructure/nginx/Dockerfile + ports: ["${MICROLEARN_HTTP_PORT:-8080}:80"] + depends_on: { app: { condition: service_healthy } } + restart: unless-stopped + mysql: + image: mysql:8.4 + environment: + MYSQL_DATABASE: ${MYSQL_DATABASE:-microlearn} + MYSQL_USER: ${MYSQL_USER:-microlearn} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?Set MYSQL_PASSWORD} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD} + volumes: ["mysql-data:/var/lib/mysql"] + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$$MYSQL_USER -p$$MYSQL_PASSWORD --silent"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + redis: + image: redis:7.4-alpine + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?Set REDIS_PASSWORD}"] + volumes: ["redis-data:/data"] + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + minio: + image: minio/minio:RELEASE.2025-07-23T15-54-02Z + command: server /data --console-address :9001 + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD} + volumes: ["object-data:/data"] + healthcheck: + test: ["CMD", "curl", "-f", "http://127.0.0.1:9000/minio/health/live"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped +volumes: + mysql-data: + redis-data: + object-data: + app-storage: diff --git a/docs/api-guidelines.md b/docs/api-guidelines.md new file mode 100644 index 0000000..0193bc7 --- /dev/null +++ b/docs/api-guidelines.md @@ -0,0 +1,26 @@ +# API Guidelines + +- Base path: `/api/v1` +- JSON request and response bodies +- Resource identifiers are opaque strings +- ISO 8601 UTC timestamps at boundaries +- Locale and timezone are explicit request context +- Cursor pagination for high-volume feeds; page pagination where users need page counts +- Filter and sort fields are allow-listed +- Mutations support idempotency keys where duplicate execution is costly + +## Response envelope + +```json +{ + "data": {}, + "meta": { "requestId": "..." } +} +``` + +Validation errors use a stable machine code, localized user-safe message, and field errors. Authorization must not leak cross-tenant existence. Controllers delegate to application commands and queries. + +## Taxonomy endpoints + +`taxonomy-types`, `taxonomy-nodes`, `taxonomy-relationships`, `content-mappings`, `mapping-suggestions`, `evidence`, `capabilities`, `capability-snapshots`, `skill-expectations`, `skill-gaps`, and `content-coverage` live under `/api/v1` and are policy protected. + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0973436 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,73 @@ +# Architecture + +## Product boundary + +This is an authoring-first microlearning platform, not a conventional LMS. The canonical course model serves Builder, Preview, Player, SCORM, PDF, and MP4 renderers. Learning intelligence adds structured, explainable links from content to evidence and capability without treating learning evidence as employee performance evaluation. + +## System shape + +```text +React/PWA -> Laravel JSON API -> Application commands/queries -> Domain modules + | | + v v + queues/events MySQL/object storage + | + v +Evidence -> Capability -> Gaps/Coverage -> Insights -> Attention +``` + +Development, tests, and production use MySQL 8.4. Production also uses Redis-compatible cache/queues, S3-compatible object storage, and a WebSocket adapter. + +## Backend modules + +- Identity, Tenancy, Organizations, Subscriptions +- Courses, Builder, Blocks, Assessments, Assignments, Learning Paths +- Learning Delivery, Events, Analytics, Monitoring, Attention +- Taxonomy, Content Mapping, Evidence, Capability, Coverage +- Collaboration, AI, Export, Notifications, Audit, System + +Controllers translate HTTP only. Application services coordinate use cases. Domain objects own invariants. Infrastructure adapters implement persistence and external providers. + +## Frontend modules + +Route-level modules are split by workspace (`admin`, `designer`, `manager`, `learner`) and feature. TanStack Query owns server state. Zustand is limited to complex UI state such as Builder selection, history, panels, preview, and drag state. + +## Tenancy + +Authenticated organization context is established server-side. Client-provided organization identifiers never override it. Tenant scope applies to queries, commands, policies, jobs, events, exports, storage paths, and aggregates. Cross-tenant identifiers return not-found unless an explicitly authorized platform operation applies. + +## Deployment mode + +`DeploymentCapabilities` exposes named capabilities for `SAAS` and `ON_PREMISE`. UI and domain code consume capabilities rather than scattering deployment-mode conditionals. + +## Learning intelligence pipeline + +```text +Learning Event -> Evidence Processor -> Evidence Record + -> Capability Recalculation -> Snapshot -> Gap/Coverage Engines + -> Insight -> Attention Action +``` + +Evidence is immutable and traceable. Score and confidence are separate. Scoring policies and algorithm versions are persisted. Published course mappings are immutable and evidence references the exact course version and mapping context. + +## Phases + +0. Repository, architecture, ADRs, contracts, quality baseline +1. Design system and application shells +2. Authentication, tenancy, organization infrastructure +3. Users, teams, subscriptions, permissions +A. Taxonomy core +4. Course domain and immutable versions +B. Version-aware content mapping +5-6. Builder core and Block Registry +7. Assessments and scenarios +C. Evidence generation +8-10. Publishing, assignments, player, PWA, manager workspace +11/D. Event pipeline, scoring and confidence +E-F. Capability monitoring, gaps, and content coverage +12. Collaboration and realtime +13/G. AI and taxonomy assistance +14. Export Center and certificates +15. Production hardening and deployment + +No phase advances while its quality gates are failing. diff --git a/docs/decisions/0001-modular-monolith.md b/docs/decisions/0001-modular-monolith.md new file mode 100644 index 0000000..311f415 --- /dev/null +++ b/docs/decisions/0001-modular-monolith.md @@ -0,0 +1,6 @@ +# ADR 0001: Modular monolith first + +Status: Accepted + +Laravel is organized as explicit domain modules within one deployable backend. This preserves transactional consistency and delivery speed while keeping boundaries suitable for later extraction. Modules communicate through application contracts and domain events, not direct cross-module table manipulation. + diff --git a/docs/decisions/0002-versioned-evidence.md b/docs/decisions/0002-versioned-evidence.md new file mode 100644 index 0000000..098651f --- /dev/null +++ b/docs/decisions/0002-versioned-evidence.md @@ -0,0 +1,6 @@ +# ADR 0002: Immutable versioned evidence + +Status: Accepted + +Published course content and its taxonomy mapping context are immutable. Evidence references the exact course version and mapping snapshot. Taxonomy nodes retain stable IDs and revisions, and scoring algorithms retain model versions. Historical reports therefore remain explainable after content, taxonomy, or algorithms evolve. + diff --git a/docs/decisions/0003-deployment-capabilities.md b/docs/decisions/0003-deployment-capabilities.md new file mode 100644 index 0000000..82f0ade --- /dev/null +++ b/docs/decisions/0003-deployment-capabilities.md @@ -0,0 +1,6 @@ +# ADR 0003: Deployment capabilities + +Status: Accepted + +SaaS and on-premise behavior is represented through a `DeploymentCapabilities` contract. Features ask for named capabilities instead of checking a deployment-mode environment value throughout controllers and components. + diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..c65d5d0 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,42 @@ +# Production and On-Premise Deployment + +MicroLearn ships one application image set for SaaS and dedicated On-Premise installations. `DEPLOYMENT_MODE` selects capabilities; it does not fork product code. + +## Prerequisites + +- Docker Engine 26+ with Compose v2 +- 4 CPU, 8 GB RAM, and storage sized for uploaded video/export retention +- DNS and TLS termination at the ingress/load balancer +- Unique MySQL, Redis, MinIO/S3 and application secrets + +## First boot + +1. Copy `infrastructure/production/.env.production.example` to `.env.production` outside source control. For dedicated installs use the On-Premise template. +2. Generate `APP_KEY` with `php artisan key:generate --show` and replace every `CHANGE_ME` value. +3. Set `MICROLEARN_ENV_FILE` and secret variables in the deployment environment. +4. Run `docker compose -f docker-compose.production.yml build --pull`. +5. Run `docker compose -f docker-compose.production.yml run --rm app php artisan migrate --force`. +6. Start with `docker compose -f docker-compose.production.yml up -d`. +7. Verify `/api/v1/health`; database and storage must report `ok`. Confirm worker and scheduler containers remain healthy in logs. + +TLS terminates before Nginx. Forward the original scheme/host through the trusted ingress and keep PHP, MySQL, Redis and object storage on a private network. Never expose MinIO console or database ports publicly. + +## Scaling and operations + +- Scale `worker` independently for ingestion/export/event load; keep `--timeout` above the largest export execution time. +- Run one or more schedulers with Laravel `withoutOverlapping` locks backed by Redis. +- Configure S3 lifecycle only after application export-retention policy; canonical Assets must not be expired by an export rule. +- Collect container logs, HTTP latency/error rate, queue depth/age, failed jobs, storage capacity, MySQL connections and scheduler heartbeat. +- Alert on health degradation, queue age, repeated export failures, low capacity, backup failure and certificate verification errors. + +## On-Premise + +Set `DEPLOYMENT_MODE=on_premise`. Multi-organization and subscription SaaS controls are hidden by capabilities. External AI defaults off. `FILESYSTEM_DISK`/`EXPORT_DISK` may be `local` for one-node installs or `s3` for MinIO-compatible storage. SMTP can remain `log` in disconnected environments. + +## Release and rollback + +Before every release: take and verify a backup, build immutable versioned images, run CI, inspect migrations, then deploy app/worker/scheduler from the same image digest. Run migrations once. Roll back images only for backward-compatible migrations; otherwise follow the database restore runbook. + +## Disaster recovery + +Define site-specific RPO/RTO. The reference scripts back up MySQL plus private application storage. Object-store versioning/replication supplements—but does not replace—tested restores. Perform a restore drill at least quarterly. diff --git a/docs/design-system.md b/docs/design-system.md new file mode 100644 index 0000000..0d91181 --- /dev/null +++ b/docs/design-system.md @@ -0,0 +1,31 @@ +# Design System + +The interface is flat, professional, content-first, and medium-to-compact density. The supplied product references establish a bright neutral canvas, white analytical cards, restrained borders, a violet primary accent, compact navigation, and a mobile-first learner experience. It uses semantic tokens instead of raw values in screens. + +## Foundations + +- Persian typography: Dana when licensed assets are supplied; system sans fallback until then +- English typography: Inter with system sans fallback +- Spacing: 4/8px rhythm +- Default radius: 12px +- Motion: fast 150ms, default 200ms, slow 250ms +- Shadows: reserved for overlays and meaningful elevation +- Icons: Lucide only; no emoji navigation icons + +Color tokens cover canvas, surface, elevated surface, text, muted text, border, primary, success, warning, danger, and focus. Light and dark themes are independently contrast checked. + +The Designer dashboard prioritizes a single compact KPI row, a visibly stronger Needs Attention panel, readable trend charts, heatmaps, and comparison tables. The Builder uses the referenced three-column studio composition (block library, canvas, inspector) and mirrors it in RTL. Learner screens use focused phone-scale cards, persistent progress, and a five-item bottom navigation. + +## Accessibility + +- WCAG AA text contrast +- visible 2-4px focus indicators +- 44px minimum interactive target +- keyboard navigation and alternatives for drag actions +- semantic headings, labels, status announcements, and error recovery +- `prefers-reduced-motion` support +- charts include a text summary or accessible table + +## Directionality + +The document `dir` is set from locale. Logical CSS properties drive layout. Navigation, breadcrumbs, pagination, inspectors, drawers, directional icons, and charts are tested in both RTL and LTR. diff --git a/docs/domain-model.md b/docs/domain-model.md new file mode 100644 index 0000000..6ec210d --- /dev/null +++ b/docs/domain-model.md @@ -0,0 +1,31 @@ +# Domain Model + +## Core aggregates + +- Organization owns users, teams, subscriptions, taxonomy, content, assignments, and analytics. +- Course owns ordered CourseVersions. A published version is immutable; editing forks a draft. +- CourseVersion owns Modules, Lessons, Blocks, Assessments, and version-aware taxonomy mappings. +- Assignment targets learners, teams, or rules and references the assigned CourseVersion. +- LearningAttempt records delivery state against the assigned version. + +## Taxonomy + +- TaxonomyType is organization-defined and has a stable identifier and semantic key. +- TaxonomyNode has a stable identifier, optional parent, status, code, metadata, and revision history. +- TaxonomyRelationship supports typed non-tree relationships without requiring a graph database. +- ContentTaxonomyMapping links a versioned content object to a node with mapping type, relative weight, source, and confirmation state. +- Inherited mappings are resolved, never copied to every descendant. + +Circular hierarchies are invalid. Referenced nodes are archived rather than deleted. + +## Evidence and capability + +- EvidenceRecord is immutable and traces learner, taxonomy node, origin, type, normalized value, strength, course version, mapping snapshot, and occurrence time. +- ScoringModel versions all calculation policies. +- CapabilityScore is the current projection; CapabilitySnapshot preserves history. +- SkillExpectation defines an expected level for a learner, team, role, or job family. +- SkillGap exists only where an expectation exists. +- ContentCoverageMetric separates learning, practice, assessment, scenario, and evidence coverage. + +Capability score is a strength/recency-weighted evidence projection. Confidence separately reflects effective evidence volume, strength, recency, diversity, and consistency. Exposure alone cannot yield high confidence. + diff --git a/docs/event-taxonomy.md b/docs/event-taxonomy.md new file mode 100644 index 0000000..c6e33f9 --- /dev/null +++ b/docs/event-taxonomy.md @@ -0,0 +1,38 @@ +# Event Taxonomy + +Events use dot-separated past-tense names and versioned payload schemas. Every tenant event includes organization context set by the server, actor context where relevant, occurred time, correlation ID, and causation ID. + +## Authoring + +- `course.draft_created` +- `course.version_published` +- `taxonomy.mapping_created` +- `taxonomy.mapping_confirmed` + +## Learning + +- `course.opened` +- `course.completed` +- `lesson.started` +- `lesson.progressed` +- `lesson.completed` +- `block.viewed`, `block.interacted`, and `block.completed` +- `video.started`, `video.progressed` (25/50/75 milestone), and `video.completed` +- `video.replayed`, `video.skipped`, and `video.exited` +- `assessment.started` and `assessment.completed` +- `question.answered` +- `comment.created`, `bookmark.created`, and `note.created` + +The ingestion allow-list is `EventTaxonomy::learning()` and its current schema version is `1`. The server owns organization and learner identity. Clients may supply only a stable event UUID, assignment/content identifiers, occurred time, optional session/correlation/causation identifiers, a bounded payload, and allow-listed device context (`platform`, `formFactor`, `online`, `appVersion`). Video telemetry reports media position/duration and discrete milestones; it never sends a derived learner score. + +## Intelligence + +- `assessment.evidence_created` +- `skill.evidence_recorded` +- `capability.recalculation_requested` +- `capability.score_updated` +- `capability.snapshot_created` +- `coverage.metric_updated` +- `skill_gap.detected` + +Raw events carry stable IDs, not copied taxonomy trees, and are immutable after ingestion. Consumers claim an event using `(learning_event_id, processor, processor_version)`, so retries and late/offline delivery cannot duplicate projections. Derived daily metrics, risks, insights, and attention records stay in separate tables and can be rebuilt from raw events. diff --git a/docs/implementation-status.md b/docs/implementation-status.md new file mode 100644 index 0000000..3c4b4c5 --- /dev/null +++ b/docs/implementation-status.md @@ -0,0 +1,35 @@ +# Implementation Status + +Last updated: 2026-08-15 + +## Completed phases + +- Phase 0 — Architecture, ADRs, API/event/design/deployment guidance, plus the Skills & Competency Intelligence addendum foundations. +- Phase 1 — Design system, bilingual RTL/LTR application shells, themes, responsive navigation, and reusable UI states. +- Phase 2 — Authentication, tenancy, organization administration, role guards, invitations, and password recovery. +- Phase 3 — Users, XLSX/CSV workforce hierarchy import, automatic manager teams, subscriptions, quotas, and permissions. +- Phase 4 — Course domain, immutable versions, Course workspace, ordered Modules/Lessons, and deep Draft forks. +- Phase 5 — Four-panel canonical Course Builder with locking, autosave, ordering, preview, and undo/redo foundations. +- Phase 6 — Registry-driven Block system, private Asset Library, reusable Asset Picker, accessibility controls, and content-to-taxonomy mappings. +- Phase 7 — Question Bank, assessments, scenarios, all assessment Block schemas, evidence-ready mappings, EvidenceRecords, and capability score/confidence foundations. +- Phase 8 — Readiness-gated publishing, scheduled lifecycle, immutable mapping snapshots, Assignments, dynamic audiences, and versioned Learning Paths. +- Phase 9 — Assignment-only Learner Home, Flow/Card Player, server-verified interactions and completion, learner notes/highlights/bookmarks/favorites/discussions, PWA manifest, approved offline downloads, IndexedDB event/note storage, and idempotent reconnect synchronization. +- Phase 10 — Scoped Manager Workspace, explainable team health/attention metrics, read-only manager routes and drill-downs, cross-role Logout, and bilingual iOS-inspired learner navigation and Player styling. +- Phase 11 — Canonical learning events, idempotent analytics projections, explainable monitoring/risk, drill-downs, and privacy-scoped reporting. +- Phase 12 — Presence, renewable soft locks, review threads, mentions, reactions, realtime transport fallback, and notification workflows. +- Phase 13 — Complete role surfaces, branding/personalization, AI Studio, governed document ingestion, and Draft-only AI assistance. +- Phase 14 — Queue-backed SCORM/xAPI/cmi5/HTML/PDF exports plus branded, verifiable, expiring, and revocable certificates. +- Phase 15 — Production containers, health heartbeats, CI quality gates, On-Prem templates, backup/restore verification, deployment and operations runbooks. + +## Current quality baseline + +- Backend: 120 passing tests / 811 assertions, including tenant boundaries, real queued exports, SCORM/PDF artifacts, certificate eligibility/idempotency/expiry/revocation, and public verification. +- Frontend: strict TypeScript, zero-warning lint, 17 passing test files / 49 tests, a passing production build, and a passing final browser smoke suite. +- Runtime: API, Vite proxy, database, storage, queue/export worker, and scheduler health checks pass; all four seeded roles pass login, authorized-surface, and Logout smoke tests. +- Security: Composer and npm dependency audits report no known vulnerabilities at the configured threshold. + +## Release state + +The implementation roadmap is complete. Remaining work is environment-specific release operation: provision production secrets/TLS, build the checked-in Docker images in an environment with Docker, run migrations, and execute the documented post-deployment smoke and backup drill. + +Known non-blocking build note: the ECharts vendor chunk emits Vite's 500 kB advisory (about 371 kB gzip). Functionality and tests are unaffected; route-level lazy loading remains an optional performance optimization. diff --git a/docs/manager-phase-0-audit.md b/docs/manager-phase-0-audit.md new file mode 100644 index 0000000..03a59c1 --- /dev/null +++ b/docs/manager-phase-0-audit.md @@ -0,0 +1,101 @@ +# Manager Phase 0 — Role, permission, and data-scope audit + +## Scope lock + +### Files expected to change + +- `backend/app/Modules/Identity/Application/RolePermissions.php` +- `backend/app/Modules/Learner/Http/LearnerController.php` +- Manager and identity authorization tests +- This audit document + +### Modules affected + +- Identity permissions +- Manager workspace read model +- Team and user visibility +- Personal learner experience shared by learners and managers + +### API endpoints affected + +- `GET /api/v1/learner/home` +- Learner assignment/player endpoints under `/api/v1/learner/*` +- `GET /api/v1/manager/workspace` is audited and tested; its response contract is unchanged. + +### Permissions affected + +- Managers gain `learning.personal.view` for learning assigned to their own account. +- Managers retain team-scoped read permissions. +- Managers do not gain course authoring, organization assignment management, user management, platform administration, or organization-wide analytics. + +### Expected behavior changes + +- A manager may consume courses and learning paths assigned to the manager's own user account. +- Personal-learning queries continue to resolve the authenticated user's assignment only. + +### Behavior that must remain unchanged + +- Learner personal learning remains functional. +- Manager team analytics remains restricted to explicitly managed teams. +- Course Designer and Super Admin capabilities are unchanged. +- Manager UI is not redesigned in Phase 0. + +## Current authorization model + +The application stores one primary role per user. A Manager therefore receives the learner capability needed for self-learning through a permission, without adding a second role or duplicating the learner domain. The same learner endpoints and course player are reused. + +Manager team scope is the union of members belonging to teams explicitly linked through `team_managers`, constrained again by `organization_id`. Direct reports are automatically attached when a designer assigns a manager to a team; a direct-report relationship alone is not treated as an implicit organization-wide grant. + +## Permission matrix + +| Capability | Manager | Enforcement | +| --- | --- | --- | +| View own assigned learning | Yes | `learning.personal.view`; assignment must contain authenticated user | +| Continue own course/player activity | Yes | Assignment, organization, course-version, lesson, and user scope | +| View explicitly managed teams | Yes | `teams.view` plus `team_managers` membership | +| View members of managed teams | Yes | User directory and manager workspace team scope | +| View team learning progress | Yes | `analytics.team.view` and managed member IDs | +| View team assessments | Yes | Manager workspace uses managed member IDs | +| View team skills taxonomy | Yes, read-only | `taxonomy.view` | +| View organization-wide users | No | User directory narrows Manager to managed-team members | +| View another team's private data | No | Managed team/member ID filters | +| View another user's personal assignment/player | No | Learner progress resolves assignment through authenticated user | +| Create or edit a course | No | `courses.author` denied | +| Use Course Builder, Question Bank, or AI Studio | No | `courses.author` denied | +| Create organization assignments | No in Phase 0 | `assignments.manage` denied | +| Create/update/delete teams or users | No | `teams.manage` and `users.manage` denied | +| Export organization data | No | Export endpoints require `courses.author` | +| View organization monitoring | No | `analytics.organization.view` denied | +| Manage subscription/settings/platform | No | Relevant organization/platform permissions denied | +| Approve requests | Not implemented | No approval capability or workflow currently exists | + +## Endpoint audit summary + +- Manager workspace aggregation begins from managed team IDs, then member IDs, and applies the tenant organization to teams, assignments, events, and assessments. +- User listing uses `UserDirectory::visibleTo`, which limits a Manager to users in managed teams. +- Team index/detail limits a Manager to teams where the Manager is explicitly attached. +- Team, user, assignment, course, builder, assessment-authoring, export, monitoring, AI, and platform mutations require permissions the Manager does not have. +- Personal learner endpoints now authorize by `learning.personal.view`; all reads and mutations remain bound to the authenticated user's own assignment. + +## Security checks + +- Cross-team Manager workspace data: covered. +- Unmanaged team detail by ID: covered. +- Organization-wide user listing: denied by scoped directory query. +- Unauthorized team mutation: covered. +- Unauthorized assignment/course mutation: covered. +- Another user's personal assignment ID: returns not found. +- Cross-tenant records: tenant middleware and organization filters remain required. +- Frontend menu hiding is not treated as an authorization control. + +## Deferred technical debt + +### Medium + +- The Manager frontend route guard currently accepts only `/manager/*`; exposing the shared learner UI inside Manager navigation belongs to Phase 1. +- Assignment creation for a Manager is intentionally still denied. A team-scoped assignment permission and recipient resolver belong to Phase 3. + +### Low + +- Approval workflows and permission names do not exist yet; they should only be introduced if the product workflow is implemented in Phase 4. +- Manager report export is not available. Any future export must resolve recipients and rows from managed-team scope on the backend. diff --git a/docs/manager-phase-1-report.md b/docs/manager-phase-1-report.md new file mode 100644 index 0000000..ebb6910 --- /dev/null +++ b/docs/manager-phase-1-report.md @@ -0,0 +1,59 @@ +# Manager Phase 1 — Shell, navigation, dashboard, and personal learning + +## Delivered + +- Manager-specific navigation now labels Dashboard, My team, Team learning, Team courses, Reports, Notifications, and Personalization clearly. +- `My learning` is permission-aware and appears only with `learning.personal.view`. +- Manager personal learning reuses the learner home API and learner player; no second player or learning domain was introduced. +- The Manager player route stays under `/manager/my-learning/:assignmentId`, while backend ownership remains tied to the authenticated user's assignment. +- The dashboard now provides real compact KPIs for team members, active learners, completion rate, overdue learning, and upcoming deadlines. +- Needs Attention remains the main actionable panel and links only to scoped team members. +- Team course progress, upcoming deadlines, personal continue-learning, and recent team activity use real backend records. +- A manager without an assigned team receives a useful empty state and retains a direct route to personal learning. + +## Data contract additions + +`GET /api/v1/manager/workspace` adds: + +- `overview.activeLearners` +- `overview.dueSoon` +- `recentActivity[]` + +Recent activity is selected only after managed-team member IDs and organization scope are resolved. The query is bounded to eight records and does not add an N+1 query. + +## Responsive and accessibility decisions + +- The dashboard uses existing semantic design tokens for light/dark compatibility. +- Navigation keeps icon and text labels, visible focus, keyboard drawer behavior, and minimum touch targets. +- Progress visuals now expose `progressbar`, min/max, and current value semantics. +- Dense desktop KPI layout reflows to three, then two columns; dashboard panels and personal-learning rows become single-column where needed. +- Automated browser checks cover 375, 768, and 1440 pixel widths with no document-level horizontal overflow. +- Reduced-motion behavior continues to come from the shared application foundation. + +## Behavior intentionally deferred + +- Send Reminder and team learning assignment remain unavailable until Phase 3 backend permissions and recipient scoping exist. +- Skills snapshot is not shown because the Manager workspace does not yet return a verified team skill model; this belongs to Phase 4. +- Approvals are not shown because no approval domain/workflow currently exists. +- Saved reports and pending approvals are not fabricated. + +## Verification + +- Backend authorization and workspace tests +- Manager dashboard and personal-learning component tests +- Shared learner player regression tests +- Manager navigation permission tests +- Full frontend and backend test suites +- Frontend lint, TypeScript, and production build +- PHP Pint format check +- Playwright Manager desktop/tablet/mobile smoke test + +## Deferred technical debt + +### Medium + +- The production bundle already reports chunks over 500 kB. Route-level splitting should be handled as a dedicated performance task rather than mixed into Manager Phase 1. + +### Low + +- The local seeded Manager has no personal assignment by default, so the browser smoke test validates the honest empty state. Assigned-course rendering and ownership are covered by frontend and backend automated tests. diff --git a/docs/manager-phase-2-report.md b/docs/manager-phase-2-report.md new file mode 100644 index 0000000..c540b16 --- /dev/null +++ b/docs/manager-phase-2-report.md @@ -0,0 +1,49 @@ +# گزارش اجرای فاز ۲ پنل مدیر + +تاریخ اجرا: ۱۴۰۵/۰۵/۲۹ (2026-08-20) + +## محدوده اجراشده + +- صفحه «تیم من» به فهرست عملیاتی اعضای تیم تبدیل شد. +- جست‌وجو روی نام، ایمیل، واحد و سمت اضافه شد. +- فیلتر تیم، وضعیت معنایی عضو و وضعیت یادگیری اضافه شد. +- جدول دسکتاپ شامل کارمند، سمت/واحد، دوره فعال، درصد تکمیل، موعد نزدیک، معوق، آخرین فعالیت و وضعیت است. +- در عرض ۹۰۰ پیکسل و کمتر، جدول به کارت‌های responsive تبدیل می‌شود. +- وضعیت‌ها با متن و آیکن نمایش داده می‌شوند و تنها به رنگ وابسته نیستند: در مسیر، نیازمند توجه، عقب‌افتاده و غیرفعال. +- پروفایل یادگیری عضو با URL قابل deep-link (`member` و `tab`) اضافه شد. +- بخش‌های دارای داده معتبر پروفایل شامل نمای کلی، یادگیری فعال، تکمیل‌شده‌ها، ارزیابی‌ها، مسیرهای یادگیری و فعالیت اخیر است. +- داده پروفایل از پاسخ تجمیعی و team-scoped موجود ساخته می‌شود؛ درخواست جداگانه به ازای هر عضو ایجاد نشده است. + +## امنیت و حریم خصوصی + +- قرارداد API تغییر نکرد و همچنان فقط `GET /api/v1/manager/workspace` مصرف می‌شود. +- تست backend اکنون نبود عضو، رویداد و نتیجه ارزیابی تیم دیگر را صریح‌تر کنترل می‌کند. +- اطلاعات منابع انسانی نامرتبط نمایش داده نمی‌شود؛ فقط مشخصات لازم برای زمینه یادگیری استفاده شده است. +- عملیات Assign Learning، Send Reminder و Extend deadline اضافه نشدند، چون permission و endpoint آن‌ها متعلق به فاز ۳ است. +- Skills و Certificates نمایش داده نشدند، چون در قرارداد فعلی داده معتبر و مجاز برای آن‌ها وجود ندارد. + +## تصمیم‌های UI/UX + +- طراحی با tokenهای فعلی پروژه، RTL، dark mode و زبان انگلیسی سازگار نگه داشته شد. +- کنترل‌های تعاملی حداقل ۴۴ پیکسل، label قابل‌مشاهده و focus state دارند. +- تب‌های پروفایل wrap می‌شوند و در موبایل به grid تبدیل می‌شوند تا اسکرول افقی ایجاد نشود. +- بر اساس QA واقعی، breakpoint کارت‌ها از ۷۰۰ به ۹۰۰ پیکسل افزایش یافت تا layout تبلت کنار sidebar بدون overflow باشد. +- motion محدود است و `prefers-reduced-motion` رعایت می‌شود. + +## تست و راستی‌آزمایی + +- Frontend unit: ۳۰ فایل، ۹۵ تست پاس. +- Backend: ۱۵۸ تست، ۱۰۶۸ assertion پاس. +- تست هدفمند Manager frontend: ۴ تست پاس. +- تست هدفمند Manager backend: ۳ تست، ۲۴ assertion پاس. +- lint، typecheck، Pint و build production پاس. +- Playwright Phase 2 در Chrome پاس؛ عرض‌های ۳۷۵، ۷۶۸ و ۱۴۴۰ پیکسل بدون overflow بررسی شدند. +- build همچنان هشدار قبلی chunkهای بزرگ‌تر از ۵۰۰ کیلوبایت را دارد؛ این هشدار از محدوده فاز ۲ مستقل است. + +## خارج از محدوده این فاز + +- تخصیص دوره/مسیر و deadline +- ارسال reminder و notification action +- تمدید deadline +- ثبت و نمایش skill/certificate بدون منبع داده معتبر +- قابلیت‌های فازهای ۳ به بعد diff --git a/docs/manager-phase-3-report.md b/docs/manager-phase-3-report.md new file mode 100644 index 0000000..334f7c5 --- /dev/null +++ b/docs/manager-phase-3-report.md @@ -0,0 +1,83 @@ +# گزارش اجرای فاز ۳ پنل مدیر + +تاریخ اجرا: ۱۴۰۵/۰۵/۲۹ (2026-08-20) + +## محدوده اجراشده + +- بخش مستقل «تخصیص آموزش» به ناوبری مدیر اضافه شد. +- جریان ساده و پنج‌مرحله‌ای مخاطب، آموزش، مهلت، اعلان و بازبینی پیاده‌سازی شد. +- مدیر می‌تواند یک تیم تحت مدیریت یا چند کارمند مشخص از همان محدوده را انتخاب کند. +- فقط نسخه‌های منتشرشده دوره‌ها و مسیرهای یادگیری در انتخاب آموزش نمایش داده می‌شوند. +- سه حالت بدون مهلت، تاریخ مشخص و مهلت نسبی پشتیبانی می‌شود. +- اعلان فوری، یادآوری قبل از مهلت، یادآوری روز مهلت و شرط «فقط اگر شروع نشده» قابل تنظیم است. +- پیش از ثبت، خلاصه کامل تصمیم نمایش داده می‌شود و پس از ثبت، تعداد تخصیص موفق، تکراری، اعلان ارسال‌شده، ردشده و زمان‌بندی‌شده گزارش می‌شود. +- فهرست تخصیص‌های اخیر مدیر با امکان تغییر/حذف مهلت و ارسال یادآوری اضافه شد. +- اقدام‌های «تخصیص آموزش» و «ارسال یادآوری» از پروفایل کارمند، با deep-link و فیلتر همان کارمند، به جریان جدید متصل شدند. + +## قرارداد API و مجوزها + +Endpointهای اختصاصی و محدود به مدیر: + +- `GET /api/v1/manager/assignment-contexts` +- `GET /api/v1/manager/assignments` +- `POST /api/v1/manager/assignments` +- `PATCH /api/v1/manager/assignments/{assignment}/deadline` +- `POST /api/v1/manager/assignments/{assignment}/reminders` + +مجوزهای افزوده‌شده: + +- `manager.assignments.manage` +- `manager.deadlines.manage` +- `manager.reminders.send` + +Endpoint عمومی سازمان برای assignment همچنان برای مدیر ممنوع است. مجوزهای جدید فقط جریان محدودشده مدیر را فعال می‌کنند. + +## امنیت، Scope و یکپارچگی داده + +- مخاطبان در backend از تیم‌های تحت مدیریت و اعضای فعال آن‌ها استخراج می‌شوند؛ شناسه ارسالی کلاینت منبع اعتماد نیست. +- ساخت، مشاهده، تغییر مهلت و یادآوری فقط برای تخصیص‌هایی مجاز است که مدیر ساخته و همه گیرندگان آن همچنان داخل scope او باشند. +- دوره Draft یا محتوای خصوصی از context حذف و در mutation نیز رد می‌شود. +- تخصیص فعال تکراری در سطح هر گیرنده رد می‌شود؛ گیرندگان غیرتکراری همان درخواست همچنان ثبت می‌شوند. +- ساخت تخصیص، تغییر مهلت و ارسال یادآوری در audit log ثبت می‌شوند. +- reminder دستی دارای کلید idempotency روزانه است تا ارسال تکراری ناخواسته رخ ندهد. +- مسیر یادآوری با rate limit مستقل محافظت شده است. + +## معماری اعلان و زمان‌بندی + +- ارسال واقعی از کانال In-App موجود انجام می‌شود و Notification Center یادگیرنده را تغذیه می‌کند. +- جدول `notification_schedules` برای اعلان‌های آینده، وضعیت تحویل، شرط، خطا و کلید idempotency اضافه شد. +- فرمان `notifications:process-schedules` هر دقیقه توسط scheduler اجرا می‌شود. +- هنگام تحویل، تکمیل/لغو assignment و شرط «شروع نشده» دوباره ارزیابی می‌شود. +- با تغییر مهلت، reminderهای در انتظار لغو و بر اساس مهلت جدید زمان‌بندی می‌شوند؛ با حذف مهلت لغو می‌شوند. +- تنظیمات `inAppNotifications`، `assignmentNotifications` و `deadlineReminders` کاربر رعایت می‌شوند؛ اعلان‌های الزامی از اعلان اختیاری تفکیک شده‌اند. +- لایه orchestration از کانال تحویل جدا است و برای اتصال بعدی Web/PWA/FCM آماده است؛ هیچ اتصال ساختگی FCM یا Push اضافه نشده است. + +## تصمیم‌های UI/UX + +- طراحی با tokenها، RTL، dark mode و ترجمه فارسی/انگلیسی موجود هماهنگ نگه داشته شد. +- هر مرحله یک تصمیم مشخص دارد و خطا پیش از عبور از همان مرحله با `role="alert"` اعلام می‌شود. +- ورودی‌ها label واقعی، دکمه آیکونی بستن نام قابل‌دسترسی و ترتیب طبیعی keyboard دارند. +- وضعیت‌ها تنها با رنگ منتقل نمی‌شوند و متن/آیکن مکمل دارند. +- motion محدود است و `prefers-reduced-motion` رعایت می‌شود. +- در پروفایل کارمند، لینک تخصیص ویزارد را برای همان شخص باز می‌کند؛ لینک یادآوری فقط تخصیص‌های مرتبط با او را فیلتر می‌کند. + +## تست و راستی‌آزمایی + +- Backend کامل: ۱۶۲ تست و ۱۱۰۲ assertion پاس. +- Frontend کامل: ۳۱ فایل و ۹۸ تست پاس. +- تست متمرکز Manager frontend: ۷ تست پاس. +- تست متمرکز Manager backend: ۷ تست و ۵۸ assertion پاس. +- TypeScript typecheck، lint، Pint و build تولیدی پاس. +- migration زمان‌بندی اعلان با موفقیت اجرا شد. +- Playwright فاز ۳ در Chrome: ۲ تست پاس؛ ویزارد پنج‌مرحله‌ای و عرض‌های ۳۷۵، ۷۶۸ و ۱۴۴۰ پیکسل بدون overflow بررسی شدند. +- build هشدار قدیمی chunkهای بزرگ‌تر از ۵۰۰ کیلوبایت را دارد؛ این هشدار مانع build نیست و به این فاز اختصاص ندارد. +- PHPStan در وابستگی‌های نصب‌شده پروژه وجود ندارد؛ بنابراین اجرای آن ممکن نبود و جایگزین نتایج آن ادعا نشده است. + +## خارج از محدوده این فاز + +- قابلیت‌های Skills، Insights، Reports و Approvals فاز ۴ +- bulk deadline extension فراتر از نیاز واقعی فعلی +- اتصال تولیدی Web Push، PWA Push یا FCM +- هرگونه redesign سراسری پنل مدیر + +فاز ۳ در همین نقطه متوقف شده است و فاز ۴ شروع نشده است. diff --git a/docs/mysql-migration.md b/docs/mysql-migration.md new file mode 100644 index 0000000..53fbaee --- /dev/null +++ b/docs/mysql-migration.md @@ -0,0 +1,27 @@ +# MySQL migration + +MicroLearn uses MySQL 8.4 in development, tests, CI, production, and on-premise deployments. + +## One-time local migration from SQLite + +1. Back up `backend/database/database.sqlite` and stop API/queue processes that can write to it. +2. Create an empty MySQL database and a least-privilege application user using `utf8mb4`. +3. Set the MySQL `DB_*` values in `backend/.env`. +4. Clear cached configuration and import the legacy data: + + ```sh + cd backend + php artisan config:clear + php artisan db:import-sqlite database/database.sqlite + ``` + +The importer applies all migrations to MySQL, requires the destination application tables to be empty, copies matching columns in batches, and leaves the SQLite source unchanged. Keep the SQLite backup until record counts and application smoke tests have been verified. + +## Verification + +```sh +php artisan migrate:status +php artisan test +``` + +Production backup and restore use `mysqldump` and `mysql` through `scripts/backup.sh` and `scripts/restore.sh`. diff --git a/docs/phase-checklist.md b/docs/phase-checklist.md new file mode 100644 index 0000000..2d23287 --- /dev/null +++ b/docs/phase-checklist.md @@ -0,0 +1,572 @@ +# Phase Completion Checklist + +This file is the execution record for the Master Prompt phases. An item is checked only after implementation and proportional verification. Passing tests alone does not make a product capability complete. + +## Phase 1 — Design System and Application Shells + +### Design foundations + +- [x] Semantic light/dark color tokens, typography, spacing, radii, elevation, motion, breakpoints, and z-index scales are implemented. +- [x] FA/EN locale changes the document language and true RTL/LTR direction without hard-coded physical layout assumptions. +- [x] Theme and locale preferences persist across reloads and respect the initial system preference. +- [x] Reusable primitives exist for buttons, fields, validation, alerts, badges, loading/skeleton, empty, error, permission-denied, offline, modal, and toast feedback. +- [x] Interactive controls have visible focus, disabled, pending, pressed, and error states with minimum 44px targets. + +### Application shells + +- [x] Designer shell contains the complete Master Prompt navigation and header actions. +- [x] Super Admin shell contains the complete platform-operations navigation. +- [x] Manager shell contains the complete team-management navigation. +- [x] Learner shell is mobile-first and uses at most five labelled bottom-navigation destinations. +- [x] Desktop sidebar, tablet collapse, and mobile drawer behavior work without horizontal overflow. +- [x] Command palette opens with Ctrl/Cmd+K, is keyboard accessible, searches authorized navigation, and supports dismissal/focus restoration. +- [x] Shell profile and organization labels are data-driven; no demo identity is presented as real user data. +- [x] Route transitions move focus to main content and all shells provide skip navigation. + +### Phase 1 quality gates + +- [x] Component and shell tests pass. +- [x] TypeScript strict typecheck passes. +- [x] Lint passes. +- [x] Production build passes. +- [x] Visual review passes at 375px-equivalent CSS breakpoint, 768px, 1024px, and desktop in RTL and LTR. +- [x] Reduced-motion and light/dark modes are reviewed. + +## Phase 2 — Authentication, Tenancy, and Organization Infrastructure + +### Backend + +- [x] Login, logout, current-user, forgot-password, reset-password, invitation acceptance, and disabled-account behavior are implemented. +- [x] Auth responses expose the minimum safe identity, role, locale, timezone, organization context, permissions, and deployment capabilities. +- [x] Auth and password endpoints have validation, privacy-safe errors, and rate limiting. +- [x] Tenant context is resolved exclusively from the authenticated user and ignores client-supplied organization identifiers. +- [x] Tenant-owned queries and route-model lookups used in this phase are organization scoped. +- [x] Super Admin organization list/create/read/update endpoints exist without exposing tenant learning analytics. +- [x] SaaS/On-Prem capabilities are served through the deployment abstraction. + +### Frontend + +- [x] Central typed API client handles bearer tokens, validation errors, unauthenticated responses, and abort signals. +- [x] Auth session provider restores the current session and keeps remote identity out of Zustand. +- [x] Login screen is complete, accessible, bilingual, responsive, and handles pending/success/failure. +- [x] Forgot-password and reset-password screens are complete and privacy-safe. +- [x] Invitation-acceptance screen is complete and validates token/name/password states. +- [x] Protected routes redirect unauthenticated users and role guards reject unauthorized workspaces. +- [x] Signed-in shell displays the real user and organization context. +- [x] Logout revokes the current token and clears local session state. +- [x] Organization administration screen consumes real APIs and implements loading, empty, populated, error, and permission-denied states. +- [x] Deployment capabilities hide SaaS-only organization administration in On-Prem mode. + +### Phase 2 quality gates + +- [x] Backend auth, tenancy, organization authorization, and isolation tests pass. +- [x] Frontend auth/provider/guard/form tests pass. +- [x] Typecheck, lint, and production build pass. +- [x] Login and recovery screens pass RTL/LTR and mobile visual review. +- [x] No client-provided organization ID is trusted by authenticated tenant APIs. + +## Phase 3 — Users, Teams, Subscriptions, and Permissions + +### Backend + +- [x] User directory supports tenant-scoped search, role/status filters, pagination, and safe user payloads. +- [x] Course Designers can update user role/status within allowed tenant boundaries; last active Designer protection is enforced. +- [x] Invitations expose pending/expired status and can be resent or revoked without leaking tokens. +- [x] Team APIs support list/create/read/update plus member and manager attach/detach with tenant validation. +- [x] Manager reads remain restricted to managed teams and their members. +- [x] Subscription API exposes dates, feature flags, seat/storage/AI quotas, and current usage without pricing logic. +- [x] Seat quota is enforced consistently for invitations and activations. +- [x] Central permission matrix is returned by the session and enforced server-side for every Phase 3 mutation. + +### Frontend + +- [x] Users screen uses real APIs with search, role/status filters, loading/empty/populated/error/permission states, and invitation workflow. +- [x] Teams screen uses real APIs with create, details, member/manager assignment, removal, and responsive states. +- [x] Subscription screen presents real plan dates, feature flags, quota usage, and accessible progress indicators. +- [x] UI actions are permission-aware while backend authorization remains authoritative. +- [x] Mutation pending/success/failure feedback is visible and accessible. +- [x] Phase 3 tables/cards remain usable on mobile, tablet, RTL/LTR, and light/dark themes. + +### Phase 3 quality gates + +- [x] Backend users/teams/subscription/permission/quota tests pass. +- [x] Frontend Users/Teams/Subscription tests pass. +- [x] Typecheck, lint, and production build pass. +- [x] Responsive and RTL/LTR visual review passes. +- [x] No Phase 3 tenant resource can be accessed or mutated cross-tenant. + +## Phase 4 — Course Domain, Versions, Modules, and Lessons + +### Backend + +- [x] Course list supports tenant-scoped search, status filters, sorting, pagination, and safe summary payloads. +- [x] Blank-course creation atomically creates Course, Draft Version 1, initial Module, and initial Lesson. +- [x] Course workspace API exposes overview, ordered structure, and version history without learner analytics placeholders. +- [x] Draft version metadata can be edited while Published versions remain immutable. +- [x] Editing a Published version forks a complete new Draft version with stable source-version history. +- [x] Module APIs support create/update/delete/reorder with contiguous positions and draft-only mutations. +- [x] Lesson APIs support create/update/delete/reorder/move with contiguous positions and draft-only mutations. +- [x] Existing blocks are copied when a Published version is forked and remain linked to the new version hierarchy. +- [x] All Course, Version, Module, Lesson, and fork operations are tenant-scoped and permission-enforced. + +### Frontend + +- [x] Courses screen uses real APIs with search, status views, sorting, and loading/empty/populated/error/permission states. +- [x] New Course screen presents exactly four creation paths and completes the Blank Course workflow. +- [x] Future Template, Import, and AI paths are clearly unavailable without fake progress or generated results. +- [x] Course Workspace provides Overview, Content, and Versions views backed by real API data. +- [x] Content view supports module and lesson creation, rename, deletion, and ordering with visible mutation feedback. +- [x] Published versions expose a clear “Create editable draft” action instead of direct editing. +- [x] Course routes are deep-linkable, permission-aware, responsive, RTL/LTR-safe, and light/dark compatible. + +### Phase 4 quality gates + +- [x] Backend course/version/module/lesson authorization, immutability, ordering, fork, and tenant-isolation tests pass. +- [x] Frontend Courses/New Course/Course Workspace tests pass. +- [x] Typecheck, lint, formatting, and production build pass. +- [x] Responsive RTL/LTR and light/dark visual review passes. +- [x] No Phase 4 resource can be accessed or mutated cross-tenant. + +## Phase 5 — Builder Core + +### Backend and contracts + +- [x] Builder reads the canonical Course Version/Lesson structure through tenant-scoped APIs. +- [x] Block creation supports an explicit insertion position and keeps lesson positions contiguous. +- [x] Block duplication preserves the unified contract and creates an independent Draft copy. +- [x] Module and Lesson duplication copy their nested Draft content without crossing tenant/version boundaries. +- [x] Module and Lesson lock state is explicit and prevents protected structure/content mutations. +- [x] All Builder mutations remain Draft-only, permission-enforced, revision-aware, and tenant-isolated. + +### Builder experience + +- [x] Builder uses the central authenticated API client and works with the real application session. +- [x] RTL Builder layout provides Canvas, Course Structure, Block Library, and Inspector with functional collapsible panels. +- [x] Toolbar provides functional Undo/Redo for persisted content edits and block ordering. +- [x] Autosave exposes clear idle, saving, saved, and failed states with a retry path. +- [x] Primary Heading, Text, and Key Point content can be edited inline on the Canvas. +- [x] Insertion affordances between blocks open an accessible Block Picker at the selected position. +- [x] Slash commands open the same Block Picker without creating a second block-selection system. +- [x] Keyboard shortcuts support save, undo, redo, selection dismissal, and safe deletion. +- [x] Drag-and-drop reordering has optimistic feedback, clear drop state, and keyboard-accessible move controls. +- [x] Course Structure supports real lesson navigation plus module/lesson add, rename, duplicate, reorder, delete, and lock controls. +- [x] Desktop, Tablet, and Mobile preview widths use the same canonical block renderer. +- [x] Published versions are visibly read-only and never expose active mutation controls. +- [x] Comments, collaboration, AI, learner preview, and publishing controls are labelled honestly as later-phase capabilities. + +### Phase 5 quality gates + +- [x] Backend insertion/duplication/locking/revision/immutability/tenant tests pass. +- [x] Frontend history/autosave/inline editing/picker/shortcuts/panel tests pass. +- [x] Typecheck, lint, formatting, and production build pass. +- [x] Builder visual review passes at desktop, tablet, mobile preview, RTL/LTR, and light/dark states. +- [x] No fake save, preview, collaboration, AI, or publishing result is presented as functional. + +## Later phases + +## Pre-Phase 7 Product Corrections + +- [x] Asset and video uploads accept files up to 2 GB in API validation and the local launcher PHP configuration. +- [x] Content Library supports a compact row view and an exact 5 cm × 5 cm desktop card view. +- [x] Workforce import accepts XLSX/CSV files with first name, last name, department, job level, direct manager, and email columns. +- [x] Workforce import validates job levels, duplicate/ambiguous identities, tenant boundaries, seat quota, and applies changes atomically. +- [x] Course cards are approximately 30% denser and preserve responsive behavior. +- [x] Designers can select, replace, or remove a private image Asset as the Course cover. +- [x] Selecting a Team manager automatically attaches that manager and their active direct reports. +- [x] Teams screen presents consolidated metrics, search, structured manager/member groups, and clearer hierarchy details. +- [x] Positive/confirmation actions use semantic green and destructive/negative actions use semantic red. +- [x] Seeded Manager and Learner review accounts are documented and usable. +- [x] User directory renders name, email, department, job position, manager, role, status, and actions in independent columns. +- [x] User profiles can be edited with hierarchy-cycle and cross-tenant manager protection. +- [x] Every Draft-safe Asset exposes deletion; used Draft references are detached atomically while Published references remain protected. +- [x] Content cards keep title, size, usage count, and destructive action in non-overlapping regions. +- [x] Image, video, audio, and PDF Assets open in an accessible preview dialog; unsupported documents offer a safe open/download path. +- [x] Workforce import includes a real downloadable RTL XLSX template with manager-first sample rows. +- [x] Backend and Frontend regression suites, typecheck, lint, formatting, production build, migrations, launcher check, API smoke tests, and visual review pass. + +## Phase 6 — Block System and Block Registry + +### Canonical block platform + +- [x] Backend and Frontend registries expose one unified Block contract: data, style, behavior, responsive, accessibility, capabilities, web behavior, and export compatibility. +- [x] Registry validation covers every implemented non-assessment Phase 6 block and rejects unknown fields/types safely. +- [x] Schema-version migration infrastructure can upgrade persisted Block data without silently rewriting Published versions. +- [x] Builder availability is driven by Registry metadata and parity is protected by automated tests. +- [x] Heading, Text, Quote, Key Point, Divider, and Button blocks are implemented. +- [x] Image, Gallery, Video, Audio, Document, and Embed blocks are implemented with safe URL/file behavior. +- [x] Flashcard, Accordion, Tabs, Timeline, Steps, Process, and Checklist blocks are implemented. +- [x] Section, preset Columns, and Controlled Grid blocks are implemented with responsive stacking configuration. +- [x] Phase 6 shipped no fake assessment interactions; the real assessment catalog is completed in Phase 7 below. + +### Editing and rendering + +- [x] Each registered block supplies its own Renderer, Editor configuration, validation schema, defaults, icon, and category without Builder-level type switches. +- [x] Inspector Content, Design, and Behavior tabs are functional for the properties supported in Phase 6. +- [x] Design controls persist alignment, width, spacing, background, border, and radius using controlled tokens. +- [x] Behavior controls persist visibility, completion, animation, and Block lock state without claiming Player behavior before Phase 9. +- [x] Accessibility controls persist alt text, labels, transcript/caption information, and decorative intent where applicable. +- [x] Canonical renderers are reused by Builder preview widths and nested controlled layouts stack safely on mobile. +- [x] Navigator supports locate/select plus visible/locked state and keyboard ordering alternatives. + +### Assets and learning mapping + +- [x] Tenant-scoped Asset API validates type, MIME, size, extension, filename, authorization, and private access. +- [x] Asset list supports search/type filtering, preview metadata, and usage-reference counts. +- [x] Used Draft assets can be detached and deleted atomically; Published references and cross-tenant assets remain protected. +- [x] Content Library provides real upload, search, filter, preview, usage, empty/loading/error, and safe-delete states. +- [x] Media Block editors select uploaded assets through one reusable Asset Picker. +- [x] Reusable LearningMappingPanel lists direct Block mappings and searches taxonomy nodes by name/code/type. +- [x] Designers can add and remove confirmed develops/practices/assesses/related mappings on Draft Blocks. +- [x] Published and cross-tenant mapping mutations remain rejected; AI suggestions are not presented as confirmed mappings. + +### Phase 6 quality gates + +- [x] Backend Registry, schema, Asset validation/privacy/usage, mapping, authorization, and tenant-isolation tests pass. +- [x] Frontend Registry/editor/renderer, Asset Library/Picker, Mapping Panel, Inspector, and responsive-layout tests pass. +- [x] Typecheck, lint, backend formatting, and production build pass. +- [x] Builder and Content Library visual review passes in RTL/LTR, light/dark, desktop/tablet/mobile states. +- [x] `start-dev.bat --check`, authenticated API smoke tests, and temporary-service cleanup pass. +- [x] No Phase 8 publishing or Phase 9 Player result is presented as functional. + +## Phase 7 — Assessments and Scenarios + +### Question bank and assessment authoring + +- [x] Tenant-scoped Question Bank supports create, edit, delete, search, type, difficulty, topic, tags, explanation, usage, and performance metadata. +- [x] Single Choice, Multiple Choice, True/False, Matching, Sorting, Drag and Drop, and Hotspot question schemas are validated server-side. +- [x] Assessments support reusable bank questions, direct question copies, ordering, random selection, pools, shuffling, passing score, attempt limit, feedback mode, and time limit. +- [x] Draft assessment/question mutations are authorized, tenant-isolated, and rejected for Published Course Versions. +- [x] Assessments and individual assessment questions support weighted taxonomy/competency mappings. + +### Scenarios and interactive blocks + +- [x] Scenario authoring supports context, choices, score, feedback, topic, difficulty, and tags. +- [x] Branching Scenario visual authoring supports Scene, Question, Choice/Branch, and Result nodes. +- [x] Branch graphs reject missing start nodes, broken targets, duplicate nodes, and unreachable nodes. +- [x] Interactive Image and Before/After blocks are registered with real editors, renderers, schemas, and Asset references. +- [x] Backend and Frontend Block registries expose the complete Phase 7 assessment/interaction catalog with validated defaults. + +### Phase 7 quality gates + +- [x] Question Bank, assessment settings, scenario graph, authorization, Published immutability, and tenant-isolation tests pass. +- [x] Assessment Studio and Registry UI tests pass, including taxonomy mapping access. +- [x] Full backend suite passes: 93 tests and 559 assertions. +- [x] Full frontend suite passes: 11 files and 35 tests. +- [x] Typecheck, lint, backend formatting, migrations, seed data, and production build pass. +- [x] Visual review passes for Question Bank, assessment mapping, scenarios, users, Excel import, and both Content Library layouts. + +## Later phases + +- [x] Phase 7 — Assessments and scenarios +- [x] Phase 8 — Publishing, assignments, and Learning Paths +- [x] Phase 9 — Learner Player, PWA, and offline +- [x] Phase 10 — Manager Workspace and Learner iOS Experience +- [x] Phase 11 — Events, analytics, and Monitoring +- [x] Phase 12 — Collaboration and realtime +- [x] Phase 13 — AI Studio and ingestion +- [x] Phase 14 — Export Center and certificates +- [x] Phase 15 — Production hardening and deployment + +## Phase 8 — Publishing, Assignments, and Learning Paths + +### Publishing and version integrity + +- [x] Course versions support Draft, In Review, Published, scheduled publish, scheduled unpublish, and safe unpublish states. +- [x] Readiness checks validate metadata, structure, lesson content, Blocks, Assets, assessments, scenarios, and completion rules before review/publish. +- [x] Completion rules use a controlled logical model for required lessons, minimum lesson percentage, assessment pass, minimum score, and required interaction. +- [x] Publishing snapshots taxonomy mappings so later Draft changes never rewrite Published evidence context. +- [x] Published versions remain immutable; forking copies modules, lessons, Blocks, assessments, questions, completion rules, and taxonomy mappings. +- [x] Publishing a new version supports no reassignment or copying all active assignments from its source version. + +### Assignment engine + +- [x] Published Courses and Learning Paths can be assigned to an individual, Team, department, organization, controlled rule, or an uploaded XLSX/CSV audience list. +- [x] Assignments support mandatory status, start/due dates, recurring month interval, reminder days, and manager escalation policy. +- [x] Recipient snapshots preserve assignment history and cancellation state. +- [x] Team and rule audiences are dynamically resolved; newly imported/edited users and newly attached Team members are synchronized automatically. +- [x] Designers can list, create, cancel, filter, and manually resynchronize Assignments from the product UI/API. + +### Learning Paths + +- [x] Learning Paths contain ordered Published Course Versions with optional prerequisites and controlled completion rules. +- [x] The V1 editor is a clean responsive ordered list with visible move controls instead of a complex node canvas. +- [x] Draft paths support add, remove, reorder, metadata, enforced-order setting, review, and publish workflows. +- [x] Published Learning Path Versions are immutable and can be forked without changing prior history. +- [x] Published Learning Paths are available to the Assignment Engine. + +### Phase 8 quality gates + +- [x] Dedicated backend tests cover publish immutability, dynamic Team assignment, and Learning Path version history. +- [x] Full backend suite, frontend typecheck, and production build pass. +- [x] UI follows the existing responsive RTL design system with semantic success/danger states, labelled controls, loading/error/empty states, and confirmation for sensitive actions. + +## Phase 9 — Learner Player, PWA, and Offline + +### Learner experience and Player + +- [x] Learner navigation provides Home, My Learning, Daily, Progress, and More without exposing a public Course catalog. +- [x] Home uses assigned data for greeting, Continue Learning, Daily Learning, Assigned to You, Due Soon, and verified progress summaries. +- [x] Assignment and tenant authorization protects every Player payload and only exposes referenced private Assets through signed URLs. +- [x] Flow and card/story presentation modes use the same canonical Course Version, Lesson, and Block content. +- [x] Card/story mode remains card-based on desktop and supports vertical touch navigation on mobile. +- [x] Player chrome includes Course, Lesson and overall progress, collapsed outline, previous/next controls, sticky mobile progress, and accessible interaction controls. +- [x] Single Choice, Multiple Choice, True/False, Matching, Sorting, Drag and Drop, Hotspot, Scenario, and Branching Scenario interactions are functional in Player. +- [x] Assessment scores are recalculated from canonical answers on the server and never trusted from the browser payload. +- [x] Completion rules update lesson, course, and Assignment recipient progress from idempotent learning events. + +### Learner tools and social learning + +- [x] Private notes, Block bookmarks, selected-text highlights, and Course favorites persist with learner/tenant scope. +- [x] Lesson discussions support posts, replies, helpful reactions, and scoped authorization. +- [x] Notes can be printed or saved as PDF using a dedicated print layout. +- [x] Notes and saved highlights are visible in one compact Player drawer. + +### PWA and offline architecture + +- [x] Installable web manifest, application icon, theme metadata, and Service Worker registration are present. +- [x] Explicit Course download uses the server manifest and caches only approved referenced Assets. +- [x] IndexedDB stores cached Course payload/download metadata, private notes, progress, and queued learning events. +- [x] Offline events synchronize on reconnect, reconcile local note state, and use client event IDs to prevent duplicate progress and notes. +- [x] Large videos follow an explicit manifest policy and are excluded from automatic offline download above the configured threshold. +- [x] Online, offline, loading, empty, authorization, and recovery states are represented honestly. + +### Phase 9 quality gates + +- [x] Dedicated backend Player tests pass for assignment scope, tenant isolation, event idempotency, completion, private notes/bookmarks, social features, offline notes, and server-side scoring. +- [x] Full backend suite passes: 103 tests and 624 assertions; PHP formatting passes. +- [x] Full frontend suite passes: 12 files and 36 tests; typecheck and zero-warning lint pass. +- [x] Production build and `start-dev.bat --check` pass. +- [x] Seeded desktop/mobile, light/dark RTL visual review passes for Learner Home and Player; temporary review services are cleaned up. + +## Phase 10 — Manager Workspace and Learner iOS Experience + +### Cross-role session controls + +- [x] A visible Logout action is available to Designer, Super Admin, Manager, and Learner users. +- [x] Logout shows pending/error feedback, revokes the current API token, clears local authentication state, and returns to Login. +- [x] Learner Player provides a predictable route back to the learner workspace where account and Logout controls remain reachable. +- [x] Automated tests cover Logout visibility and behavior for workspace and learner shells. + +### Manager authorization and data contracts + +- [x] Manager APIs are protected by Manager role/permission checks and derive tenant context only from the authenticated user. +- [x] Every Manager query is restricted to Teams explicitly managed by that Manager; unmanaged and cross-tenant records are not disclosed. +- [x] Manager cannot author, publish, assign, mutate Team membership, or manage organization settings through UI or API. +- [x] Manager Overview returns real, explainable Team Learning Health, completion, engagement, assessment, overdue, at-risk, and attention metrics. +- [x] My Team returns managed Teams and member profiles with assignment/progress summaries and safe drill-down data. +- [x] Learning Status, Courses, Assessments, Attention, Reports, and Notifications use scoped server data with documented empty states. +- [x] No Phase 11 predictive/Monitoring metric is fabricated; unavailable future analytics are labelled honestly. + +### Manager workspace UI + +- [x] `/manager` provides Overview, My Team, Learning Status, Courses, Assessments, Attention, Reports, and Notifications routes. +- [x] Overview composes reusable metric, health, attention, progress, and status components rather than page-local duplicates. +- [x] Tables/lists support useful Team, status, Course, and due-state filters with responsive card alternatives on mobile. +- [x] Attention items explain why a person needs attention and link to a scoped detail or recommended follow-up. +- [x] Manager pages include loading, empty, error, permission-denied, and retry states. +- [x] Manager workspace is responsive, bilingual, RTL/LTR-safe, keyboard accessible, and uses semantic success/danger colors. + +### Learner iOS-style redesign + +- [x] Learner Home, My Learning, Daily, Progress, More, and Player use one cohesive iOS-inspired visual language in light and dark themes. +- [x] Learner screens use large-title hierarchy, grouped surfaces, restrained blur/elevation, system-like controls, and consistent rounded geometry. +- [x] Bottom navigation is a floating rounded tab bar with five labelled icons, safe-area spacing, visible selected state, and at least 44×44 px touch targets. +- [x] Scroll content reserves enough bottom inset so the floating navigation never covers cards or actions. +- [x] More includes account identity, theme/language access, offline information, and visible Logout. +- [x] Player retains minimal chrome, readable progress, offline state, notes/discussion access, and safe-area-aware controls after the redesign. +- [x] Motion is subtle, interruptible, and disabled/reduced when `prefers-reduced-motion` is enabled. + +### Phase 10 quality gates + +- [x] Backend tests cover Manager scope, unmanaged/cross-tenant denial, real metric calculations, and forbidden mutations. +- [x] Frontend tests cover Manager routes/states, filters/drill-down, Logout, and learner floating navigation. +- [x] Full backend/frontend tests, typecheck, zero-warning lint, PHP formatting, and production build pass. +- [x] `start-dev.bat --check`, seeded Manager/Learner smoke tests, and temporary-service cleanup pass. +- [x] Visual review passes for Manager desktop/tablet/mobile and learner iOS-style small-phone/large-phone/desktop in FA/EN, RTL/LTR, light/dark. + +## Phase 11 — Event Pipeline, Analytics, and Monitoring Engine + +### Event contracts and processing + +- [x] Learning Events use a versioned, allow-listed taxonomy with server-owned tenant and actor context. +- [x] Event ingestion preserves immutable raw facts, client idempotency, occurred/received time, session, correlation, causation, and device context. +- [x] Offline and late events are accepted safely without duplicate projections or client-controlled analytics values. +- [x] Projection processing is idempotent, retryable, observable, and can rebuild derived metrics from raw events. +- [x] Meaningful Course, Lesson, Block, video, assessment, social, note, and completion interactions emit documented events. + +### Metrics and learning intelligence + +- [x] Metrics Engine derives completion, on-time completion, engagement, learning time, inactivity, and progress velocity from canonical data. +- [x] Course/Lesson/Block analytics expose starts, completions, drop-off, and drill-down without fabricated telemetry. +- [x] Video analytics support reliable start, 25/50/75 percent, completion, average watch, exit point, replay, and skip signals. +- [x] Assessment analytics expose score, pass rate, attempts, question difficulty, common wrong answers, and Team comparison. +- [x] Evidence processing remains immutable and triggers versioned Capability score, confidence, snapshot, gap, and coverage recalculation. +- [x] Insufficient evidence is labelled honestly and score precision never exceeds evidence quality. + +### Monitoring, Risk, Attention, and actions + +- [x] Learning Health uses centrally configured weights and exposes every contributing factor. +- [x] V1 Risk Score is an explainable heuristic behind a provider abstraction and is never labelled as ML probability. +- [x] Insight and Attention items contain severity, reason, entity, evidence, trend, suggested action, and drill-down destination. +- [x] Attention supports inactivity, deadline risk, engagement decline, drop-off, assessment difficulty, capability gaps, confidence, and coverage warnings. +- [x] Raw events remain conceptually and physically separate from metrics, insights, risks, and attention projections. + +### APIs, privacy, and UI + +- [x] Designer Monitoring APIs are tenant-scoped; Manager analytics are limited to managed Teams; Super Admin cannot access employee learning analytics. +- [x] Analytics filters support date, Team, Course, learner status, skill/competency, evidence type, and confidence where relevant. +- [x] Drill-down supports Organization → Team → Course → Module → Lesson → Block with stable URLs and a predictable back path. +- [x] Monitoring UI provides Overview, Health, Engagement, Courses, Teams, Learners, Assessments, Skills & Competencies, Risk, and Reports. +- [x] Charts use suitable forms, visible units/legends, keyboard-readable summaries, responsive layouts, and accessible table alternatives. +- [x] Every Monitoring screen includes loading, empty, insufficient-data, error, retry, FA/EN, RTL/LTR, light/dark, and reduced-motion states. + +### Phase 11 quality gates + +- [x] Backend tests cover event schema, idempotency, late events, projection rebuild, metric formulas, risk explanation, privacy, and tenant isolation. +- [x] Frontend tests cover Monitoring routes, filters, chart summaries, drill-down, empty/error states, and role authorization. +- [x] Full backend/frontend tests, PHP formatting, lint, typecheck, migrations, seed, and production build pass. +- [x] `start-dev.bat --check`, four-role smoke tests, seeded analytics smoke test, and temporary-service cleanup pass. +- [x] Visual review passes for Monitoring desktop/tablet/mobile in FA/EN, RTL/LTR, light/dark, including a 375px viewport. + +## Phase 12 — Collaboration and Realtime + +### Cross-role routing and account header corrections + +- [x] Login replaces an existing session safely and redirects only to a route compatible with the newly authenticated role. +- [x] Designer, Super Admin, Manager, and Learner can switch accounts without landing in another role's workspace or a permission dead end. +- [x] Direct navigation to an incompatible workspace redirects to the authenticated role home while backend authorization remains authoritative. +- [x] Workspace profile moves from the sidebar to the top bar beside theme and notifications, with accessible account details and Logout. +- [x] Header account controls remain usable at 375px, RTL/LTR, light/dark, keyboard, and reduced-motion settings. + +### Collaboration domain and authorization + +- [x] Multiple Designers can open the same Course Version and see active presence with stale-session expiry. +- [x] Block-level soft locks use renewable leases, prevent conflicting edits, identify the holder, and never become permanent hard locks. +- [x] Collaboration access is tenant-scoped and restricted to authorized Course Designers; published content remains immutable. +- [x] Collaboration mutations are idempotent where retry/offline behavior can duplicate a request. +- [x] The canonical Course/Version/Lesson/Block model remains the single collaboration target; no duplicate editor document is introduced. + +### Comments, review, mentions, reactions, and notifications + +- [x] Block and Course review comments support open/resolved state, threaded replies, @mentions, and reactions. +- [x] Mention targets are restricted to active same-tenant collaborators and cannot disclose cross-tenant identities. +- [x] Review Center aggregates unresolved threads, resolution state, author, target, timestamps, and stable Builder deep-links. +- [x] In-app notifications cover mentions, replies, reactions, review changes, and lock conflicts with read/unread state. +- [x] Notification links route each role to an authorized destination and degrade safely when a target is no longer available. + +### Realtime transport and UI + +- [x] Presence, soft locks, comments, and notifications publish versioned collaboration changes through a transport abstraction. +- [x] UI updates promptly when change delivery is available and falls back to bounded API refresh without losing core functionality. +- [x] Builder shows presence and lock ownership without implying Google Docs-style simultaneous text editing. +- [x] Collaboration panel and Review Center include loading, empty, error, retry, offline/degraded, and permission states. +- [x] Collaboration UI is responsive, bilingual, RTL/LTR-safe, light/dark, keyboard accessible, and uses 44px minimum targets. + +### Phase 12 quality gates + +- [x] Backend tests cover presence expiry, lock acquire/renew/release/conflict, comments, mentions, reactions, notifications, permissions, and tenant isolation. +- [x] Frontend tests cover four-role routing, header profile behavior, collaboration states, lock conflicts, review actions, and refresh fallback. +- [x] Full backend/frontend tests, PHP formatting, lint, typecheck, migrations, seed, and production build pass. +- [x] `start-dev.bat --check`, four-role account-switch smoke tests, collaboration smoke tests, and temporary-service cleanup pass. +- [x] Visual review passes for account header, Builder collaboration, and Review Center on desktop/tablet/375px in FA/EN, RTL/LTR, light/dark. + +## Phase 13 — Product Completion, AI Studio, and Document Ingestion + +### Cross-role product completion + +- [x] Designer Dashboard opens the complete Monitoring experience instead of the Foundation placeholder. +- [x] Designer Reports, Export Center, Skills & Competencies, Certificates, Brand Kit, Settings, and Templates are functional screens with real data or honest actionable empty states. +- [x] Admin Dashboard exposes real platform monitoring and Organizations, subscriptions, usage, storage, AI usage, system health, audit, and settings no longer use Foundation placeholders. +- [x] Course Workspace Learners, Analytics, Discussion, and Settings tabs are active, distinct, and explain their purpose without duplicate Settings tabs. +- [x] User directory uses an 11px dense table presentation while preserving readable mobile cards and 44px actions. + +### Branding and role-aware personalization + +- [x] Supplied MicroLearn logo is installed as the application mark, favicon, Apple touch icon, and PWA icons. +- [x] Organization Brand Kit supports logo, display name, primary/accent colors, learner welcome copy, and certificate identity. +- [x] Designer, Admin, Manager, and Learner personalization surfaces expose only role-appropriate preferences. +- [x] Branding and preferences persist with tenant/user scoping and safe defaults across FA/EN and light/dark themes. + +### Notifications + +- [x] Notifications are removed from Designer and Manager side navigation and open from an accessible top-bar popover. +- [x] Learner receives an iOS-style notification sheet from the learner header. +- [x] Manager and Learner notification cards support left/right swipe with visible non-gesture alternatives. +- [x] Notification read/dismiss actions are tenant-scoped, keyboard accessible, reduced-motion-safe, and show honest empty/error states. + +### AI provider and governance + +- [x] AI operations use a provider abstraction with explicit local/external/disabled state and no direct provider coupling in Course code. +- [x] AI operations record organization, actor, provider, operation, status, usage, error, and timing without logging private prompts unnecessarily. +- [x] Organization quota and On-Prem external-AI disablement are enforced server-side. +- [x] Generated content and taxonomy mappings are always Draft proposals requiring Designer confirmation and can never auto-publish. + +### Document ingestion and provenance + +- [x] PDF, DOCX, and PPTX uploads are privately stored, MIME-validated, size-limited, and processed through resumable/idempotent ingestion jobs. +- [x] Extraction normalizes text, identifies sections, proposes Modules/Lessons, and preserves page/slide/source-fragment provenance. +- [x] Existing SCORM is retained as packaged external learning content and is never reverse-engineered into editable Blocks. +- [x] Designers can inspect source versus proposal, edit structure, retry/cancel failures, and accept a proposal into a canonical Draft Course Version. + +### AI Studio and Builder assistance + +- [x] `/app/ai-studio` shows ingestion/generation jobs, progress, provider state, quota, retry/cancel, and source traceability. +- [x] Create Course offers Blank, Template, Import Content, and Create with AI as four working paths. +- [x] AI course creation accepts topic, audience, objective, duration, difficulty, language, tone, lesson count, assessment level, and interaction density. +- [x] Builder assistant supports Generate Lesson/Quiz, Rewrite, Shorten, Simplify, Examples, Interaction, Split, Audit, Objectives, and Assessment Alignment through preview/diff confirmation. +- [x] AI taxonomy assistance suggests existing same-tenant Skills/Competencies with rationale/confidence and persists only after explicit confirmation. + +### Phase 13 quality gates + +- [x] Backend tests cover authorization, tenant isolation, provider disabled/quota states, ingestion formats, provenance, idempotency, Draft-only acceptance, and taxonomy confirmation. +- [x] Frontend tests cover completed routes, notification popovers/swipe alternatives, Course tabs, personalization, AI Studio states, and proposal confirmation. +- [x] Full backend/frontend tests, migrations, seed, Pint, lint, typecheck, and production build pass. +- [x] Four-role smoke tests and visual review pass on desktop/tablet/375px in FA/EN, RTL/LTR, light/dark, and reduced motion. + +## Phase 14 — Export Center and Certificates + +### Export engine + +- [x] Central Export Center lists Published Course Versions, formats, queue status, progress, history, size, errors, retry, cancel, and download. +- [x] SCORM 1.2, SCORM 2004, xAPI, cmi5, Standalone HTML5, PDF Workbook, and adapter-based MP4 are represented behind canonical render adapters. +- [x] Compatibility checks run before generation and require explicit confirmation for deterministic static fallbacks. +- [x] Export generation is asynchronous, tenant-scoped, retained according to policy, and stored through the configured filesystem abstraction. +- [x] PDF Workbook includes cover, TOC, modules, lessons, static interaction representations, branding, and page footer/numbering. + +### Certificates + +- [x] Completion-rule satisfaction issues an idempotent certificate automatically and manual issuance is limited to eligible completions. +- [x] Certificates include unique number/code, issue date, optional expiry, branded PDF, QR, verification URL, and immutable issuance snapshot. +- [x] Public `/certificate/verify/:code` shows only safe verification data and clearly distinguishes valid, expired, revoked, and unknown credentials. +- [x] Certificate Builder supports branded canvas preview, layers, colors, signature identity, default templates, download, and reasoned revocation. +- [x] Certificate issuance creates a learner notification and remains tenant-isolated. + +### Phase 14 quality gates + +- [x] Backend tests cover compatibility, queue jobs, tenant isolation, artifact download, eligibility, idempotent issuance, QR verification, expiry, and revocation. +- [x] Frontend tests cover export states, warning confirmation, certificate builder/issuance, and public verification states. +- [x] Full backend/frontend tests, Pint, lint, typecheck, migration, seed, build, and browser smoke pass. + +## Phase 15 — Production Hardening, On-Prem, and Deployment + +### Runtime and security + +- [x] Production containers provide Nginx, PHP-FPM, worker, scheduler, MySQL, Redis, and S3-compatible storage with restart and health policies. +- [x] Nginx/PHP enforce 2 GB uploads, safe timeouts, security headers, private storage boundaries, SPA routing, and API proxying. +- [x] Production and On-Prem environment templates keep secrets out of source and expose deployment/storage/queue/mail/AI controls. +- [x] Health checks report API, database, storage, queue, scheduler, mail, WebSocket, export worker, and AI adapter status without exposing credentials. +- [x] On-Prem capability mode hides SaaS-only controls and defaults external AI off while supporting local or S3-compatible storage. + +### Operations and release + +- [x] Backup and restore scripts cover MySQL and private storage with explicit verification and migration steps. +- [x] Upgrade, rollback, backup/restore, incident, queue, scheduler, storage, and certificate/export runbooks are documented. +- [x] CI runs dependency audit, formatting, backend/frontend tests, lint, typecheck, migrations, and production build. +- [x] Deployment documentation includes prerequisites, TLS boundary, first boot, migrations, workers, scheduler, health, scaling, observability, and disaster recovery. +- [x] Final live smoke verifies launcher, four roles, core APIs, Export/Certificate flows, and clean runtime logs. + +### Phase 15 quality gates + +- [x] Docker Compose configuration validation and production image builds are enforced in CI; local Docker was unavailable for a duplicate build. +- [x] SaaS and On-Prem capability tests, production config checks, dependency audits, and full automated suites pass. +- [x] No implemented-scope placeholder remains; final checklist and handoff documentation are complete. diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md new file mode 100644 index 0000000..cd0c592 --- /dev/null +++ b/docs/runbooks/operations.md @@ -0,0 +1,32 @@ +# Operations Runbook + +## Health and incident triage + +1. Request `/api/v1/health` and identify the degraded dependency. +2. Check `docker compose ps` and bounded logs for `web`, `app`, `worker`, and `scheduler`. +3. If database/storage failed, stop mutating traffic before recovery. If queue/scheduler degraded, reads may remain available while workers are repaired. +4. Never paste environment files, tokens, prompts, learner content, or database dumps into tickets or chat. + +## Queue and scheduler + +- Inspect failures with `php artisan queue:failed`; retry only after fixing the cause. +- Restart workers gracefully using `php artisan queue:restart`. +- `system:heartbeat` runs every minute. A stale heartbeat indicates scheduler or cache failure. +- `exports:prune` removes only expired completed/failed/cancelled artifacts. + +## Export and certificate incidents + +- Failed exports retain a bounded error and may be retried. MP4 requires a configured render worker and fails honestly when unavailable. +- Never mark an artifact completed unless it exists on the configured disk. +- Certificate issuance is idempotent for learner + Course Version. Revocation requires a reason and retains the immutable snapshot. +- Public verification never exposes email, internal IDs or learning analytics. + +## Backup and restore + +Run `scripts/backup.sh /backups` with `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, and `DB_PASSWORD` plus private storage mounted. Copy the timestamped directory to separate encrypted storage and verify both artifacts are non-empty. + +Restore first into an isolated target with `scripts/restore.sh /backups/TIMESTAMP`. Verify migrations, health, login, one signed Asset, one export and one certificate before switching traffic. Restore overwrites state and requires an approved maintenance window. + +## Upgrade + +Read migration notes, take a verified backup, deploy the same image digest to app/worker/scheduler, run migrations once, restart workers, clear caches and perform role/API smoke tests. Keep previous images through the observation window. diff --git a/docs/screen-architecture.md b/docs/screen-architecture.md new file mode 100644 index 0000000..d68cc84 --- /dev/null +++ b/docs/screen-architecture.md @@ -0,0 +1,26 @@ +# Screen Architecture + +Screens orchestrate reusable components and route-level data boundaries. They do not own domain logic or duplicate server state. + +## Workspaces + +- `/admin`: SaaS operations and platform health +- `/app`: Course Designer creative and monitoring workspace +- `/manager`: authorized team analytics +- `/learn`: mobile-first assigned learning + +Designer navigation includes `/app/skills-taxonomy` and Monitoring > Skills & Competencies. Contextual `LearningMappingPanel` is reused for Course, Module, Lesson, Block, Question, and Assessment. + +Every meaningful route renders loading, empty, populated, error, and permission-denied states. Offline state is explicit where supported. Mutations expose pending, success, and failure. + +Desktop uses persistent workspace navigation. Tablet uses collapsible panels. Designer mobile uses a drawer; the full Builder is not compressed into an unusable layout. Learner mobile uses at most five labeled bottom-navigation destinations. + +## Course Builder + +- Real route: `/app/courses/:courseId/versions/:versionId/lessons/:lessonId/builder` +- UI-review route: `/app/builder-preview` (clearly labelled local-only sample data) +- Physical desktop layout: block library, canvas, contextual inspector, and course structure; content inside each panel follows the active document direction. +- TanStack Query owns server documents and mutations. Zustand owns selection, panel, and device-preview state only. +- DnD is progressive enhancement: every library block has a semantic button for click/keyboard insertion and canvas handles have accessible labels. +- Inspector edits are debounced before the API mutation. The server remains authoritative through `expectedRevision`; conflict responses are HTTP 409 and include the current block. +- Widths below 760px show a clear larger-screen requirement instead of a misleading compressed authoring surface. diff --git a/docs/ui-audit-phase1.md b/docs/ui-audit-phase1.md new file mode 100644 index 0000000..f6df7f3 --- /dev/null +++ b/docs/ui-audit-phase1.md @@ -0,0 +1,85 @@ +# UI Phase 1 — Audit and Design System Foundation + +Date: 2026-08-19 + +## Scope lock + +| Item | Locked scope | +| --- | --- | +| Files expected to change | Global tokens/styles, shared UI primitives, direct tests, this audit | +| Modules affected | Theme/tokens, buttons, form feedback, shared screen states | +| Behavior expected to change | Consistent scales, accessible validation descriptions, reusable loading/not-found states | +| Behavior that must remain unchanged | Routing, API contracts, business logic, backend, page workflows, app-shell layout | + +## Baseline inventory + +| Measure | Baseline | +| --- | ---: | +| TSX files | 90 | +| CSS files | 24 | +| Files using native buttons | 37 | +| Files using shared `Button` | 31 | +| Files using native form controls | 36 | +| Files using `FormField` | 8 | +| Files using `ScreenState` | 27 | +| CSS files containing raw colors | 12 | + +The project already had a sound semantic light/dark palette, Dana/Inter font stacks, visible focus rings, 44px touch targets, reduced-motion handling, modal focus management, and shared primitives. Phase 1 strengthens that existing foundation instead of replacing it. + +## Product surface audit + +| Surface | Main findings | Priority | Routed phase | +| --- | --- | --- | --- | +| Login and recovery | Strong two-column hierarchy; raw brand colors and form composition need token adoption checks | Medium | UI Phase 8 polish | +| Dashboard | Useful semantic panels exist; action hierarchy and card density need a dedicated pass | High | UI Phase 3 | +| Courses and workspace | Shared states are used; filter, action, and content-density patterns vary | High | UI Phase 6 | +| Builder | Functional and state-rich; owns several local colors/radii and needs canvas-first UX work | High | UI Phase 4 | +| Library and assets | Good loading/empty coverage; picker overlay and preview surfaces use local styling | Medium | UI Phase 6 | +| Templates and question bank | Dense native controls and local tab/action patterns reduce consistency | High | UI Phase 6 | +| Users, teams, assignments | Similar management workflows use partially different table/filter/action patterns | High | UI Phase 6 | +| Monitoring, reports, skills | Data hierarchy is serviceable; chart/filter and empty/error presentation need alignment | Medium | UI Phase 6 | +| Reviews, certificates, subscription, settings | Shared cards/states exist; destructive actions and long-form settings need pattern review | Medium | UI Phase 6 | +| AI Studio | Core flow exists, but progress, source selection, advanced settings, and review need a single wizard hierarchy | High | UI Phase 5 | +| Learner home/player/assessments | Deliberately distinct learner styling; glass-like surfaces, mobile density, and reading flow need targeted validation | High | UI Phase 7 | + +## Findings by category + +| Category | Status | Finding | +| --- | --- | --- | +| Consistency | Needs improvement | Shared primitives coexist with many page-local native controls; bulk replacement is deferred to page phases. | +| Hierarchy | Mixed | Global page/section typography existed but was not represented as a reusable token scale. | +| Density | Mixed | Spacing was mostly tokenized, but the scale skipped 20, 40, and 48 pixels. | +| Typography | Good foundation | Dana and Inter are preserved; page, section, card, body, secondary, and caption sizes are now explicit. | +| Forms | Improved | Labels, required markers, focus, disabled, and error styles existed; helper text was lost whenever an error was present. | +| Interactive states | Improved | Focus and disabled states existed; shared buttons now expose compatible size variants and an async loading contract. | +| Loading/empty/error | Good foundation | Skeleton and ScreenState were already reused broadly; loading, success, and not-found semantics are now available. | +| RTL/LTR | Good foundation | Logical CSS properties and language font tokens are established. Page-level exceptions remain phase-specific. | +| Dark mode | Good foundation | Semantic dark tokens exist; elevated, interactive, and subtle-text layers were added explicitly. | +| Accessibility | Improved | Visible focus, reduced motion, modal focus trap, and touch targets exist; form helper/error associations are now complete. | + +## Foundation contract + +- Surfaces: canvas, surface, elevated, interactive, soft, accent, overlay. +- Semantic color: primary, success, warning/attention, danger, info. +- Spacing: 4, 8, 12, 16, 20, 24, 32, 40, 48. +- Radius: 8, 12, 16, 20; the previous 18px value remains available as a compatibility alias. +- Typography: page, section, card, body, secondary, caption with four documented weights. +- Motion: 150–200ms with standard and exit easing; reduced motion remains enforced globally. +- Layering: base, sticky, drawer, popover, tooltip, modal, toast. +- Buttons: primary, secondary, outline, ghost, danger, icon-compatible styling; small, medium, and large sizing; loading semantics. +- Forms: persistent helper text, adjacent validation message, required marker, `aria-describedby`, and `aria-errormessage`. +- States: skeleton plus empty, loading, error, offline, permission, not-found, and success screen states. + +## Deferred technical debt + +| Item | Severity | Reason deferred | +| --- | --- | --- | +| Replace or normalize page-local native controls | Medium | Requires page-level visual and behavioral regression checks in Phases 3–7. | +| Remove remaining raw colors and arbitrary radii | Medium | Some values are media/certificate/user-configurable colors; each needs contextual review. | +| Standardize table, tabs, select, checkbox, switch, tooltip, drawer, pagination, search, and filter APIs | High | No complete shared implementations exist; creating them without their consuming page phase risks unused abstractions. | +| Validate every learner glass/blur surface in dark mode | Medium | Belongs to learner and final polish phases. | +| Route-level lazy loading | Medium | Performance change is outside the visual foundation scope and needs measurement first. | + +## Before / after UX summary + +Before, the product had a capable but implicit foundation: common colors and controls existed, while typography, spacing, surfaces, layering, async buttons, and validation relationships were only partially codified. After Phase 1, those contracts are explicit and test-covered, existing component APIs remain compatible, and later page phases have a stable base without a broad visual rewrite. diff --git a/docs/visual-review/phase10-learner-home-ios-desktop-ltr.png b/docs/visual-review/phase10-learner-home-ios-desktop-ltr.png new file mode 100644 index 0000000..965030f Binary files /dev/null and b/docs/visual-review/phase10-learner-home-ios-desktop-ltr.png differ diff --git a/docs/visual-review/phase10-learner-home-ios-small.png b/docs/visual-review/phase10-learner-home-ios-small.png new file mode 100644 index 0000000..b061328 Binary files /dev/null and b/docs/visual-review/phase10-learner-home-ios-small.png differ diff --git a/docs/visual-review/phase10-learner-more-ios-dark.png b/docs/visual-review/phase10-learner-more-ios-dark.png new file mode 100644 index 0000000..7269a23 Binary files /dev/null and b/docs/visual-review/phase10-learner-more-ios-dark.png differ diff --git a/docs/visual-review/phase10-learner-player-ios.png b/docs/visual-review/phase10-learner-player-ios.png new file mode 100644 index 0000000..da8a2d5 Binary files /dev/null and b/docs/visual-review/phase10-learner-player-ios.png differ diff --git a/docs/visual-review/phase10-manager-attention-mobile-rtl.png b/docs/visual-review/phase10-manager-attention-mobile-rtl.png new file mode 100644 index 0000000..6b07426 Binary files /dev/null and b/docs/visual-review/phase10-manager-attention-mobile-rtl.png differ diff --git a/docs/visual-review/phase10-manager-overview-desktop-ltr.png b/docs/visual-review/phase10-manager-overview-desktop-ltr.png new file mode 100644 index 0000000..1d7b4c3 Binary files /dev/null and b/docs/visual-review/phase10-manager-overview-desktop-ltr.png differ diff --git a/docs/visual-review/phase10-manager-overview-desktop-rtl.png b/docs/visual-review/phase10-manager-overview-desktop-rtl.png new file mode 100644 index 0000000..0ba6061 Binary files /dev/null and b/docs/visual-review/phase10-manager-overview-desktop-rtl.png differ diff --git a/docs/visual-review/phase10-manager-team-tablet-dark.png b/docs/visual-review/phase10-manager-team-tablet-dark.png new file mode 100644 index 0000000..05602c3 Binary files /dev/null and b/docs/visual-review/phase10-manager-team-tablet-dark.png differ diff --git a/docs/visual-review/phase11-monitoring-assessments-tablet-dark.png b/docs/visual-review/phase11-monitoring-assessments-tablet-dark.png new file mode 100644 index 0000000..a099e9a Binary files /dev/null and b/docs/visual-review/phase11-monitoring-assessments-tablet-dark.png differ diff --git a/docs/visual-review/phase11-monitoring-overview-desktop-ltr.png b/docs/visual-review/phase11-monitoring-overview-desktop-ltr.png new file mode 100644 index 0000000..ef76b04 Binary files /dev/null and b/docs/visual-review/phase11-monitoring-overview-desktop-ltr.png differ diff --git a/docs/visual-review/phase11-monitoring-overview-desktop-rtl.png b/docs/visual-review/phase11-monitoring-overview-desktop-rtl.png new file mode 100644 index 0000000..2a3bd7e Binary files /dev/null and b/docs/visual-review/phase11-monitoring-overview-desktop-rtl.png differ diff --git a/docs/visual-review/phase11-monitoring-risk-mobile-rtl.png b/docs/visual-review/phase11-monitoring-risk-mobile-rtl.png new file mode 100644 index 0000000..9a7e90b Binary files /dev/null and b/docs/visual-review/phase11-monitoring-risk-mobile-rtl.png differ diff --git a/docs/visual-review/phase11-monitoring-skills-375-dark.png b/docs/visual-review/phase11-monitoring-skills-375-dark.png new file mode 100644 index 0000000..7ebcab6 Binary files /dev/null and b/docs/visual-review/phase11-monitoring-skills-375-dark.png differ diff --git a/docs/visual-review/phase3-subscription-ltr.png b/docs/visual-review/phase3-subscription-ltr.png new file mode 100644 index 0000000..1ef619f Binary files /dev/null and b/docs/visual-review/phase3-subscription-ltr.png differ diff --git a/docs/visual-review/phase3-teams-mobile-dark.png b/docs/visual-review/phase3-teams-mobile-dark.png new file mode 100644 index 0000000..068a1ce Binary files /dev/null and b/docs/visual-review/phase3-teams-mobile-dark.png differ diff --git a/docs/visual-review/phase3-users-desktop.png b/docs/visual-review/phase3-users-desktop.png new file mode 100644 index 0000000..35eec07 Binary files /dev/null and b/docs/visual-review/phase3-users-desktop.png differ diff --git a/docs/visual-review/phase4-courses-desktop.png b/docs/visual-review/phase4-courses-desktop.png new file mode 100644 index 0000000..315fe6f Binary files /dev/null and b/docs/visual-review/phase4-courses-desktop.png differ diff --git a/docs/visual-review/phase4-new-course-mobile-dark.png b/docs/visual-review/phase4-new-course-mobile-dark.png new file mode 100644 index 0000000..ee423e9 Binary files /dev/null and b/docs/visual-review/phase4-new-course-mobile-dark.png differ diff --git a/docs/visual-review/phase4-workspace-ltr.png b/docs/visual-review/phase4-workspace-ltr.png new file mode 100644 index 0000000..af96377 Binary files /dev/null and b/docs/visual-review/phase4-workspace-ltr.png differ diff --git a/docs/visual-review/phase5-builder-desktop-rtl.png b/docs/visual-review/phase5-builder-desktop-rtl.png new file mode 100644 index 0000000..66e69e5 Binary files /dev/null and b/docs/visual-review/phase5-builder-desktop-rtl.png differ diff --git a/docs/visual-review/phase5-builder-mobile-preview-ltr.png b/docs/visual-review/phase5-builder-mobile-preview-ltr.png new file mode 100644 index 0000000..996b0cd Binary files /dev/null and b/docs/visual-review/phase5-builder-mobile-preview-ltr.png differ diff --git a/docs/visual-review/phase5-builder-tablet-dark.png b/docs/visual-review/phase5-builder-tablet-dark.png new file mode 100644 index 0000000..141b45c Binary files /dev/null and b/docs/visual-review/phase5-builder-tablet-dark.png differ diff --git a/docs/visual-review/phase6-builder-accessibility-rtl-dark.png b/docs/visual-review/phase6-builder-accessibility-rtl-dark.png new file mode 100644 index 0000000..5e43a10 Binary files /dev/null and b/docs/visual-review/phase6-builder-accessibility-rtl-dark.png differ diff --git a/docs/visual-review/phase6-builder-navigator-ltr.png b/docs/visual-review/phase6-builder-navigator-ltr.png new file mode 100644 index 0000000..ca2cc15 Binary files /dev/null and b/docs/visual-review/phase6-builder-navigator-ltr.png differ diff --git a/docs/visual-review/phase6-library-desktop-rtl.png b/docs/visual-review/phase6-library-desktop-rtl.png new file mode 100644 index 0000000..2e34d61 Binary files /dev/null and b/docs/visual-review/phase6-library-desktop-rtl.png differ diff --git a/docs/visual-review/phase6-library-mobile-ltr-dark.png b/docs/visual-review/phase6-library-mobile-ltr-dark.png new file mode 100644 index 0000000..26a399e Binary files /dev/null and b/docs/visual-review/phase6-library-mobile-ltr-dark.png differ diff --git a/docs/visual-review/phase7-assessment-mapping-rtl.png b/docs/visual-review/phase7-assessment-mapping-rtl.png new file mode 100644 index 0000000..ec00bb4 Binary files /dev/null and b/docs/visual-review/phase7-assessment-mapping-rtl.png differ diff --git a/docs/visual-review/phase7-question-bank-rtl.png b/docs/visual-review/phase7-question-bank-rtl.png new file mode 100644 index 0000000..bf7a5a4 Binary files /dev/null and b/docs/visual-review/phase7-question-bank-rtl.png differ diff --git a/docs/visual-review/phase7-scenarios-mobile-dark.png b/docs/visual-review/phase7-scenarios-mobile-dark.png new file mode 100644 index 0000000..364b852 Binary files /dev/null and b/docs/visual-review/phase7-scenarios-mobile-dark.png differ diff --git a/docs/visual-review/phase9-learner-home-desktop-rtl.png b/docs/visual-review/phase9-learner-home-desktop-rtl.png new file mode 100644 index 0000000..c6b2bed Binary files /dev/null and b/docs/visual-review/phase9-learner-home-desktop-rtl.png differ diff --git a/docs/visual-review/phase9-learner-home-mobile-dark.png b/docs/visual-review/phase9-learner-home-mobile-dark.png new file mode 100644 index 0000000..8bcf2ee Binary files /dev/null and b/docs/visual-review/phase9-learner-home-mobile-dark.png differ diff --git a/docs/visual-review/phase9-player-desktop-rtl.png b/docs/visual-review/phase9-player-desktop-rtl.png new file mode 100644 index 0000000..d1ce2a4 Binary files /dev/null and b/docs/visual-review/phase9-player-desktop-rtl.png differ diff --git a/docs/visual-review/phase9-player-mobile-dark.png b/docs/visual-review/phase9-player-mobile-dark.png new file mode 100644 index 0000000..13c1c7f Binary files /dev/null and b/docs/visual-review/phase9-player-mobile-dark.png differ diff --git a/docs/visual-review/pre-phase7-course-cover.png b/docs/visual-review/pre-phase7-course-cover.png new file mode 100644 index 0000000..f7a1f23 Binary files /dev/null and b/docs/visual-review/pre-phase7-course-cover.png differ diff --git a/docs/visual-review/pre-phase7-courses-compact.png b/docs/visual-review/pre-phase7-courses-compact.png new file mode 100644 index 0000000..705b383 Binary files /dev/null and b/docs/visual-review/pre-phase7-courses-compact.png differ diff --git a/docs/visual-review/pre-phase7-library-cards-5cm.png b/docs/visual-review/pre-phase7-library-cards-5cm.png new file mode 100644 index 0000000..2e34d61 Binary files /dev/null and b/docs/visual-review/pre-phase7-library-cards-5cm.png differ diff --git a/docs/visual-review/pre-phase7-library-rows.png b/docs/visual-review/pre-phase7-library-rows.png new file mode 100644 index 0000000..3e9db37 Binary files /dev/null and b/docs/visual-review/pre-phase7-library-rows.png differ diff --git a/docs/visual-review/pre-phase7-teams-enhanced.png b/docs/visual-review/pre-phase7-teams-enhanced.png new file mode 100644 index 0000000..02aeee5 Binary files /dev/null and b/docs/visual-review/pre-phase7-teams-enhanced.png differ diff --git a/docs/visual-review/pre-phase7-users-import.png b/docs/visual-review/pre-phase7-users-import.png new file mode 100644 index 0000000..6e17919 Binary files /dev/null and b/docs/visual-review/pre-phase7-users-import.png differ diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/e2e/final-phase-smoke.spec.ts b/frontend/e2e/final-phase-smoke.spec.ts new file mode 100644 index 0000000..0b3ffd2 --- /dev/null +++ b/frontend/e2e/final-phase-smoke.spec.ts @@ -0,0 +1,46 @@ +import { expect, test } from '@playwright/test' + +test.describe.configure({ mode: 'serial' }) +let validCertificate: { verificationCode: string; number: string } | undefined + +test('designer can open final export and certificate workspaces', async ({ page }) => { + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill('designer@microlearn.test') + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(/\/app\/dashboard$/) + + await page.goto('http://127.0.0.1:5173/app/exports') + await expect(page.getByRole('heading', { name: 'مرکز خروجی' })).toBeVisible() + await expect(page.getByText('تاریخچه خروجی‌ها')).toBeVisible() + + await page.goto('http://127.0.0.1:5173/app/certificates') + await expect(page.getByRole('heading', { name: 'گواهی‌نامه‌ها' })).toBeVisible() + await page.getByRole('button', { name: /قالب‌ها/ }).click() + await expect(page.getByRole('heading', { name: 'قالب جدید' })).toBeVisible() + validCertificate = await page.evaluate(async () => { + const token = window.localStorage.getItem('microlearn.access_token') + const response = await fetch('/api/v1/certificates', { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } }) + const body = await response.json() + return body.data.items.find((item: { revokedAt?: string }) => !item.revokedAt) + }) +}) + +test('final workspaces remain usable at iPhone width', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill('designer@microlearn.test') + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(/\/app\/dashboard$/) + await page.goto('http://127.0.0.1:5173/app/exports') + await expect(page.getByRole('heading', { name: 'مرکز خروجی' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) +}) + +test('public certificate verification exposes a valid safe result', async ({ page }) => { + expect(validCertificate).toBeTruthy() + await page.goto(`http://127.0.0.1:5173/certificate/verify/${validCertificate!.verificationCode}`) + await expect(page.getByRole('heading', { name: 'گواهی معتبر است' })).toBeVisible() + await expect(page.getByText(validCertificate!.number)).toBeVisible() +}) diff --git a/frontend/e2e/learner-more.spec.ts b/frontend/e2e/learner-more.spec.ts new file mode 100644 index 0000000..4a968b3 --- /dev/null +++ b/frontend/e2e/learner-more.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test' + +const learner = { + id: 'learner-1', name: 'سارا احمدی', email: 'sara.ahmadi@company.com', role: 'learner', status: 'active', locale: 'fa', timezone: 'Asia/Tehran', permissions: [], + organization: { id: 'org-1', name: 'MicroLearn', slug: 'microlearn', defaultLocale: 'fa', timezone: 'Asia/Tehran' }, + deployment: { mode: 'saas', supportsMultipleOrganizations: true, exposesSubscriptionManagement: true }, +} + +test('learner More is responsive and its personalization controls work', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.route('**/api/v1/**', route => { + const pathname = new URL(route.request().url()).pathname + const data = pathname.endsWith('/auth/me') ? learner : pathname.endsWith('/learner/home') ? { + continueLearning: null, + assigned: [], + dueSoon: [], + summary: { total: 0, completed: 0, inProgress: 0, averageProgress: 0 }, + } : {} + return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ data }) }) + }) + await page.addInitScript(() => localStorage.setItem('microlearn.access_token', 'e2e-token')) + await page.goto('http://127.0.0.1:5173/learn/more') + + await expect(page.getByRole('heading', { name: 'بیشتر', level: 1 })).toBeVisible() + await expect(page.getByRole('heading', { name: 'شخصی‌سازی تجربه من' })).toBeVisible() + await expect(page.getByText('سارا احمدی')).toBeVisible() + await expect(page.getByRole('button', { name: 'خروج از حساب کاربری' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + if (process.env.TEMP) await page.screenshot({ path: `${process.env.TEMP}\\microlearn-more-page.png`, fullPage: true }) + + await page.getByRole('button', { name: /اندازه متن/ }).click() + await expect(page.getByRole('button', { name: /اندازه متن: بزرگ/ })).toBeVisible() + await page.getByRole('button', { name: /ترجیحات یادگیری/ }).first().click() + await expect(page.getByRole('dialog', { name: 'ترجیحات یادگیری' })).toBeVisible() + await page.getByRole('button', { name: '۱۵ دقیقه' }).click() + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('microlearn.learner-preferences') ?? '{}').state.dailyDuration)).toBe(15) + if (process.env.TEMP) await page.screenshot({ path: `${process.env.TEMP}\\microlearn-more-modal.png`, fullPage: true }) + await page.getByRole('dialog', { name: 'ترجیحات یادگیری' }).getByRole('button', { name: 'بستن' }).click() + await page.setViewportSize({ width: 320, height: 700 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) +}) diff --git a/frontend/e2e/manager-phase1.spec.ts b/frontend/e2e/manager-phase1.spec.ts new file mode 100644 index 0000000..251a0ea --- /dev/null +++ b/frontend/e2e/manager-phase1.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +async function signInAsManager(page: Page) { + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill('manager@microlearn.test') + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(/\/manager\/overview$/) +} + +test('manager dashboard has scoped navigation and personal learning', async ({ page }) => { + await signInAsManager(page) + const navigation = page.getByRole('navigation', { name: /ناوبری اصلی|Primary navigation/ }) + await expect(page.getByRole('heading', { level: 1, name: /^سلام/ })).toBeVisible() + await expect(page.getByRole('heading', { name: /نیازمند توجه|Needs attention/ })).toBeVisible() + await expect(navigation.getByRole('link', { name: /^(دوره‌های من|My learning)$/ })).toBeVisible() + await expect(navigation.getByRole('link', { name: /^(تیم من|My team)$/ })).toBeVisible() + await expect(navigation.getByRole('link', { name: /سازنده دوره|Course builder/ })).toHaveCount(0) + await expect(navigation.getByRole('link', { name: /استودیوی هوش مصنوعی|AI Studio/ })).toHaveCount(0) + + await navigation.getByRole('link', { name: /^(دوره‌های من|My learning)$/ }).click() + await expect(page).toHaveURL(/\/manager\/my-learning$/) + await expect(page.getByRole('heading', { level: 1, name: /دوره‌های من|My learning/ })).toBeVisible() + + for (const viewport of [{ width: 375, height: 812 }, { width: 768, height: 1024 }, { width: 1440, height: 1000 }]) { + await page.setViewportSize(viewport) + await page.goto('http://127.0.0.1:5173/manager/overview') + await expect(page.getByRole('heading', { level: 1, name: /^سلام/ })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + } +}) diff --git a/frontend/e2e/manager-phase2.spec.ts b/frontend/e2e/manager-phase2.spec.ts new file mode 100644 index 0000000..93a6123 --- /dev/null +++ b/frontend/e2e/manager-phase2.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +async function signInAsManager(page: Page) { + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill('manager@microlearn.test') + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(/\/manager\/overview$/) +} + +test('manager team directory and employee learning profile are responsive and scoped', async ({ page }) => { + await signInAsManager(page) + await page.goto('http://127.0.0.1:5173/manager/team') + await expect(page.getByRole('heading', { level: 1, name: /تیم من|My team/ })).toBeVisible() + await expect(page.getByRole('searchbox', { name: /جست‌وجوی عضو|Search members/ })).toBeVisible() + await expect(page.getByRole('combobox', { name: /وضعیت عضو|Member status/ })).toBeVisible() + await expect(page.locator('.manager-team-table')).toBeVisible() + + await page.locator('.manager-team-table').getByRole('button', { name: /مشاهده پروفایل|View profile/ }).first().click() + await expect(page).toHaveURL(/\/manager\/team\?member=/) + await expect(page.locator('#manager-profile-title')).toBeVisible() + await page.getByRole('navigation', { name: /بخش‌های پروفایل یادگیری|Learning profile sections/ }).getByRole('button', { name: /یادگیری فعال|Active learning/ }).click() + await expect(page).toHaveURL(/tab=active/) + await expect(page.getByText(/تخصیص آموزش|Assign learning/)).toHaveCount(0) + await expect(page.getByText(/ارسال یادآوری|Send reminder/)).toHaveCount(0) + + for (const viewport of [{ width: 375, height: 812 }, { width: 768, height: 1024 }, { width: 1440, height: 1000 }]) { + await page.setViewportSize(viewport) + await page.goto('http://127.0.0.1:5173/manager/team') + await expect(page.getByRole('heading', { level: 1, name: /تیم من|My team/ })).toBeVisible() + const layout = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + offenders: [...document.querySelectorAll('body *')].filter(element => element.getBoundingClientRect().right > document.documentElement.clientWidth + 1 || element.getBoundingClientRect().left < -1).slice(0, 8).map(element => ({ className: element.className, tag: element.tagName, left: Math.round(element.getBoundingClientRect().left), right: Math.round(element.getBoundingClientRect().right) })), + })) + expect(layout, JSON.stringify({ viewport, layout })).toMatchObject({ scrollWidth: layout.clientWidth }) + } +}) diff --git a/frontend/e2e/manager-phase3.spec.ts b/frontend/e2e/manager-phase3.spec.ts new file mode 100644 index 0000000..f4df535 --- /dev/null +++ b/frontend/e2e/manager-phase3.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' + +async function signInAsManager(page: Page) { + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill('manager@microlearn.test') + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(/\/manager\/overview$/) +} + +test('manager assignment wizard exposes the complete five-step scoped flow', async ({ page }) => { + await signInAsManager(page) + await page.goto('http://127.0.0.1:5173/manager/assignments') + await expect(page.getByRole('heading', { level: 1, name: /تخصیص آموزش|Assign learning/ })).toBeVisible() + await page.getByRole('button', { name: /تخصیص جدید|New assignment/ }).click() + + for (const heading of [/مخاطب|Audience/, /آموزش|Learning/, /مهلت|Deadline/, /اعلان|Notification/]) { + await expect(page.getByRole('heading', { level: 2, name: heading })).toBeVisible() + await page.getByRole('button', { name: /ادامه|Continue/ }).click() + } + + await expect(page.getByRole('heading', { level: 2, name: /بازبینی|Review/ })).toBeVisible() + await expect(page.getByText(/تخصیص‌های فعال تکراری|Duplicate active assignments/)).toBeVisible() + await expect(page.getByRole('button', { name: /تأیید و تخصیص|Confirm and assign/ })).toBeEnabled() +}) + +test('manager assignments remain free of page-level overflow at target widths', async ({ page }) => { + await signInAsManager(page) + + for (const viewport of [{ width: 375, height: 812 }, { width: 768, height: 1024 }, { width: 1440, height: 1000 }]) { + await page.setViewportSize(viewport) + await page.goto('http://127.0.0.1:5173/manager/assignments') + await expect(page.getByRole('heading', { level: 1, name: /تخصیص آموزش|Assign learning/ })).toBeVisible() + await page.getByRole('button', { name: /تخصیص جدید|New assignment/ }).click() + await expect(page.getByRole('heading', { level: 2, name: /مخاطب|Audience/ })).toBeVisible() + + const layout = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + offenders: [...document.querySelectorAll('body *')] + .filter(element => element.getBoundingClientRect().right > document.documentElement.clientWidth + 1 || element.getBoundingClientRect().left < -1) + .slice(0, 8) + .map(element => ({ className: element.className, tag: element.tagName, left: Math.round(element.getBoundingClientRect().left), right: Math.round(element.getBoundingClientRect().right) })), + })) + + expect(layout, JSON.stringify({ viewport, layout })).toMatchObject({ scrollWidth: layout.clientWidth, offenders: [] }) + } +}) diff --git a/frontend/e2e/organization-settings.spec.ts b/frontend/e2e/organization-settings.spec.ts new file mode 100644 index 0000000..63cd384 --- /dev/null +++ b/frontend/e2e/organization-settings.spec.ts @@ -0,0 +1,48 @@ +import { expect, test } from '@playwright/test' + +const user = { + id: 'u1', name: 'سارا محمدی', email: 'sara@example.test', role: 'course_designer', status: 'active', locale: 'fa', timezone: 'Asia/Tehran', + permissions: ['courses.author'], organization: { id: 'o1', name: 'آکادمی ایمن', slug: 'safe', defaultLocale: 'fa', timezone: 'Asia/Tehran' }, + deployment: { mode: 'saas', supportsMultipleOrganizations: true, exposesSubscriptionManagement: true }, +} +const settings = { + general: { locale: 'fa', timezone: 'Asia/Tehran', calendar: 'jalali', firstDayOfWeek: 'saturday', dateFormat: 'short', numberFormat: 'persian', hourCycle: '24', theme: 'system', density: 'standard', fontScale: 'standard', highContrast: false, focusGuide: true, reduceMotion: false }, + courseDefaults: { language: 'fa', difficulty: 'beginner', presentationMode: 'flow', completionMode: 'all_required', passingScore: 70, resumeLearning: true, showProgress: true }, + assignments: { mandatory: true, dueDays: 14, reminderDays: 3, managerEscalation: true, overduePolicy: 'keep_active', reassignmentPolicy: 'preserve_progress' }, + learner: { notes: true, bookmarks: true, favorites: true, discussions: true, downloads: false, offlineLearning: false, videoAutoplay: false }, + certificates: { issuance: 'approval', validityMonths: 0, renewal: true, verificationQr: true, notifyLearner: true }, + notifications: { email: true, push: true, inApp: true, digest: 'weekly', quietHours: '22-07', overdue: true, risk: true }, + analytics: { defaultRange: 30, riskThreshold: 50, rebuildFrequency: 'daily', exportRetentionDays: 90, managerAccess: 'team_only' }, + security: { sessionMinutes: 480, twoFactor: 'required_admins', defaultRole: 'learner', downloadPolicy: 'designers', auditRetentionDays: 365 }, + media: { maxFileMb: 200, videoQuality: 'adaptive', allowedDocuments: 'standard', retentionPolicy: 'keep', imageOptimization: true }, +} + +async function mockSettings(page: import('@playwright/test').Page) { + await page.addInitScript(() => window.localStorage.setItem('microlearn.access_token', 'visual-review')) + await page.route('**/api/v1/**', async route => { + const url = route.request().url() + const data = url.endsWith('/auth/me') ? user : url.endsWith('/organization-settings') ? { settings } : {} + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data }) }) + }) +} + +test('organization settings is a focused responsive control center', async ({ page }, testInfo) => { + await mockSettings(page) + await page.setViewportSize({ width: 1440, height: 1000 }) + await page.goto('http://127.0.0.1:5173/app/settings?section=appearance') + + await expect(page.getByRole('heading', { name: 'تنظیمات سامانه' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'ظاهر و دسترس‌پذیری' })).toBeVisible() + await expect(page.locator('.workspace > .sidebar')).toBeHidden() + await expect(page.locator('.workspace__body > .topbar')).toBeHidden() + await expect(page.locator('.appearance-preview')).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + await page.screenshot({ path: testInfo.outputPath('settings-desktop.png'), fullPage: true }) + + await page.setViewportSize({ width: 375, height: 812 }) + await expect(page.getByRole('heading', { name: 'ظاهر و دسترس‌پذیری' })).toBeVisible() + await page.getByRole('radio', { name: 'تاریک' }).click() + await expect(page.getByRole('region', { name: 'ذخیره تغییرات' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + await page.screenshot({ path: testInfo.outputPath('settings-mobile.png'), fullPage: true }) +}) diff --git a/frontend/e2e/phase13-smoke.spec.ts b/frontend/e2e/phase13-smoke.spec.ts new file mode 100644 index 0000000..9642543 --- /dev/null +++ b/frontend/e2e/phase13-smoke.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from '@playwright/test' + +const accounts = [ + { email: 'admin@microlearn.test', home: '/admin/dashboard' }, + { email: 'designer@microlearn.test', home: '/app/dashboard' }, + { email: 'manager@microlearn.test', home: '/manager/overview' }, + { email: 'maryam@microlearn.test', home: '/learn/home' }, +] + +for (const account of accounts) { + test(`${account.email} signs in to its workspace and signs out`, async ({ page }) => { + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill(account.email) + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(new RegExp(account.home.replaceAll('/', '\\/'))) + await page.getByRole('button', { name: /بازکردن حساب کاربری|Open account/ }).click() + await page.getByRole(account.email === 'maryam@microlearn.test' ? 'button' : 'menuitem', { name: /خروج از حساب|Sign out/ }).click() + await expect(page).toHaveURL(/\/login$/, { timeout: 15_000 }) + }) +} + +test('designer phase 13 surfaces are responsive and free of horizontal overflow', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill('designer@microlearn.test') + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(/\/app\/dashboard$/) + await page.goto('http://127.0.0.1:5173/app/ai-studio') + await expect(page.getByRole('heading', { name: 'استودیوی هوش مصنوعی' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + await page.screenshot({ path: 'phase13-ai-studio-375.png', fullPage: true }) +}) diff --git a/frontend/e2e/phase5-navigation-dashboard.spec.ts b/frontend/e2e/phase5-navigation-dashboard.spec.ts new file mode 100644 index 0000000..fd9c2ba --- /dev/null +++ b/frontend/e2e/phase5-navigation-dashboard.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test' + +test('designer navigation and dashboard stay usable on desktop and mobile', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill('designer@microlearn.test') + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + + await expect(page).toHaveURL(/\/app\/dashboard$/) + await expect(page.getByRole('heading', { name: 'داشبورد طراحی' })).toBeVisible() + await expect(page.getByText('ساخت', { exact: true })).toBeVisible() + await expect(page.getByText('افراد', { exact: true })).toBeVisible() + await expect(page.getByText('بینش', { exact: true })).toBeVisible() + await expect(page.getByText('مدیریت', { exact: true })).toBeVisible() + await expect(page.getByRole('link', { name: 'ایجاد دوره' })).toHaveAttribute('href', '/app/courses/new') + await expect(page.getByRole('region', { name: 'اقدام‌های سریع' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'نیازمند توجه' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'دوره‌های اخیر' })).toBeVisible() + + const attentionBeforeRecent = await page.evaluate(() => { + const headings = [...document.querySelectorAll('h2')] + const attention = headings.find((heading) => heading.textContent?.trim() === 'نیازمند توجه') + const recent = headings.find((heading) => heading.textContent?.trim() === 'دوره‌های اخیر') + + return Boolean(attention && recent && (attention.compareDocumentPosition(recent) & Node.DOCUMENT_POSITION_FOLLOWING)) + }) + expect(attentionBeforeRecent).toBe(true) + + for (const width of [375, 430, 768, 1024, 1440]) { + await page.setViewportSize({ width, height: 900 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + } + + await page.setViewportSize({ width: 375, height: 812 }) + await page.getByRole('button', { name: 'باز کردن منو' }).click() + await expect(page.getByRole('navigation', { name: 'ناوبری اصلی' })).toBeVisible() +}) diff --git a/frontend/e2e/phase6-builder-ai-studio.spec.ts b/frontend/e2e/phase6-builder-ai-studio.spec.ts new file mode 100644 index 0000000..62b4cd0 --- /dev/null +++ b/frontend/e2e/phase6-builder-ai-studio.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test' + +async function login(page: import('@playwright/test').Page) { + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill('designer@microlearn.test') + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(/\/app\/dashboard$/) +} + +test('builder uses the production authoring workspace architecture', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await login(page) + await page.goto('http://127.0.0.1:5173/app/builder-preview') + await expect(page.getByRole('heading', { name: 'بهترین روش‌های ایمنی در محیط کار' })).toBeVisible() + await expect(page.getByRole('banner')).toContainText('ذخیره خودکار فعال است') + await expect(page.getByRole('complementary', { name: 'ساختار دوره' })).toBeVisible() + await page.getByRole('button', { name: 'افزودن', exact: true }).click() + const library = page.getByRole('dialog', { name: 'کتابخانه بلاک‌ها' }) + const search = library.getByRole('searchbox', { name: 'جست‌وجوی بلاک‌ها' }) + await expect(search).toBeVisible() + await search.fill('تصویر') + await expect(library.getByRole('button', { name: 'افزودن تصویر', exact: true })).toBeVisible() + await expect(library.getByRole('button', { name: 'افزودن متن', exact: true })).toHaveCount(0) + await search.fill('') + + await library.getByLabel('بستن کتابخانه').click() + await page.getByRole('heading', { name: 'بهترین روش‌های ایمنی در محیط کار' }).click() + const panel = page.getByLabel('تنظیمات زمینه‌ای') + await expect(panel.getByRole('button', { name: 'محتوا' })).toBeVisible() + await expect(panel.getByRole('button', { name: 'دسترس‌پذیری' })).toBeVisible() + await panel.getByRole('button', { name: 'طراحی' }).click() + await panel.getByLabel('اندازه فونت').fill('44') + await expect(page.locator('.canvas-block.is-selected .render-heading')).toHaveCSS('font-size', '44px') + await expect(panel.getByText(/ذخیره شد · ویرایش/)).toBeVisible() + + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) + await page.keyboard.press('/') + await expect(page.getByRole('dialog', { name: 'انتخاب بلاک' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog', { name: 'انتخاب بلاک' })).toHaveCount(0) + await page.getByRole('heading', { name: 'بهترین روش‌های ایمنی در محیط کار' }).click() + const canvasShare = await page.evaluate(() => { + const workspace = document.querySelector('.builder-workspace')?.getBoundingClientRect() + const canvas = document.querySelector('.builder-canvas-wrap')?.getBoundingClientRect() + return workspace && canvas ? canvas.width / workspace.width : 0 + }) + expect(canvasShare).toBeGreaterThan(0.35) + await expect(page.getByText(/زمان تخمینی:/)).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) +}) + +test('AI Studio four-stage workflow stays usable on a narrow viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await login(page) + await page.goto('http://127.0.0.1:5173/app/ai-studio') + await expect(page.getByRole('heading', { name: 'استودیوی هوش مصنوعی' })).toBeVisible() + const workflow = page.getByRole('list', { name: 'مراحل ساخت با هوش مصنوعی' }) + await expect(workflow).toBeVisible() + await expect(workflow.getByText('منبع').locator('..')).toHaveAttribute('aria-current', 'step') + await expect(page.getByLabel('مخاطب')).toBeVisible() + await page.getByLabel('مخاطب').selectOption('managers') + await page.getByLabel('موضوع دوره').fill('ایمنی محیط کار') + await page.getByRole('button', { name: 'ادامه به طراحی یادگیری' }).click() + await expect(page.getByRole('heading', { name: 'طراحی دوره' })).toBeVisible() + await expect(page.getByLabel('مخاطب')).toHaveCount(0) + await expect(page.getByText('مدیران', { exact: true })).toBeVisible() + await page.getByRole('button', { name: 'ادامه به مرور درخواست' }).click() + await expect(page.getByRole('heading', { name: 'مرور درخواست' })).toBeVisible() + await expect(page.getByText('مدیران', { exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: /شروع تولید/ })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + await expect(page.locator('html')).toHaveAttribute('dir', 'rtl') + await page.locator('html').evaluate(element => element.setAttribute('dir', 'ltr')) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + + await page.evaluate(() => window.localStorage.setItem('microlearn.theme', 'dark')) + await page.reload() + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') + await expect(page.locator('.ai-course-details')).toBeVisible() + const darkSurfaceColors = await page.evaluate(() => { + const step = document.querySelector('.ai-course-details') + const probe = document.createElement('div') + probe.style.background = 'var(--surface)' + document.body.append(probe) + const colors = { step: step ? getComputedStyle(step).backgroundColor : 'missing', token: getComputedStyle(probe).backgroundColor } + probe.remove() + return colors + }) + expect(darkSurfaceColors.step).toBe(darkSurfaceColors.token) +}) diff --git a/frontend/e2e/phase7-responsive-a11y-pwa.spec.ts b/frontend/e2e/phase7-responsive-a11y-pwa.spec.ts new file mode 100644 index 0000000..14ffbb1 --- /dev/null +++ b/frontend/e2e/phase7-responsive-a11y-pwa.spec.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test' + +async function login(page: import('@playwright/test').Page, email: string, home: RegExp) { + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill(email) + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(home) +} + +test('Builder switches between Quick Edit and desktop canvas at Phase 7 viewports', async ({ page }) => { + await login(page, 'designer@microlearn.test', /\/app\/dashboard$/) + for (const width of [375, 430, 768, 1024, 1280, 1440, 1920]) { + await page.setViewportSize({ width, height: width < 768 ? 812 : 900 }) + await page.goto('http://127.0.0.1:5173/app/builder-preview') + if (width < 768) { + await expect(page.getByRole('region', { name: 'ویرایش سریع موبایل' })).toBeVisible() + await expect(page.getByRole('button', { name: 'افزودن بلاک', exact: true })).toBeVisible() + } else { + await expect(page.locator('.builder-canvas')).toBeVisible() + } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), `${width}px overflow`).toBe(true) + } + + await page.setViewportSize({ width: 375, height: 812 }) + await page.getByRole('button', { name: /ویرایش بلاک بهترین روش‌های ایمنی/ }).click() + await expect(page.getByRole('dialog', { name: 'ویرایش بلاک در موبایل' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog', { name: 'ویرایش بلاک در موبایل' })).toBeHidden() +}) + +test('Learner Home is action-led, responsive, offline-aware and accessible', async ({ page, context }) => { + const runtimeErrors: string[] = [] + page.on('pageerror', error => runtimeErrors.push(error.message)) + page.on('console', message => { if (message.type() === 'error') runtimeErrors.push(message.text()) }) + await page.setViewportSize({ width: 430, height: 932 }) + await login(page, 'maryam@microlearn.test', /\/learn\/home$/) + await expect(page.getByRole('navigation', { name: 'ناوبری یادگیرنده' }).getByRole('link')).toHaveCount(5) + await expect(page.getByRole('heading', { name: 'ادامه یادگیری', level: 2 })).toBeVisible() + await expect(page.getByRole('heading', { name: 'جلسه امروز', level: 2 })).toBeVisible() + await expect(page.getByRole('heading', { name: 'موعدهای نزدیک', level: 2 })).toBeVisible() + await expect(page.getByRole('heading', { name: 'اعلان‌های مهم', level: 2 })).toBeVisible() + await expect(page.locator('.learner-primary-action')).toHaveCount(1) + for (const width of [320, 375, 430, 768]) { + await page.setViewportSize({ width, height: width < 768 ? 812 : 900 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), `${width}px learner overflow`).toBe(true) + await expect(page.getByRole('navigation', { name: 'ناوبری یادگیرنده' })).toBeVisible() + expect(await page.getByRole('navigation', { name: 'ناوبری یادگیرنده' }).evaluate(element => getComputedStyle(element).position)).toBe('fixed') + } + await page.setViewportSize({ width: 430, height: 932 }) + + await context.setOffline(true) + await page.evaluate(() => window.dispatchEvent(new Event('offline'))) + await expect(page.getByText('آفلاین هستید')).toBeVisible() + await expect(page.locator('.learner-next-action')).toBeVisible() + await context.setOffline(false) + await page.evaluate(() => window.dispatchEvent(new Event('online'))) + + const account = page.getByRole('button', { name: 'بازکردن حساب کاربری' }) + await account.click() + await expect(page.getByRole('dialog', { name: 'حساب کاربری' })).toBeFocused() + await page.keyboard.press('Escape') + await expect(account).toBeFocused() + + await account.click() + await page.getByRole('button', { name: 'English' }).click() + await page.getByRole('button', { name: 'Change theme' }).click() + await expect(page.locator('html')).toHaveAttribute('dir', 'ltr') + await expect(page.locator('html')).toHaveAttribute('lang', 'en') + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') + await expect(page.getByRole('navigation', { name: 'Learner navigation' })).toBeVisible() + expect(runtimeErrors).toEqual([]) +}) + +test('My Learning works as a searchable responsive portfolio', async ({ page, context }) => { + const runtimeErrors: string[] = [] + page.on('pageerror', error => runtimeErrors.push(error.message)) + page.on('console', message => { if (message.type() === 'error') runtimeErrors.push(message.text()) }) + await page.setViewportSize({ width: 430, height: 932 }) + await login(page, 'maryam@microlearn.test', /\/learn\/home$/) + await page.goto('http://127.0.0.1:5173/learn/my-learning') + + await expect(page.getByRole('heading', { name: 'یادگیری‌های من', level: 1 })).toBeVisible() + await expect(page.getByRole('searchbox', { name: 'جستجو در یادگیری‌های من' })).toBeVisible() + await expect(page.getByRole('tab', { name: 'در حال انجام' })).toHaveAttribute('aria-selected', 'true') + await expect(page.getByRole('navigation', { name: 'ناوبری یادگیرنده' }).getByRole('link', { name: 'یادگیری من' })).toHaveAttribute('aria-current', 'page') + + const filterButton = page.getByRole('button', { name: /^(?:فیلترها.*|بازکردن فیلترها)$/ }) + await filterButton.click() + const filtersDialog = page.getByRole('dialog', { name: 'فیلتر و مرتب‌سازی' }) + await expect(filtersDialog).toBeVisible() + await filtersDialog.getByRole('combobox').last().selectOption('progress') + await page.keyboard.press('Escape') + await expect(filterButton).toHaveAttribute('aria-expanded', 'false') + + const overflowButton = page.getByRole('button', { name: /عملیات / }).first() + if (await overflowButton.count()) { + await overflowButton.click() + await expect(page.getByRole('menu')).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'مشاهده جزئیات' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('menu')).toBeHidden() + } + + for (const width of [320, 375, 430, 768]) { + await page.setViewportSize({ width, height: width < 768 ? 812 : 900 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), `${width}px portfolio overflow`).toBe(true) + const targets = page.locator('.my-learning-segments button, .my-learning-filter-trigger') + const heights = await targets.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().height)) + expect(heights.every(height => height >= 44), `${width}px portfolio touch targets`).toBe(true) + } + + await context.setOffline(true) + await page.evaluate(() => window.dispatchEvent(new Event('offline'))) + await expect(page.getByText('آفلاین هستید')).toBeVisible() + await expect(page.getByRole('heading', { name: 'یادگیری‌های من', level: 1 })).toBeVisible() + await context.setOffline(false) + await page.evaluate(() => window.dispatchEvent(new Event('online'))) + expect(runtimeErrors).toEqual([]) +}) + +test('Daily Learning creates a focused, persistent and offline-aware session', async ({ page, context }) => { + const runtimeErrors: string[] = [] + page.on('pageerror', error => runtimeErrors.push(error.message)) + page.on('console', message => { if (message.type() === 'error') runtimeErrors.push(message.text()) }) + await page.setViewportSize({ width: 430, height: 932 }) + await login(page, 'maryam@microlearn.test', /\/learn\/home$/) + await page.evaluate(() => { localStorage.removeItem('microlearn.daily.history'); localStorage.removeItem('microlearn.daily.duration'); sessionStorage.removeItem('microlearn.daily.active') }) + await page.goto('http://127.0.0.1:5173/learn/daily') + + await expect(page.getByRole('heading', { name: 'جلسه امروز', level: 1 })).toBeVisible() + await expect(page.getByText('امروز چقدر وقت داری؟')).toBeVisible() + await expect(page.getByRole('button', { name: '۱۰ دقیقه' })).toHaveAttribute('aria-pressed', 'true') + await expect(page.getByRole('heading', { name: 'جلسه امروز شما', level: 2 })).toBeVisible() + await expect(page.getByRole('button', { name: 'شروع جلسه' })).toHaveCount(1) + await page.getByRole('button', { name: '۱۵ دقیقه' }).click() + expect(await page.evaluate(() => localStorage.getItem('microlearn.daily.duration'))).toBe('15') + + const why = page.getByText('چرا این محتوا پیشنهاد شده؟') + await why.click() + await expect(why.locator('xpath=..')).toHaveAttribute('open', '') + + for (const width of [320, 375, 430, 768]) { + await page.setViewportSize({ width, height: width < 768 ? 812 : 900 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), `${width}px daily overflow`).toBe(true) + const targets = page.locator('.daily-time-selector button, .daily-primary-action') + const heights = await targets.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().height)) + expect(heights.every(height => height >= 44), `${width}px daily touch targets`).toBe(true) + } + + await context.setOffline(true) + await page.evaluate(() => window.dispatchEvent(new Event('offline'))) + await expect(page.getByText('آفلاین هستید')).toBeVisible() + await expect(page.getByRole('heading', { name: 'جلسه امروز شما', level: 2 })).toBeVisible() + await context.setOffline(false) + await page.evaluate(() => window.dispatchEvent(new Event('online'))) + + const activityCount = await page.locator('.daily-activity-list > li').count() + await page.getByRole('button', { name: 'شروع جلسه' }).click() + await expect(page).toHaveURL(/\/learn\/player\/.*daily=1/) + for (let index = 0; index < activityCount; index += 1) { + const before = page.url() + await page.getByRole('button', { name: 'تکمیل فعالیت' }).click() + if (index < activityCount - 1) await expect.poll(() => page.url()).not.toBe(before) + } + await expect(page).toHaveURL(/\/learn\/daily\?completed=1/) + await expect(page.locator('.daily-completion')).toBeFocused() + expect(runtimeErrors).toEqual([]) +}) + +test('Course Player keeps progress and touch actions usable at all target viewports', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await login(page, 'maryam@microlearn.test', /\/learn\/home$/) + const continueLink = page.locator('.learner-next-action').first() + await expect(continueLink).toBeVisible() + await continueLink.click() + await expect(page).toHaveURL(/\/learn\/player\//) + await expect(page.getByRole('progressbar', { name: 'پیشرفت دوره' })).toBeVisible() + await expect(page.getByText(/درس .* از/)).toBeVisible() + + for (const width of [375, 430, 768, 1024, 1280, 1440, 1920]) { + await page.setViewportSize({ width, height: width < 768 ? 812 : 900 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), `${width}px player overflow`).toBe(true) + const actions = page.locator('.player-footer .button') + await expect(actions).toHaveCount(3) + const heights = await actions.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().height)) + expect(heights.every(height => height >= 44), `${width}px player touch targets`).toBe(true) + } +}) diff --git a/frontend/e2e/ui-phase2-app-shell.spec.ts b/frontend/e2e/ui-phase2-app-shell.spec.ts new file mode 100644 index 0000000..06ff445 --- /dev/null +++ b/frontend/e2e/ui-phase2-app-shell.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from '@playwright/test' + +async function login(page: import('@playwright/test').Page, email = 'designer@microlearn.test') { + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill(email) + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(/\/(app\/dashboard|admin\/dashboard|manager\/overview)$/) +} + +async function expectWorkspaceWithinViewport(page: import('@playwright/test').Page) { + const bounds = await page.locator('.workspace').evaluate(element => { + const workspace = element.getBoundingClientRect() + const body = element.querySelector('.workspace__body')?.getBoundingClientRect() + return { + workspaceLeft: workspace.left, + workspaceRight: workspace.right, + bodyLeft: body?.left ?? 0, + bodyRight: body?.right ?? 0, + viewportWidth: window.innerWidth, + scrollWidth: document.documentElement.scrollWidth, + } + }) + expect(bounds.workspaceLeft).toBeGreaterThanOrEqual(-1) + expect(bounds.bodyLeft).toBeGreaterThanOrEqual(-1) + expect(bounds.workspaceRight).toBeLessThanOrEqual(bounds.viewportWidth + 1) + expect(bounds.bodyRight).toBeLessThanOrEqual(bounds.viewportWidth + 1) + expect(bounds.scrollWidth).toBeLessThanOrEqual(bounds.viewportWidth) +} + +test('desktop shell groups navigation, exposes context and preserves RTL/dark mode', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await login(page) + await expectWorkspaceWithinViewport(page) + + const primary = page.getByRole('navigation', { name: 'ناوبری اصلی' }) + const create = primary.getByRole('button', { name: 'ساخت' }) + await expect(create).toHaveAttribute('aria-expanded', 'true') + await create.click() + await expect(primary.getByRole('link', { name: 'دوره‌ها' })).toBeHidden() + await create.click() + + const collapse = page.getByRole('button', { name: 'جمع‌کردن نوار کناری' }) + await collapse.click() + await expect(page.locator('.workspace')).toHaveClass(/workspace--collapsed/) + await expectWorkspaceWithinViewport(page) + const courseLink = primary.getByRole('link', { name: 'دوره‌ها' }) + await expect(courseLink).toHaveAttribute('data-tooltip', 'دوره‌ها') + + await page.goto('http://127.0.0.1:5173/app/courses/course-1') + const breadcrumb = page.getByRole('navigation', { name: 'مسیر صفحه' }) + await expect(breadcrumb).toContainText('دوره‌ها') + await expect(breadcrumb).toContainText('فضای دوره') + + await page.getByRole('button', { name: 'انتخاب زبان و تقویم' }).click() + await page.getByRole('menuitemradio', { name: 'English' }).click() + await page.getByRole('button', { name: 'Choose appearance' }).click() + await page.getByRole('menuitemradio', { name: 'Dark' }).click() + await expect(page.locator('html')).toHaveAttribute('dir', 'ltr') + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) +}) + +test('mobile drawer announces state, traps focus and restores its trigger', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }) + await login(page) + + for (const width of [375, 430, 768, 1024]) { + await page.setViewportSize({ width, height: width < 768 ? 812 : 900 }) + const trigger = page.getByRole('button', { name: 'باز کردن منو' }) + await expect(trigger).toBeVisible() + await expect(trigger).toHaveAttribute('aria-expanded', 'false') + await trigger.click() + await expect(trigger).toHaveAttribute('aria-expanded', 'true') + const sidebar = page.locator('.sidebar') + await expect(sidebar).toBeVisible() + await expect(sidebar.getByRole('button', { name: 'بستن منو' })).toBeFocused() + await page.keyboard.press('Escape') + await expect(sidebar).toBeHidden() + await expect(trigger).toBeFocused() + await expect(trigger).toHaveAttribute('aria-expanded', 'false') + expect(await page.evaluate(() => document.body.style.overflow), `${width}px body lock`).toBe('') + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), `${width}px overflow`).toBe(true) + } +}) + +test('admin and manager receive distinct role-aware navigation', async ({ page }) => { + await login(page, 'admin@microlearn.test') + await expect(page.getByRole('navigation', { name: 'ناوبری اصلی' }).getByRole('link', { name: 'سازمان‌ها' })).toBeVisible() + await expect(page.getByRole('link', { name: 'دوره‌ها' })).toHaveCount(0) + + await page.getByRole('button', { name: 'بازکردن حساب کاربری' }).click() + await page.getByRole('menuitem', { name: 'خروج از حساب' }).click() + await expect(page).toHaveURL(/\/login$/) + await login(page, 'manager@microlearn.test') + await expect(page.getByRole('navigation', { name: 'ناوبری اصلی' }).getByRole('link', { name: 'تیم من' })).toBeVisible() + await expect(page.getByRole('link', { name: 'استودیوی هوش مصنوعی' })).toHaveCount(0) +}) diff --git a/frontend/e2e/ui-phase6-management.spec.ts b/frontend/e2e/ui-phase6-management.spec.ts new file mode 100644 index 0000000..884ed23 --- /dev/null +++ b/frontend/e2e/ui-phase6-management.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test' + +async function login(page: import('@playwright/test').Page) { + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill('designer@microlearn.test') + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(/\/app\/dashboard$/) +} + +test('management filters are visible, removable and responsive', async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await login(page) + await page.goto('http://127.0.0.1:5173/app/users') + await expect(page.getByRole('heading', { name: 'کاربران' })).toBeVisible() + await page.getByRole('combobox', { name: 'نقش' }).selectOption('manager') + const filters = page.getByRole('region', { name: 'فیلترهای فعال' }) + await expect(filters).toContainText('نقش: مدیر') + await filters.getByRole('button', { name: 'پاک‌کردن همه فیلترها' }).click() + await expect(page.getByRole('combobox', { name: 'نقش' })).toHaveValue('') + + await page.setViewportSize({ width: 375, height: 812 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + const firstRow = page.locator('.users-directory-table tbody tr').first() + if (await firstRow.count()) { + await expect(firstRow).toHaveCSS('display', 'grid') + await expect(firstRow.locator('td').first()).toHaveAttribute('data-label', 'نام و نام خانوادگی') + } + + await expect(page.locator('html')).toHaveAttribute('dir', 'rtl') + await page.locator('html').evaluate(element => element.setAttribute('dir', 'ltr')) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) + await page.evaluate(() => window.localStorage.setItem('microlearn.theme', 'dark')) + await page.reload() + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') + await expect(page.locator('.filter-bar')).toHaveCSS('background-color', await page.locator('body').evaluate(() => { + const probe = document.createElement('div'); probe.style.background = 'var(--surface)'; document.body.append(probe) + const color = getComputedStyle(probe).backgroundColor; probe.remove(); return color + })) +}) + +test('assignments expose search and status filters without changing actions', async ({ page }) => { + await page.setViewportSize({ width: 430, height: 900 }) + await login(page) + await page.goto('http://127.0.0.1:5173/app/assignments') + await expect(page.getByRole('heading', { name: 'تخصیص‌ها' })).toBeVisible() + await expect(page.getByRole('searchbox', { name: 'جستجوی تخصیص' })).toBeVisible() + await page.getByRole('combobox', { name: 'وضعیت تخصیص' }).selectOption('active') + await expect(page.getByRole('region', { name: 'فیلترهای فعال' })).toContainText('وضعیت: فعال') + await expect(page.getByRole('button', { name: 'تخصیص جدید' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true) +}) diff --git a/frontend/e2e/ui-phase8-accessibility.spec.ts b/frontend/e2e/ui-phase8-accessibility.spec.ts new file mode 100644 index 0000000..27c0183 --- /dev/null +++ b/frontend/e2e/ui-phase8-accessibility.spec.ts @@ -0,0 +1,80 @@ +import { expect, test } from '@playwright/test' + +async function login(page: import('@playwright/test').Page) { + await page.goto('http://127.0.0.1:5173/login') + await page.getByLabel(/ایمیل|Email/).fill('designer@microlearn.test') + await page.getByLabel(/رمز عبور|Password/).fill('password') + await page.getByRole('button', { name: /ورود|Sign in/ }).click() + await expect(page).toHaveURL(/\/app\/dashboard$/) +} + +test('command palette traps focus and restores its trigger', async ({ page }) => { + await login(page) + const trigger = page.getByRole('button', { name: 'جست‌وجو و فرمان سریع' }) + await trigger.focus(); await trigger.click() + const dialog = page.getByRole('dialog', { name: 'پالت فرمان' }) + const input = dialog.getByPlaceholder('جست‌وجوی صفحه یا فرمان…') + await expect(input).toBeFocused() + await page.keyboard.press('Shift+Tab') + await expect(dialog.getByRole('button').last()).toBeFocused() + await page.keyboard.press('Tab') + await expect(input).toBeFocused() + await page.keyboard.press('Escape') + await expect(dialog).toHaveCount(0) + await expect(trigger).toBeFocused() +}) + +test('library tabs support arrows, Home, and End', async ({ page }) => { + await login(page) + await page.goto('http://127.0.0.1:5173/app/library') + const tabs = page.getByRole('tablist', { name: 'نوع فایل' }) + const all = tabs.getByRole('tab', { name: 'همه' }) + await all.focus(); await all.press('ArrowRight') + await expect(tabs.getByRole('tab', { name: 'تصاویر' })).toBeFocused() + await expect(tabs.getByRole('tab', { name: 'تصاویر' })).toHaveAttribute('aria-selected', 'true') + await page.keyboard.press('End') + await expect(tabs.getByRole('tab', { name: 'اسناد' })).toBeFocused() + await page.keyboard.press('Home') + await expect(all).toBeFocused() +}) + +test('semantic colors meet contrast targets and reduced motion is honored', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.goto('http://127.0.0.1:5173/login') + for (const theme of ['light', 'dark'] as const) { + await page.evaluate(value => window.localStorage.setItem('microlearn.theme', value), theme) + await page.reload() + await expect(page.locator('html')).toHaveAttribute('data-theme', theme) + const result = await page.evaluate(() => { + const style = getComputedStyle(document.documentElement) + const rgb = (value: string) => { + const hex = value.trim().replace('#', '') + return [0, 2, 4].map(offset => Number.parseInt(hex.slice(offset, offset + 2), 16)) + } + const luminance = (value: string) => { + const channels = rgb(value).map(channel => channel / 255).map(channel => channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4) + return .2126 * channels[0] + .7152 * channels[1] + .0722 * channels[2] + } + const contrast = (foreground: string, background: string) => { + const [lighter, darker] = [luminance(foreground), luminance(background)].sort((a, b) => b - a) + return (lighter + .05) / (darker + .05) + } + const token = (name: string) => style.getPropertyValue(name) + const probe = document.createElement('button') + probe.className = 'button' + document.body.append(probe) + const transition = getComputedStyle(probe).transitionDuration.split(',').map(value => value.trim().endsWith('ms') ? Number.parseFloat(value) / 1000 : Number.parseFloat(value)) + probe.remove() + return { + text: contrast(token('--text'), token('--surface')), + muted: contrast(token('--text-muted'), token('--surface')), + focus: contrast(token('--focus'), token('--canvas')), + maxTransitionSeconds: Math.max(...transition), + } + }) + expect(result.text, `${theme} primary text contrast`).toBeGreaterThanOrEqual(4.5) + expect(result.muted, `${theme} secondary text contrast`).toBeGreaterThanOrEqual(4.5) + expect(result.focus, `${theme} focus indicator contrast`).toBeGreaterThanOrEqual(3) + expect(result.maxTransitionSeconds, `${theme} reduced motion`).toBeLessThanOrEqual(.001) + } +}) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a9429c7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + MicroLearn + + +
        + + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..2dc3119 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3557 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-table": "^9.1.2", + "@tiptap/extension-link": "3.30.0", + "@tiptap/react": "^3.30.0", + "@tiptap/starter-kit": "^3.30.0", + "echarts": "^6.1.0", + "lucide-react": "^1.31.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "jsdom": "^30.0.1", + "oxlint": "^1.75.0", + "typescript": "~6.0.2", + "vite": "^8.2.0", + "vitest": "^4.1.10" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT", + "optional": true + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.78.0.tgz", + "integrity": "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.78.0.tgz", + "integrity": "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.78.0.tgz", + "integrity": "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.78.0.tgz", + "integrity": "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.78.0.tgz", + "integrity": "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.78.0.tgz", + "integrity": "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.78.0.tgz", + "integrity": "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.78.0.tgz", + "integrity": "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.78.0.tgz", + "integrity": "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.78.0.tgz", + "integrity": "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.78.0.tgz", + "integrity": "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.78.0.tgz", + "integrity": "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.78.0.tgz", + "integrity": "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.78.0.tgz", + "integrity": "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.78.0.tgz", + "integrity": "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.78.0.tgz", + "integrity": "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.78.0.tgz", + "integrity": "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.78.0.tgz", + "integrity": "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.78.0.tgz", + "integrity": "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.1.tgz", + "integrity": "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.1.2.tgz", + "integrity": "sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg==", + "license": "MIT", + "dependencies": { + "@tanstack/react-store": "^0.11.0", + "@tanstack/table-core": "9.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz", + "integrity": "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.1.2.tgz", + "integrity": "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.3.tgz", + "integrity": "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tiptap/core": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.30.0.tgz", + "integrity": "sha512-jlR5STfcpoYxvqnWoR6dhnkB0OBTNbTWd/Jw10wx1LLCrUOK2vrM1z3rXdiVSqqoDjphEu8HX/k4q+Okt+5YGg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.30.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.30.0.tgz", + "integrity": "sha512-hDIb52yeY+IptKibIzApOTXOuJITX9F9dPSwYO3zhcxGzdWGzLjjoYyekA66lMm4fdGYkRlJWapyaG71vravLQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0", + "@tiptap/pm": "3.30.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.30.0.tgz", + "integrity": "sha512-/pDKSzd1btAIT8jWr5NGitQBe4hx4eebezT46/WCILBzf8j4+xi+dyG5fc5yebe7I4IsZlCRnmW21pLmctIHkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.30.0.tgz", + "integrity": "sha512-53UzuEjC0OBGkip0gHBwNGFlA8d8fLaeH/K0jJEj4v4P1Xu+2wWNY3fsjEKKG6bp2sEygXIlvK+OmGoc+ObWJw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0", + "@tiptap/pm": "3.30.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.30.0.tgz", + "integrity": "sha512-NOCwi6A3zYsv2UriyJDM4Wa3Unc/ignaTsPMmHuI1CvY9o4RFrJWhNarPEwQ02vvmdDVUidGRThLZJ+qM6JGzg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.30.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.30.0.tgz", + "integrity": "sha512-1KPgOXlVUQ7269u9zQU8PZYaIp6yLJj+TM6ZkuiqjGbq7UMdazO/rVofNTSMJ+jg0YqrLopEZP7DfRjkmikfnQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.30.0.tgz", + "integrity": "sha512-m0KcCiUijaHNqTQCw9ydKRAzOrWxL0MogQu2WnTrbSrtCBwSiRvbG3ocqv5L3OfkoqnezeZCSg9JRwN+QBLP4Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0", + "@tiptap/pm": "3.30.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.30.0.tgz", + "integrity": "sha512-gSXVcISMkK9IXa2YUc0TM+lkyCWaK6KgcsIoePnCWWdUGs0wtcktz3XK9mmcVu5xntg7AR7AT9B6gvl1k+MKYA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.30.0.tgz", + "integrity": "sha512-RjTRfPH/fMaXYeRRp1etivWrH5vhrPEhEEY+34IrEXglp11svTp3heK1G2eIhppUKPRhsczWffSpUYEgEcEYsw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.30.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.30.0.tgz", + "integrity": "sha512-shLDHdWxFcxhJxJqdOcm0JnQP2kU1pEeokEqGyfbISgJ7iVSqyP5KabAP0TuCpJs4NN9Hw/zoAZB/LR6jXF5Mg==", + "license": "MIT", + "optional": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.30.0", + "@tiptap/pm": "3.30.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.30.0.tgz", + "integrity": "sha512-CeRNnp1BatrpYYdt9tayp5SnAW5KYv30/Ckngrl2f6uQjcWObFkDp4klJu+7OT/DpioeOz9s3LOsSrzYLgscyg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.30.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.30.0.tgz", + "integrity": "sha512-JzDGLfVMyvjqTPlfZ6f7+lfVu3nNg9RuhAY+N1clCr5JTzmxHsgvvpDYlkMyUvnqTbWlvW379+wtxi9BiDOpRg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.30.0.tgz", + "integrity": "sha512-PqUZP/dPFtBtETYXkQrybfdMRntxEY9ZQv2b+8vC0U3JCE+6IUEnJaFZgAimIyN3++ZLh3gCVvI/LfqFh3UHRQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.30.0.tgz", + "integrity": "sha512-SD/udnQ6aAEbyi0DSf48Yh/LAPMulgvaX83MwVhzMb/qgV7eCUbJxLsqQvaaNnVFZZyl5GHbCwTbPEdEymEIRA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0", + "@tiptap/pm": "3.30.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.30.0.tgz", + "integrity": "sha512-X/mg09T4XLG1kBXJVCONtyvzP+DOc3t0QogpYDMLOyVV5d2+p40NMIGPwfnSfVofOQynJFBCNs5rkI/eSZQz4g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.30.0.tgz", + "integrity": "sha512-slfnLDFDqN4FLPBzdf1ZTM417FSxUUzfIb3Zjbe5d5r9SK6PWmf7wRCA6wb5o9BlpTPGncnKYNet+NlnBHZLWQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0", + "@tiptap/pm": "3.30.0" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.30.0.tgz", + "integrity": "sha512-cunfjAWsYESK5rSVqB+xdBKX8pREuTN1wh2cR+G+OckgwQaWHB9cf3erCJ9nhvMyDVsNEGy/iJNQ6qd1CGzuoQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0", + "@tiptap/pm": "3.30.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.30.0.tgz", + "integrity": "sha512-lPGsXb3wiVooxhlcqf8C5SWP4xu4xJEqhSX+0K3zTQWmubvYzc7WIePSQCKl/+PqPXyuIRyFAELKJGd9oybkKg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.30.0" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.30.0.tgz", + "integrity": "sha512-u4siME/FwDzs24tM/Z21lUQGmmkfbvMgJ+FSNk+m32lX5U0yUojviJ5sqoxaot56G2RAkhxRmJdQvKWDeLKVqA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.30.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.30.0.tgz", + "integrity": "sha512-x4mDqfQft4szLsLCIQPKMtxdtIwZtchF1ONZMHvamy7RXOXDAa4CL5wyaNqDaRAeppI0SSL77/F0GWSqJS7aUw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.30.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.30.0.tgz", + "integrity": "sha512-xfGv8ZRYncPCQyKViTLAA52n9wbb1CnjxMezKbQVmckOMWroRxtT+4zmbpOslnR0cuNHIYAWMZ25MM+wxAQpsw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.30.0.tgz", + "integrity": "sha512-sn2VbcVzgW/w0gWQHhcTiIgnkai9vjejXFHwlNgnnvcNd1zZuGCJegWJDzC6Ze8XupxRwKIkgL6eSi1d4n65ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.30.0.tgz", + "integrity": "sha512-NjsZzbuCkDth/XIpXQbcdoDpXQarQlISBARdwVEEXq2Cv8YEJRERblO8PHUdaP7AxuxPru5ZfgbnAkbopy/fwQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.30.0.tgz", + "integrity": "sha512-EJ7NF3CP96gJVmdaXUJ9GaaFKpC92flftT6/PtINK4oCBPYbyWRI8nOdeBPijm12yXGbzcPS3oyIjc2FIzkdHA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.30.0.tgz", + "integrity": "sha512-dCevnLGpISgrMqnEse3BeZPpaPtYBdIWONIiVSl4ODDCMUthcs8QqM5qi192f0fT+jnYikD7UP3zn+hdCFOtaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0", + "@tiptap/pm": "3.30.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.30.0.tgz", + "integrity": "sha512-0if2mMq/9nZ/IOiuuyvo36OEz/fhwFJiHNy49+NywUkkKVSsa6S7Pcsxb03RnKzW648EanOjE5oqTd4jCkgRKg==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.41.9" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.30.0.tgz", + "integrity": "sha512-v4yMEd52RWajwPrIujFRm4tvG/esNgBY3lE2BHq0d1S636y6UXLt85DluNcf7B2R1CD3/Jknek6Bl3GIS8qA6g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.30.0", + "@tiptap/extension-floating-menu": "^3.30.0" + }, + "peerDependencies": { + "@tiptap/core": "3.30.0", + "@tiptap/pm": "3.30.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.30.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.30.0.tgz", + "integrity": "sha512-g78rSRaM6RepFv01QlfiQ9boPX4xMjrD3T4BCk+EnXJGKKxLTIhwYsV40l/+A7rQM2jNF6PE8+JBkdJ8nEtX3w==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.30.0", + "@tiptap/extension-blockquote": "3.30.0", + "@tiptap/extension-bold": "3.30.0", + "@tiptap/extension-bullet-list": "3.30.0", + "@tiptap/extension-code": "3.30.0", + "@tiptap/extension-code-block": "3.30.0", + "@tiptap/extension-document": "3.30.0", + "@tiptap/extension-dropcursor": "3.30.0", + "@tiptap/extension-gapcursor": "3.30.0", + "@tiptap/extension-hard-break": "3.30.0", + "@tiptap/extension-heading": "3.30.0", + "@tiptap/extension-horizontal-rule": "3.30.0", + "@tiptap/extension-italic": "3.30.0", + "@tiptap/extension-link": "3.30.0", + "@tiptap/extension-list": "3.30.0", + "@tiptap/extension-list-item": "3.30.0", + "@tiptap/extension-list-keymap": "3.30.0", + "@tiptap/extension-ordered-list": "3.30.0", + "@tiptap/extension-paragraph": "3.30.0", + "@tiptap/extension-strike": "3.30.0", + "@tiptap/extension-text": "3.30.0", + "@tiptap/extension-underline": "3.30.0", + "@tiptap/extensions": "3.30.0", + "@tiptap/pm": "3.30.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-equals": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-react": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, + "node_modules/oxlint": { + "version": "1.78.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.78.0.tgz", + "integrity": "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.78.0", + "@oxlint/binding-android-arm64": "1.78.0", + "@oxlint/binding-darwin-arm64": "1.78.0", + "@oxlint/binding-darwin-x64": "1.78.0", + "@oxlint/binding-freebsd-x64": "1.78.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", + "@oxlint/binding-linux-arm-musleabihf": "1.78.0", + "@oxlint/binding-linux-arm64-gnu": "1.78.0", + "@oxlint/binding-linux-arm64-musl": "1.78.0", + "@oxlint/binding-linux-ppc64-gnu": "1.78.0", + "@oxlint/binding-linux-riscv64-gnu": "1.78.0", + "@oxlint/binding-linux-riscv64-musl": "1.78.0", + "@oxlint/binding-linux-s390x-gnu": "1.78.0", + "@oxlint/binding-linux-x64-gnu": "1.78.0", + "@oxlint/binding-linux-x64-musl": "1.78.0", + "@oxlint/binding-openharmony-arm64": "1.78.0", + "@oxlint/binding-win32-arm64-msvc": "1.78.0", + "@oxlint/binding-win32-ia32-msvc": "1.78.0", + "@oxlint/binding-win32-x64-msvc": "1.78.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz", + "integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..6423117 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,47 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc -b --pretty false", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-table": "^9.1.2", + "@tiptap/extension-link": "3.30.0", + "@tiptap/react": "^3.30.0", + "@tiptap/starter-kit": "^3.30.0", + "echarts": "^6.1.0", + "lucide-react": "^1.31.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "jsdom": "^30.0.1", + "oxlint": "^1.75.0", + "typescript": "~6.0.2", + "vite": "^8.2.0", + "vitest": "^4.1.10" + } +} diff --git a/frontend/phase13-ai-studio-375.png b/frontend/phase13-ai-studio-375.png new file mode 100644 index 0000000..62c5a7d Binary files /dev/null and b/frontend/phase13-ai-studio-375.png differ diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..8f9c030 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + use: { + browserName: 'chromium', + launchOptions: { executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' }, + trace: 'retain-on-failure', + }, +}) diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png new file mode 100644 index 0000000..eac7e5f Binary files /dev/null and b/frontend/public/apple-touch-icon.png differ diff --git a/frontend/public/brand/microlearn-logo.png b/frontend/public/brand/microlearn-logo.png new file mode 100644 index 0000000..e71b29a Binary files /dev/null and b/frontend/public/brand/microlearn-logo.png differ diff --git a/frontend/public/favicon-32.png b/frontend/public/favicon-32.png new file mode 100644 index 0000000..965dd74 Binary files /dev/null and b/frontend/public/favicon-32.png differ diff --git a/frontend/public/favicon-64.png b/frontend/public/favicon-64.png new file mode 100644 index 0000000..6614cc6 Binary files /dev/null and b/frontend/public/favicon-64.png differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/icons/app-icon-192.png b/frontend/public/icons/app-icon-192.png new file mode 100644 index 0000000..b6ec99d Binary files /dev/null and b/frontend/public/icons/app-icon-192.png differ diff --git a/frontend/public/icons/app-icon-512.png b/frontend/public/icons/app-icon-512.png new file mode 100644 index 0000000..882c0b3 Binary files /dev/null and b/frontend/public/icons/app-icon-512.png differ diff --git a/frontend/public/icons/app-icon.svg b/frontend/public/icons/app-icon.svg new file mode 100644 index 0000000..5cc154f --- /dev/null +++ b/frontend/public/icons/app-icon.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..7eae157 --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "MicroLearn Learning", + "short_name": "MicroLearn", + "description": "یادگیری کوتاه، در دسترس و قابل ادامه در حالت آفلاین", + "lang": "fa", + "dir": "rtl", + "start_url": "/learn/home", + "scope": "/", + "display": "standalone", + "background_color": "#f7f7fb", + "theme_color": "#5b3fd3", + "icons": [ + { "src": "/icons/app-icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/icons/app-icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/frontend/public/offline.html b/frontend/public/offline.html new file mode 100644 index 0000000..8d250e3 --- /dev/null +++ b/frontend/public/offline.html @@ -0,0 +1,13 @@ + + + + + + + MicroLearn · Offline + + +

        اتصال اینترنت در دسترس نیست

        محتوای دانلودشده از بخش یادگیری همچنان در دسترس است. پس از اتصال دوباره، این صفحه را تازه‌سازی کنید.

        You are offline. Downloaded learning remains available.

        + diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..64d5d52 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,27 @@ +const CACHE = 'microlearn-shell-v2' +const OFFLINE_URL = '/offline.html' +const SHELL = ['/', OFFLINE_URL, '/manifest.webmanifest', '/icons/app-icon.svg'] + +self.addEventListener('install', event => event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)))) +self.addEventListener('activate', event => event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()))) +self.addEventListener('message', event => { if (event.data?.type === 'SKIP_WAITING') void self.skipWaiting() }) + +function isCacheableAsset(url) { + if (url.origin !== self.location.origin) return false + return url.pathname.startsWith('/assets/') || url.pathname.startsWith('/icons/') || url.pathname.startsWith('/brand/') || url.pathname === '/manifest.webmanifest' || url.pathname.startsWith('/favicon') || url.pathname === '/apple-touch-icon.png' +} + +self.addEventListener('fetch', event => { + const request = event.request + const url = new URL(request.url) + if (request.method !== 'GET' || url.origin !== self.location.origin || url.pathname.startsWith('/api/') || url.pathname.startsWith('/storage/')) return + if (request.mode === 'navigate') { + event.respondWith(fetch(request).catch(async () => (await caches.match('/')) ?? (await caches.match(OFFLINE_URL)))) + return + } + if (!isCacheableAsset(url)) return + event.respondWith(caches.match(request).then(cached => cached ?? fetch(request).then(response => { + if (response.ok) void caches.open(CACHE).then(cache => cache.put(request, response.clone())) + return response + }))) +}) diff --git a/frontend/src/app/auth/AuthProvider.tsx b/frontend/src/app/auth/AuthProvider.tsx new file mode 100644 index 0000000..207d231 --- /dev/null +++ b/frontend/src/app/auth/AuthProvider.tsx @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { PropsWithChildren } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useLocation, useNavigate } from 'react-router-dom' +import { apiRequest, setUnauthorizedHandler, tokenStorage } from '../../shared/api/client' +import { AuthContext } from './auth-context' +import type { AuthUser } from './auth-context' +import { useLocale } from '../i18n/useLocale' + +export function AuthProvider({ children }: PropsWithChildren) { + const { setLocale } = useLocale() + const location = useLocation() + const navigate = useNavigate() + const queryClient = useQueryClient() + const [user, setUser] = useState(null) + const [status, setStatus] = useState<'loading' | 'authenticated' | 'guest'>(() => tokenStorage.get() ? 'loading' : 'guest') + const clearLocalSession = useCallback(() => { tokenStorage.clear(); void queryClient.cancelQueries(); queryClient.clear(); setUser(null); setStatus('guest') }, [queryClient]) + useEffect(() => setUnauthorizedHandler(() => { + clearLocalSession() + const authPaths = ['/login', '/forgot-password', '/reset-password', '/accept-invitation'] + if (!authPaths.includes(location.pathname)) navigate('/session-expired', { replace: true }) + }), [clearLocalSession, location.pathname, navigate]) + const acceptToken = useCallback(async (token: string) => { tokenStorage.set(token); try { const identity = await apiRequest('/auth/me'); setUser(identity); setLocale(identity.locale); setStatus('authenticated'); return identity } catch (error) { clearLocalSession(); throw error } }, [clearLocalSession, setLocale]) + useEffect(() => { const token = tokenStorage.get(); if (!token) return; void acceptToken(token).catch(() => undefined) }, [acceptToken]) + const login = useCallback(async (email: string, password: string) => { if (tokenStorage.get()) { try { await apiRequest('/auth/logout', { method: 'POST', suppressUnauthorized: true }) } catch { /* A stale session must not block account switching. */ } finally { clearLocalSession() } } const result = await apiRequest<{ token: string; user: AuthUser }>('/auth/login', { method: 'POST', auth: false, body: JSON.stringify({ email, password, device_name: 'web' }) }); queryClient.clear(); tokenStorage.set(result.token); setUser(result.user); setLocale(result.user.locale); setStatus('authenticated'); return result.user }, [clearLocalSession, queryClient, setLocale]) + const logout = useCallback(async () => { try { await apiRequest<{ loggedOut: boolean }>('/auth/logout', { method: 'POST', suppressUnauthorized: true }) } finally { clearLocalSession() } }, [clearLocalSession]) + const value = useMemo(() => ({ user, status, login, acceptToken, logout }), [acceptToken, login, logout, status, user]) + return {children} +} diff --git a/frontend/src/app/auth/AuthState.test.tsx b/frontend/src/app/auth/AuthState.test.tsx new file mode 100644 index 0000000..ffe488e --- /dev/null +++ b/frontend/src/app/auth/AuthState.test.tsx @@ -0,0 +1,77 @@ +import { useQueryClient } from '@tanstack/react-query' +import type { QueryClient } from '@tanstack/react-query' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AppProviders } from '../providers/AppProviders' +import { apiRequest, tokenStorage } from '../../shared/api/client' +import { useAuth } from './useAuth' + +const designer = { id: 'u1', name: 'سارا', email: 'sara@example.test', role: 'course_designer', status: 'active', locale: 'fa', timezone: 'Asia/Tehran', permissions: ['courses.author'], organization: { id: 'o1', name: 'آکادمی اول', slug: 'first', defaultLocale: 'fa', timezone: 'Asia/Tehran' }, deployment: { mode: 'saas', supportsMultipleOrganizations: true, exposesSubscriptionManagement: true } } as const +let observedQueryClient: QueryClient + +function AuthHarness() { + const auth = useAuth() + const queryClient = useQueryClient() + observedQueryClient = queryClient + return
        + {auth.status} + + + + +
        +} + +describe('authentication state synchronization', () => { + beforeEach(() => { window.localStorage.clear(); window.history.pushState({}, '', '/app/dashboard') }) + afterEach(() => { cleanup(); vi.unstubAllGlobals() }) + + it('synchronizes a protected 401 with auth state and clears private query data', async () => { + tokenStorage.set('token') + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ data: designer }) }) + .mockResolvedValueOnce({ ok: false, status: 401, json: async () => ({ message: 'expired' }) }) + vi.stubGlobal('fetch', fetchMock) + render() + expect(await screen.findByText('authenticated')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'seed cache' })) + expect(observedQueryClient.getQueryData(['private', 'o1'])).toBe('organization secret') + fireEvent.click(screen.getByRole('button', { name: 'expire session' })) + await waitFor(() => expect(screen.getByLabelText('auth-status')).toHaveTextContent('guest')) + expect(observedQueryClient.getQueryData(['private', 'o1'])).toBeUndefined() + expect(tokenStorage.get()).toBeNull() + expect(window.location.pathname).toBe('/session-expired') + }) + + it('clears private query data even when the logout request fails', async () => { + tokenStorage.set('token') + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ data: designer }) }) + .mockResolvedValueOnce({ ok: false, status: 503, json: async () => ({ message: 'unavailable' }) }) + vi.stubGlobal('fetch', fetchMock) + render() + expect(await screen.findByText('authenticated')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'seed cache' })) + fireEvent.click(screen.getByRole('button', { name: 'logout' })) + await waitFor(() => expect(screen.getByLabelText('auth-status')).toHaveTextContent('guest')) + expect(observedQueryClient.getQueryData(['private', 'o1'])).toBeUndefined() + expect(tokenStorage.get()).toBeNull() + }) + + it('clears organization-scoped cache before accepting another account', async () => { + tokenStorage.set('first-token') + const second = { ...designer, id: 'u2', email: 'new@example.test', organization: { ...designer.organization, id: 'o2', name: 'آکادمی دوم', slug: 'second' } } + const fetchMock = vi.fn() + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ data: designer }) }) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ data: { loggedOut: true } }) }) + .mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ data: { token: 'second-token', user: second } }) }) + vi.stubGlobal('fetch', fetchMock) + render() + expect(await screen.findByText('authenticated')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'seed cache' })) + expect(observedQueryClient.getQueryData(['private', 'o1'])).toBe('organization secret') + fireEvent.click(screen.getByRole('button', { name: 'switch account' })) + await waitFor(() => expect(tokenStorage.get()).toBe('second-token')) + expect(observedQueryClient.getQueryData(['private', 'o1'])).toBeUndefined() + }) +}) diff --git a/frontend/src/app/auth/RequireAuth.tsx b/frontend/src/app/auth/RequireAuth.tsx new file mode 100644 index 0000000..496b2ab --- /dev/null +++ b/frontend/src/app/auth/RequireAuth.tsx @@ -0,0 +1,14 @@ +import type { PropsWithChildren } from 'react' +import { Navigate, useLocation } from 'react-router-dom' +import { Skeleton } from '../../shared/components/Skeleton' +import type { UserRole } from './auth-context' +import { useAuth } from './useAuth' + +export function RequireAuth({ roles, children }: PropsWithChildren<{ roles: UserRole[] }>) { + const { status, user } = useAuth() + const location = useLocation() + if (status === 'loading') return
        + if (status === 'guest' || !user) return + if (!roles.includes(user.role)) return + return children +} diff --git a/frontend/src/app/auth/auth-context.ts b/frontend/src/app/auth/auth-context.ts new file mode 100644 index 0000000..6e442da --- /dev/null +++ b/frontend/src/app/auth/auth-context.ts @@ -0,0 +1,6 @@ +import { createContext } from 'react' + +export type UserRole = 'super_admin' | 'course_designer' | 'manager' | 'learner' +export type AuthUser = Readonly<{ id: string; name: string; email: string; role: UserRole; status: string; locale: 'fa' | 'en'; timezone: string; permissions: string[]; organization: null | { id: string; name: string; slug: string; defaultLocale: string; timezone: string }; deployment: { mode: 'saas' | 'on_premise'; supportsMultipleOrganizations: boolean; exposesSubscriptionManagement: boolean } }> +export type AuthContextValue = Readonly<{ user: AuthUser | null; status: 'loading' | 'authenticated' | 'guest'; login: (email: string, password: string) => Promise; acceptToken: (token: string) => Promise; logout: () => Promise }> +export const AuthContext = createContext(null) diff --git a/frontend/src/app/auth/roleRouting.ts b/frontend/src/app/auth/roleRouting.ts new file mode 100644 index 0000000..0927dea --- /dev/null +++ b/frontend/src/app/auth/roleRouting.ts @@ -0,0 +1,16 @@ +import type { UserRole } from './auth-context' + +export function roleHome(role: UserRole): string { + if (role === 'super_admin') return '/admin/dashboard' + if (role === 'manager') return '/manager/overview' + if (role === 'learner') return '/learn/home' + return '/app/dashboard' +} + +export function canAccessPath(role: UserRole, path?: string): boolean { + if (!path?.startsWith('/')) return false + if (role === 'super_admin') return path.startsWith('/admin/') + if (role === 'manager') return path.startsWith('/manager/') + if (role === 'learner') return path.startsWith('/learn/') + return path.startsWith('/app/') +} diff --git a/frontend/src/app/auth/useAuth.ts b/frontend/src/app/auth/useAuth.ts new file mode 100644 index 0000000..65c4e15 --- /dev/null +++ b/frontend/src/app/auth/useAuth.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react' +import { AuthContext } from './auth-context' + +export function useAuth() { const value = useContext(AuthContext); if (!value) throw new Error('useAuth must be used inside AuthProvider'); return value } diff --git a/frontend/src/app/auth/usePermission.ts b/frontend/src/app/auth/usePermission.ts new file mode 100644 index 0000000..d2711b3 --- /dev/null +++ b/frontend/src/app/auth/usePermission.ts @@ -0,0 +1,6 @@ +import { useAuth } from './useAuth' + +export function usePermission(permission: string) { + const { user } = useAuth() + return Boolean(user?.permissions.includes(permission)) +} diff --git a/frontend/src/app/i18n/LocaleProvider.tsx b/frontend/src/app/i18n/LocaleProvider.tsx new file mode 100644 index 0000000..d36b0a2 --- /dev/null +++ b/frontend/src/app/i18n/LocaleProvider.tsx @@ -0,0 +1,23 @@ +import { useEffect, useMemo, useState } from 'react' +import type { PropsWithChildren } from 'react' +import { LocaleContext } from './locale-context' +import type { Locale, LocaleContextValue } from './locale-context' + +export function LocaleProvider({ children }: PropsWithChildren) { + const [locale, setLocale] = useState(() => window.localStorage.getItem('microlearn.locale') === 'en' ? 'en' : 'fa') + + useEffect(() => { + document.documentElement.lang = locale + document.documentElement.dir = locale === 'fa' ? 'rtl' : 'ltr' + window.localStorage.setItem('microlearn.locale', locale) + }, [locale]) + + const value = useMemo(() => ({ + locale, + direction: locale === 'fa' ? 'rtl' : 'ltr', + setLocale, + text: (copy) => copy[locale], + }), [locale]) + + return {children} +} diff --git a/frontend/src/app/i18n/locale-context.ts b/frontend/src/app/i18n/locale-context.ts new file mode 100644 index 0000000..134a3d5 --- /dev/null +++ b/frontend/src/app/i18n/locale-context.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react' + +export type Locale = 'fa' | 'en' +export type LocalizedText = Readonly<{ fa: string; en: string }> + +export type LocaleContextValue = Readonly<{ + locale: Locale + direction: 'rtl' | 'ltr' + setLocale: (locale: Locale) => void + text: (value: LocalizedText) => string +}> + +export const LocaleContext = createContext(null) + diff --git a/frontend/src/app/i18n/useLocale.ts b/frontend/src/app/i18n/useLocale.ts new file mode 100644 index 0000000..94e18a9 --- /dev/null +++ b/frontend/src/app/i18n/useLocale.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react' +import { LocaleContext } from './locale-context' + +export function useLocale() { + const context = useContext(LocaleContext) + + if (!context) { + throw new Error('useLocale must be used inside LocaleProvider') + } + + return context +} + diff --git a/frontend/src/app/providers/AppProviders.tsx b/frontend/src/app/providers/AppProviders.tsx new file mode 100644 index 0000000..a7fc611 --- /dev/null +++ b/frontend/src/app/providers/AppProviders.tsx @@ -0,0 +1,33 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { StrictMode, useState } from 'react' +import type { PropsWithChildren } from 'react' +import { BrowserRouter } from 'react-router-dom' +import { LocaleProvider } from '../i18n/LocaleProvider' +import { ThemeProvider } from '../theme/ThemeProvider' +import { ToastProvider } from '../../shared/components/ToastProvider' +import { AuthProvider } from '../auth/AuthProvider' +import { ApiError } from '../../shared/api/client' +import { PwaLifecycle } from '../pwa/PwaLifecycle' + +export function AppProviders({ children }: PropsWithChildren) { + const [queryClient] = useState(() => new QueryClient({ + defaultOptions: { + queries: { + retry: (failureCount, error) => failureCount < 1 && (!(error instanceof ApiError) || error.status >= 500), + staleTime: 30_000, + refetchOnWindowFocus: false, + }, + mutations: { retry: false }, + }, + })) + + return ( + + + + {children} + + + + ) +} diff --git a/frontend/src/app/pwa/PwaLifecycle.tsx b/frontend/src/app/pwa/PwaLifecycle.tsx new file mode 100644 index 0000000..985f976 --- /dev/null +++ b/frontend/src/app/pwa/PwaLifecycle.tsx @@ -0,0 +1,41 @@ +import { Download, RefreshCw, WifiOff, X } from 'lucide-react' +import { useEffect, useState } from 'react' +import { useLocation } from 'react-router-dom' +import { useLocale } from '../i18n/useLocale' + +type InstallPromptEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }> } + +export function PwaLifecycle() { + const { text } = useLocale() + const routeLocation = useLocation() + const [online, setOnline] = useState(navigator.onLine) + const [installPrompt, setInstallPrompt] = useState() + const [waiting, setWaiting] = useState() + const [dismissed, setDismissed] = useState(false) + + useEffect(() => { + const onlineHandler = () => setOnline(true) + const offlineHandler = () => { setOnline(false); setDismissed(false) } + const installHandler = (event: Event) => { event.preventDefault(); setInstallPrompt(event as InstallPromptEvent) } + addEventListener('online', onlineHandler); addEventListener('offline', offlineHandler); addEventListener('beforeinstallprompt', installHandler) + if ('serviceWorker' in navigator && import.meta.env.PROD) { + void navigator.serviceWorker.register('/sw.js').then(registration => { + if (registration.waiting) setWaiting(registration.waiting) + registration.addEventListener('updatefound', () => { + const worker = registration.installing + worker?.addEventListener('statechange', () => { if (worker.state === 'installed' && navigator.serviceWorker.controller) setWaiting(worker) }) + }) + }) + let refreshing = false + navigator.serviceWorker.addEventListener('controllerchange', () => { if (!refreshing) { refreshing = true; window.location.reload() } }) + } + return () => { removeEventListener('online', onlineHandler); removeEventListener('offline', offlineHandler); removeEventListener('beforeinstallprompt', installHandler) } + }, []) + + if (dismissed || (!online && routeLocation.pathname === '/learn/home') || (online && !installPrompt && !waiting)) return null + const install = async () => { if (!installPrompt) return; await installPrompt.prompt(); const choice = await installPrompt.userChoice; if (choice.outcome === 'accepted') setInstallPrompt(undefined) } + return +} diff --git a/frontend/src/app/pwa/pwaPolicy.test.ts b/frontend/src/app/pwa/pwaPolicy.test.ts new file mode 100644 index 0000000..bf9963b --- /dev/null +++ b/frontend/src/app/pwa/pwaPolicy.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'vitest' +import source from '../../../public/sw.js?raw' + +describe('service worker cache policy', () => { + it('has an explicit offline fallback and never runtime-caches arbitrary private URLs', () => { + expect(source).toContain("const OFFLINE_URL = '/offline.html'") + expect(source).toContain('isCacheableAsset') + expect(source).toContain("pathname.startsWith('/api/')") + expect(source).toContain("pathname.startsWith('/storage/')") + }) +}) diff --git a/frontend/src/app/router/AppRouter.test.tsx b/frontend/src/app/router/AppRouter.test.tsx new file mode 100644 index 0000000..c9829a8 --- /dev/null +++ b/frontend/src/app/router/AppRouter.test.tsx @@ -0,0 +1,308 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { tokenStorage } from '../../shared/api/client' +import { AppProviders } from '../providers/AppProviders' +import { AppRouter } from './AppRouter' + +const designer = { id: 'u1', name: 'سارا محمدی', email: 'sara@example.test', role: 'course_designer', status: 'active', locale: 'fa', timezone: 'Asia/Tehran', permissions: ['courses.author'], organization: { id: 'o1', name: 'آکادمی ایمن', slug: 'safe', defaultLocale: 'fa', timezone: 'Asia/Tehran' }, deployment: { mode: 'saas', supportsMultipleOrganizations: true, exposesSubscriptionManagement: true } } +const admin = { ...designer, role: 'super_admin', organization: null, permissions: ['platform.organizations.manage', 'platform.operations.view'] } +const organizationSettings = { + general: { locale: 'fa', timezone: 'Asia/Tehran', calendar: 'jalali', firstDayOfWeek: 'saturday', dateFormat: 'short', numberFormat: 'persian', hourCycle: '24', theme: 'system', density: 'standard', fontScale: 'standard', highContrast: false, focusGuide: true, reduceMotion: false }, + courseDefaults: { language: 'fa', difficulty: 'beginner', presentationMode: 'flow', completionMode: 'all_required', passingScore: 70, resumeLearning: true, showProgress: true }, + assignments: { mandatory: true, dueDays: 14, reminderDays: 3, managerEscalation: true, overduePolicy: 'keep_active', reassignmentPolicy: 'preserve_progress' }, + learner: { notes: true, bookmarks: true, favorites: true, discussions: true, downloads: false, offlineLearning: false, videoAutoplay: false }, + certificates: { issuance: 'approval', validityMonths: 0, renewal: true, verificationQr: true, notifyLearner: true }, + notifications: { email: true, push: true, inApp: true, digest: 'weekly', quietHours: '22-07', overdue: true, risk: true }, + analytics: { defaultRange: 30, riskThreshold: 50, rebuildFrequency: 'daily', exportRetentionDays: 90, managerAccess: 'team_only' }, + security: { sessionMinutes: 480, twoFactor: 'required_admins', defaultRole: 'learner', downloadPolicy: 'designers', auditRetentionDays: 365 }, + media: { maxFileMb: 200, videoQuality: 'adaptive', allowedDocuments: 'standard', retentionPolicy: 'keep', imageOptimization: true }, +} + +function session(user: object = designer) { + tokenStorage.set('token') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ data: user }) })) +} + +describe('AppRouter', () => { + beforeEach(() => { window.localStorage.clear(); window.history.pushState({}, '', '/') }) + afterEach(() => { cleanup(); vi.unstubAllGlobals() }) + + it('renders the authenticated designer shell with real identity', async () => { + session(); window.history.pushState({}, '', '/app/dashboard') + render() + expect(await screen.findByText('سارا محمدی')).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'داشبورد طراحی' })).toBeInTheDocument() + }) + + it('redirects a guest from protected routes to login', async () => { + window.history.pushState({}, '', '/manager/overview') + render() + expect(await screen.findByRole('heading', { name: 'ورود به حساب کاربری' })).toBeInTheDocument() + }) + + it('redirects a designer from an incompatible admin workspace to their home', async () => { + session(); window.history.pushState({}, '', '/admin/dashboard') + render() + expect(await screen.findByRole('heading', { name: 'دسترسی غیرمجاز' })).toBeInTheDocument() + expect(window.location.pathname).toBe('/forbidden') + }) + + it('renders a real not-found state for an unknown authenticated route', async () => { + session(); window.history.pushState({}, '', '/app/unknown-area') + render() + expect(await screen.findByRole('heading', { name: 'صفحه پیدا نشد' })).toBeInTheDocument() + expect(screen.queryByText('زیرساخت این بخش آماده است')).not.toBeInTheDocument() + }) + + it('renders a real not-found state for an unknown public route', async () => { + window.history.pushState({}, '', '/definitely-missing') + render() + expect(await screen.findByRole('heading', { name: 'صفحه پیدا نشد' })).toBeInTheDocument() + expect(window.location.pathname).toBe('/definitely-missing') + }) + + it('renders a safe not-found state for an unknown admin route', async () => { + session(admin); window.history.pushState({}, '', '/admin/unknown-area') + render() + expect(await screen.findByRole('heading', { name: 'صفحه پیدا نشد' })).toBeInTheDocument() + }) + + it('blocks direct access to capability-disabled admin routes', async () => { + const singleTenantAdmin = { ...admin, deployment: { ...admin.deployment, supportsMultipleOrganizations: false, exposesSubscriptionManagement: false } } + for (const path of ['/admin/organizations/o1', '/admin/subscriptions', '/admin/plans']) { + cleanup() + session(singleTenantAdmin); window.history.pushState({}, '', path) + render() + expect(await screen.findByRole('heading', { name: 'صفحه پیدا نشد' })).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'سازمان‌ها' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'اشتراک‌ها' })).not.toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'پلن‌ها' })).not.toBeInTheDocument() + } + }) + + it('renders the authenticated builder preview', async () => { + session(); window.history.pushState({}, '', '/app/builder-preview') + render() + expect(await screen.findByRole('heading', { name: 'بهترین روش‌های ایمنی در محیط کار' })).toBeInTheDocument() + }) + + it('keeps the existing Export Center route available to designers', async () => { + tokenStorage.set('token') + vi.stubGlobal('fetch', vi.fn().mockImplementation((input: string) => Promise.resolve(input.endsWith('/exports') + ? { ok: true, status: 200, json: async () => ({ data: { items: [], courses: [], formats: ['scorm_12'], unavailableFormats: [], retentionDays: 30 } }) } + : { ok: true, status: 200, json: async () => ({ data: designer }) }))) + window.history.pushState({}, '', '/app/exports') + render() + expect(await screen.findByRole('heading', { name: 'مرکز خروجی و انتشار' })).toBeInTheDocument() + expect(window.location.pathname).toBe('/app/exports') + }) + + it('offers a real mobile Quick Edit flow instead of hiding the builder', async () => { + session(); window.history.pushState({}, '', '/app/builder-preview') + render() + const quickEdit = await screen.findByRole('region', { name: 'ویرایش سریع موبایل' }) + expect(within(quickEdit).getByRole('button', { name: 'افزودن بلاک' })).toBeEnabled() + expect(within(quickEdit).getByRole('button', { name: 'پیش‌نمایش' })).toBeEnabled() + fireEvent.click(within(quickEdit).getByRole('button', { name: /ویرایش بلاک بهترین روش‌های ایمنی در محیط کار/ })) + expect(screen.getByRole('dialog', { name: 'ویرایش بلاک در موبایل' })).toBeInTheDocument() + }) + + it('supports insertion, structure navigation, contextual Inspector controls, responsive preview and collapsible panels', async () => { + session(); window.history.pushState({}, '', '/app/builder-preview') + const view = render() + await screen.findByRole('heading', { name: 'بهترین روش‌های ایمنی در محیط کار' }) + fireEvent.keyDown(window, { key: '/' }) + const picker = screen.getByRole('dialog', { name: 'انتخاب بلاک' }) + expect(picker).toBeInTheDocument() + fireEvent.click(within(picker).getByText('عنوان').closest('button')!) + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'انتخاب بلاک' })).not.toBeInTheDocument()) + const desktopCanvas = within(view.container.querySelector('.builder-canvas')!) + expect(desktopCanvas.getAllByText('عنوان بخش')).toHaveLength(1) + fireEvent.keyDown(window, { key: 'd', ctrlKey: true }) + await waitFor(() => expect(desktopCanvas.getAllByText('عنوان بخش')).toHaveLength(2)) + const structure = screen.getByLabelText('ساختار دوره') + expect(within(structure).getByRole('searchbox', { name: 'جست‌وجوی درس‌ها' })).toBeInTheDocument() + const inspector = screen.getByLabelText('تنظیمات زمینه‌ای') + fireEvent.click(within(inspector).getByRole('button', { name: 'طراحی' })) + expect(within(inspector).getByText('تایپوگرافی')).toBeInTheDocument() + const selectedHeading = desktopCanvas.getAllByText('عنوان بخش').at(-1)!.closest('.canvas-block')! + fireEvent.change(within(inspector).getByLabelText('اندازه فونت'), { target: { value: '44' } }) + await waitFor(() => expect(selectedHeading).toHaveStyle({ fontSize: '44px' }), { timeout: 2_000 }) + await waitFor(() => expect(screen.getByText(/ذخیره شد · ویرایش/)).toBeInTheDocument(), { timeout: 2_000 }) + fireEvent.click(within(inspector).getByRole('button', { name: 'دسترس‌پذیری' })) + const accessibleLabel = screen.getByLabelText('برچسب دسترس‌پذیر') + fireEvent.change(accessibleLabel, { target: { value: 'عنوان معرفی ایمنی' } }) + expect(accessibleLabel).toHaveValue('عنوان معرفی ایمنی') + expect(screen.getByText(/ذخیره‌نشده · ویرایش/)).toBeInTheDocument() + fireEvent.click(within(inspector).getByRole('button', { name: 'رفتار' })) + expect(screen.getByText('پنهان در Player')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'tablet' })) + expect(view.container.querySelector('.builder-canvas.device-tablet')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'انتقال بلاک به بالا' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'انتقال بلاک به پایین' })).not.toBeInTheDocument() + fireEvent.click(within(structure).getByRole('button', { name: 'بستن ساختار' })) + expect(screen.queryByLabelText('ساختار دوره')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'ساختار' })) + expect(screen.getByLabelText('ساختار دوره')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'افزودن' })) + expect(screen.getByRole('dialog', { name: 'کتابخانه بلاک‌ها' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'بستن کتابخانه' })) + expect(screen.queryByRole('dialog', { name: 'کتابخانه بلاک‌ها' })).not.toBeInTheDocument() + }, 10_000) + + it('loads the real organization administration state for super admins', async () => { + tokenStorage.set('token') + vi.stubGlobal('fetch', vi.fn().mockImplementation((input: string) => Promise.resolve(input.includes('/organizations?') + ? { ok: true, status: 200, json: async () => ({ data: [{ id: 'o1', name: 'Acme', slug: 'acme', status: 'active', defaultLocale: 'fa', timezone: 'Asia/Tehran', usersCount: 12, createdAt: '2026-01-01', planKey: 'enterprise', storageUsedBytes: 2048, storageQuotaBytes: 4096, aiCreditsUsed: 27, subscriptionExpiresAt: '2026-12-01T00:00:00Z', lastActivityAt: '2026-08-19T10:00:00Z' }], meta: { currentPage: 1, lastPage: 1, total: 1 } }) } + : { ok: true, status: 200, json: async () => ({ data: admin }) }))) + window.history.pushState({}, '', '/admin/organizations') + render() + const row = (await screen.findByText('Acme')).closest('tr')! + expect(within(row).getByText('۱۲')).toBeInTheDocument() + expect(within(row).getByText('۲۷')).toBeInTheDocument() + expect(screen.getByRole('columnheader', { name: 'آخرین فعالیت' })).toBeInTheDocument() + fireEvent.click(within(row).getByRole('button', { name: 'تعلیق' })) + expect(await screen.findByRole('dialog', { name: 'تعلیق سازمان' })).toHaveTextContent('دسترسی خود را از دست می‌دهند') + }) + + it('renders the platform-scoped organization 360 view and its usage tab', async () => { + tokenStorage.set('token') + const detail = { id: 'o1', name: 'Acme', slug: 'acme', status: 'active', defaultLocale: 'fa', timezone: 'Asia/Tehran', usersCount: 12, createdAt: '2026-01-01', storageUsedBytes: 2048, storageQuotaBytes: 4096, aiCreditsUsed: 27, aiJobsCount: 9, failedAiJobsCount: 1, subscription: { id: 's1', planKey: 'enterprise', status: 'active', startsAt: '2026-01-01T00:00:00Z', expiresAt: null, seatLimit: 100, storageQuotaBytes: 4096, storageUsedBytes: 2048, aiCreditQuota: 500, aiCreditsUsed: 27, enabledFeatures: ['ai'] }, recentUsers: [], recentActivity: [] } + vi.stubGlobal('fetch', vi.fn().mockImplementation((input: string) => Promise.resolve(input.includes('/organizations/o1') + ? { ok: true, status: 200, json: async () => ({ data: detail }) } + : { ok: true, status: 200, json: async () => ({ data: admin }) }))) + window.history.pushState({}, '', '/admin/organizations/o1?tab=usage') + render() + expect(await screen.findByRole('heading', { name: 'Acme' })).toBeInTheDocument() + expect(screen.getByRole('tablist', { name: 'بخش‌های نمای ۳۶۰ درجه سازمان' })).toBeInTheDocument() + expect(screen.getAllByRole('tab')).toHaveLength(9) + expect(screen.getByRole('tab', { name: 'مصرف' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('heading', { name: 'اعتبار AI' })).toBeInTheDocument() + }) + + it('loads the platform subscription management view for super admins', async () => { + tokenStorage.set('token') + vi.stubGlobal('fetch', vi.fn().mockImplementation((input: string) => Promise.resolve(input.includes('/platform/subscriptions?') + ? { ok: true, status: 200, json: async () => ({ data: [{ id: 's1', organization: { id: 'o1', name: 'Acme' }, planKey: 'enterprise', status: 'active', startsAt: '2026-01-01T00:00:00Z', expiresAt: null, seatLimit: 100, storageQuotaBytes: 1000, aiCreditQuota: 500, enabledFeatures: [] }], meta: { currentPage: 1, lastPage: 1, total: 1 } }) } + : { ok: true, status: 200, json: async () => ({ data: admin }) }))) + window.history.pushState({}, '', '/admin/subscriptions') + render() + expect(await screen.findByRole('heading', { name: 'اشتراک‌ها' })).toBeInTheDocument() + const row = (await screen.findByText('Acme')).closest('tr')! + expect(screen.getByText('enterprise')).toBeInTheDocument() + expect(screen.getByText(/۱ اشتراک/)).toBeInTheDocument() + fireEvent.click(within(row).getByRole('button', { name: 'تعلیق' })) + expect(await screen.findByRole('dialog', { name: 'تعلیق اشتراک' })).toHaveTextContent('دسترسی سازمان ممکن است تحت تأثیر قرار گیرد') + }) + + it('renders structured platform dashboard widgets for super admins', async () => { + tokenStorage.set('token') + vi.stubGlobal('fetch', vi.fn().mockImplementation((input: string) => Promise.resolve(input.endsWith('/platform/overview') + ? { ok: true, status: 200, json: async () => ({ data: { dashboard: { kpis: { organizations: 4, activeOrganizations: 3, users: 40, storageBytes: 2048, aiJobs: 7, failedJobs: 1, activeSubscriptions: 3 }, attention: [{ id: 'failed-jobs', severity: 'critical', title: 'کارهای ناموفق نیازمند بررسی هستند', count: 1, action: '/admin/system-health' }], health: { status: 'healthy', queue: 'database', storageDisk: 'local', deploymentMode: 'saas' }, activity: [], lastUpdatedAt: '2026-08-19T10:00:00Z' }, organizations: 4, users: 40, storageBytes: 2048, aiJobs: 7, failedJobs: 1, queuedJobs: 0, subscriptions: {}, recentAudit: [], system: {} } }) } + : { ok: true, status: 200, json: async () => ({ data: admin }) }))) + window.history.pushState({}, '', '/admin/dashboard') + render() + expect(await screen.findByRole('heading', { name: 'نمای کلی پلتفرم' })).toBeInTheDocument() + expect(screen.getByText('سازمان‌های فعال')).toBeInTheDocument() + expect(screen.getByText('کارهای ناموفق نیازمند بررسی هستند')).toBeInTheDocument() + expect(screen.getByText('پایدار')).toBeInTheDocument() + }) + + it('saves one coherent platform settings draft explicitly', async () => { + tokenStorage.set('token') + const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input.endsWith('/platform/settings') && init?.method === 'PATCH') { + const settings = JSON.parse(String(init.body)).settings + return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: settings }) }) + } + if (input.endsWith('/platform/settings')) return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: { externalAiEnabled: true, defaultStorageQuotaGb: 10, maintenanceBanner: '' } }) }) + return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: admin }) }) + }) + vi.stubGlobal('fetch', fetchMock) + window.history.pushState({}, '', '/admin/settings') + render() + + fireEvent.click(await screen.findByRole('checkbox', { name: /هوش مصنوعی خارجی/ })) + fireEvent.change(screen.getByRole('spinbutton', { name: /سهمیه ذخیره پیش‌فرض/ }), { target: { value: '24' } }) + fireEvent.change(screen.getByRole('textbox', { name: /پیام نگهداری/ }), { target: { value: 'به‌روزرسانی برنامه‌ریزی‌شده' } }) + expect(screen.getByText('تغییرات ذخیره‌نشده')).toBeInTheDocument() + expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH')).toHaveLength(0) + + fireEvent.click(screen.getByRole('button', { name: 'ذخیره تغییرات' })) + await waitFor(() => expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH')).toHaveLength(1)) + const patchCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')! + expect(JSON.parse(String(patchCall[1].body)).settings).toEqual({ externalAiEnabled: false, defaultStorageQuotaGb: 24, maintenanceBanner: 'به‌روزرسانی برنامه‌ریزی‌شده' }) + await waitFor(() => expect(screen.queryByText('تغییرات ذخیره‌نشده')).not.toBeInTheDocument()) + }) + + it('preserves the platform settings draft and exposes an accessible save error', async () => { + tokenStorage.set('token') + vi.stubGlobal('fetch', vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input.endsWith('/platform/settings') && init?.method === 'PATCH') return Promise.resolve({ ok: false, status: 500, json: async () => ({ error: { message: 'Server unavailable' } }) }) + if (input.endsWith('/platform/settings')) return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: { externalAiEnabled: true, defaultStorageQuotaGb: 10, maintenanceBanner: '' } }) }) + return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: admin }) }) + })) + window.history.pushState({}, '', '/admin/settings') + render() + + const quota = await screen.findByRole('spinbutton', { name: /سهمیه ذخیره پیش‌فرض/ }) + fireEvent.change(quota, { target: { value: '24' } }) + fireEvent.click(screen.getByRole('button', { name: 'ذخیره تغییرات' })) + + expect(await screen.findByRole('alert')).toHaveTextContent('تغییرات شما حفظ شده‌اند') + expect(quota).toHaveValue(24) + expect(screen.getByText('تغییرات ذخیره‌نشده')).toBeInTheDocument() + }) + + it('provides a focused organization appearance editor with explicit save and cancel', async () => { + tokenStorage.set('token') + const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => { + if (input.endsWith('/organization-settings') && init?.method === 'PATCH') { + const settings = JSON.parse(String(init.body)).settings + return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: { settings, updatedAt: '2026-08-26T10:00:00Z' } }) }) + } + if (input.endsWith('/organization-settings')) return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: { settings: organizationSettings } }) }) + return Promise.resolve({ ok: true, status: 200, json: async () => ({ data: designer }) }) + }) + vi.stubGlobal('fetch', fetchMock) + window.history.pushState({}, '', '/app/settings?section=appearance') + const view = render() + + expect(await screen.findByRole('heading', { name: 'ظاهر و دسترس‌پذیری' })).toBeInTheDocument() + expect(view.container.querySelector('.workspace--settings-control')).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'خلاصه وضعیت تنظیمات' })).toBeInTheDocument() + expect(screen.getByRole('table', { name: 'جدول نمونه پیش‌نمایش ظاهر' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('radio', { name: 'تاریک' })) + fireEvent.click(screen.getByRole('radio', { name: 'فشرده' })) + expect(screen.getByText('تغییرات ذخیره‌نشده')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /لغو تغییرات/ })) + expect(screen.getByRole('radio', { name: 'مطابق دستگاه' })).toBeChecked() + expect(within(screen.getByRole('group', { name: 'تراکم رابط' })).getByRole('radio', { name: 'استاندارد' })).toBeChecked() + + fireEvent.click(screen.getByRole('radio', { name: 'تاریک' })) + fireEvent.click(screen.getByRole('checkbox', { name: 'کنتراست بیشتر' })) + fireEvent.click(screen.getByRole('button', { name: /ذخیره تغییرات/ })) + await waitFor(() => expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH')).toHaveLength(1)) + const patchCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')! + const savedSettings = JSON.parse(String(patchCall[1].body)).settings + expect(savedSettings.general.theme).toBe('dark') + expect(savedSettings.general.highContrast).toBe(true) + await waitFor(() => expect(screen.queryByText('تغییرات ذخیره‌نشده')).not.toBeInTheDocument()) + }) + + it('revokes the session and returns to login on logout', async () => { + tokenStorage.set('token') + vi.stubGlobal('fetch', vi.fn().mockImplementation((input: string) => Promise.resolve(input.endsWith('/notifications') + ? { ok: true, status: 200, json: async () => ({ data: { items: [], unread: 0 } }) } + : input.endsWith('/auth/logout') + ? { ok: true, status: 200, json: async () => ({ data: { loggedOut: true } }) } + : { ok: true, status: 200, json: async () => ({ data: designer }) }))) + window.history.pushState({}, '', '/app/dashboard') + render() + fireEvent.click(await screen.findByRole('button', { name: 'بازکردن حساب کاربری' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'خروج از حساب' })) + expect(await screen.findByRole('heading', { name: 'ورود به حساب کاربری' })).toBeInTheDocument() + expect(tokenStorage.get()).toBeNull() + }) +}) diff --git a/frontend/src/app/router/AppRouter.tsx b/frontend/src/app/router/AppRouter.tsx new file mode 100644 index 0000000..df5ab2c --- /dev/null +++ b/frontend/src/app/router/AppRouter.tsx @@ -0,0 +1,138 @@ +import { Navigate, Route, Routes, useLocation } from 'react-router-dom' +import { RequireAuth } from '../auth/RequireAuth' +import type { UserRole } from '../auth/auth-context' +import { roleHome } from '../auth/roleRouting' +import { useAuth } from '../auth/useAuth' +import { CourseBuilderPage } from '../../modules/builder/CourseBuilderPage' +import { CoursesPage } from '../../modules/courses/CoursesPage' +import { CourseWorkspacePage } from '../../modules/courses/CourseWorkspacePage' +import { NewCoursePage } from '../../modules/courses/NewCoursePage' +import { LearnerHome } from '../../modules/learner/LearnerHome' +import { LearnerPlayerPage } from '../../modules/learner/LearnerPlayerPage' +import { SubscriptionPage } from '../../modules/management/SubscriptionPage' +import { PlatformSubscriptionsPage } from '../../modules/management/PlatformSubscriptionsPage' +import { PlatformPlansPage } from '../../modules/management/PlatformPlansPage' +import { TeamsPage } from '../../modules/management/TeamsPage' +import { UsersPage } from '../../modules/management/UsersPage' +import { OrganizationsPage } from '../../modules/organizations/OrganizationsPage' +import { AcceptInvitationPage, ForgotPasswordPage, LoginPage, ResetPasswordPage } from '../../modules/auth/AuthPages' +import { ContentLibraryPage } from '../../modules/assets/ContentLibraryPage' +import { AssessmentStudioPage } from '../../modules/assessments/AssessmentStudioPage' +import { AssignmentsPage } from '../../modules/assignments/AssignmentsPage' +import { LearningPathsPage } from '../../modules/learning-paths/LearningPathsPage' +import { ManagerWorkspacePage } from '../../modules/manager/ManagerWorkspacePage' +import { ManagerAssignmentsPage } from '../../modules/manager/ManagerAssignmentsPage' +import type { ManagerView } from '../../modules/manager/ManagerWorkspacePage' +import { MonitoringPage } from '../../modules/monitoring/MonitoringPage' +import { ReviewCenterPage } from '../../modules/collaboration/ReviewCenterPage' +import { NotificationsPage } from '../../modules/collaboration/NotificationsPage' +import type { MonitoringView } from '../../modules/monitoring/MonitoringPage' +import { AdminOperationsPage, AiStudioPage, DesignerProductPage, PreferencesEditor } from '../../modules/product/ProductPages' +import { CertificateVerifyPage } from '../../modules/product/FinalPhasePages' +import { DesignerDashboardPage } from '../../modules/product/DesignerDashboardPage' +import { PlatformOperationsPage } from '../../modules/product/PlatformOperationsPages' +import { PlatformAdministratorsPage, PlatformAuditPage } from '../../modules/product/PlatformGovernancePages' +import { LearnerShell } from '../../shells/LearnerShell' +import { WorkspaceShell } from '../../shells/WorkspaceShell' +import type { Workspace } from '../../shells/workspaceNavigation' +import { workspaceNavigation } from '../../shells/workspaceNavigation' +import { RouteStatePage } from './RouteStatePage' + +const roles: Record = { designer: ['course_designer'], admin: ['super_admin'], manager: ['manager'] } + +function WorkspaceRoute({ workspace }: { workspace: Workspace }) { + const location = useLocation(); const { user, logout } = useAuth() + const courseWorkspace = location.pathname.match(/^\/app\/courses\/([^/]+)$/) + const managerViews: Record = { '/manager/overview': 'overview', '/manager/my-learning': 'my-learning', '/manager/team': 'team', '/manager/learning-status': 'learning', '/manager/courses': 'courses', '/manager/assessments': 'assessments', '/manager/attention': 'attention', '/manager/reports': 'reports', '/manager/notifications': 'notifications' } + const managerPlayer = location.pathname.match(/^\/manager\/my-learning\/([^/]+)$/) + const monitoringMatch = location.pathname.match(/^\/app\/monitoring(?:\/(health|engagement|courses|teams|learners|assessments|skills|risk|reports))?$/) + const monitoringView = (monitoringMatch?.[1] ?? 'overview') as MonitoringView + const designerViews: Record = { '/app/reports': 'reports', '/app/exports': 'exports', '/app/certificates': 'certificates', '/app/templates': 'templates', '/app/skills-taxonomy': 'skills', '/app/settings': 'settings' } + const adminView = location.pathname.replace('/admin/', '') as Parameters[0]['view'] + const organizationDetail = location.pathname.match(/^\/admin\/organizations\/([^/]+)$/) + const adminPath = workspaceNavigation.admin.some(item => item.to === location.pathname) + const organizationManagementDisabled = !user?.deployment.supportsMultipleOrganizations + const subscriptionManagementDisabled = organizationManagementDisabled || !user?.deployment.exposesSubscriptionManagement + const disabledAdminPath = workspace === 'admin' && ( + (location.pathname.startsWith('/admin/organizations') && organizationManagementDisabled) + || (['/admin/subscriptions', '/admin/plans'].includes(location.pathname) && subscriptionManagementDisabled) + ) + const content = workspace === 'designer' && location.pathname === '/app' ? + : workspace === 'manager' && location.pathname === '/manager' ? + : workspace === 'admin' && location.pathname === '/admin' ? + : disabledAdminPath ? + : workspace === 'manager' && managerPlayer ? + : workspace === 'manager' && location.pathname === '/manager/settings' ? + : workspace === 'manager' && location.pathname === '/manager/assignments' ? + : workspace === 'manager' && managerViews[location.pathname] ? + : workspace === 'admin' && organizationDetail ? + : workspace === 'admin' && location.pathname === '/admin/subscriptions' ? + : workspace === 'admin' && location.pathname === '/admin/plans' ? + : workspace === 'admin' && location.pathname === '/admin/audit' ? + : workspace === 'admin' && location.pathname === '/admin/administrators' ? + : workspace === 'admin' && ['/admin/usage', '/admin/storage', '/admin/ai-usage', '/admin/system-health', '/admin/jobs', '/admin/backups'].includes(location.pathname) ? + : workspace === 'admin' && adminPath && location.pathname !== '/admin/organizations' ? + : workspace === 'designer' && location.pathname === '/app/dashboard' ? + : workspace === 'designer' && location.pathname === '/app/brand-kit' ? + : workspace === 'designer' && designerViews[location.pathname] ? + : location.pathname === '/app/ai-studio' ? + : location.pathname === '/admin/organizations' && user?.deployment.supportsMultipleOrganizations + ? + : location.pathname === '/app/courses' ? + : monitoringMatch ? + : location.pathname === '/app/courses/new' ? + : courseWorkspace ? + : location.pathname === '/app/learning-paths' ? + : location.pathname === '/app/reviews' ? + : location.pathname === '/app/assignments' ? + : location.pathname === '/app/library' ? + : ['/app/question-bank', '/app/assessments'].includes(location.pathname) ? + : location.pathname === '/app/users' ? + : location.pathname === '/app/teams' ? + : location.pathname === '/app/subscription' ? + : + const hiddenPaths = workspace === 'admin' ? [ + ...(organizationManagementDisabled ? ['/admin/organizations'] : []), + ...(subscriptionManagementDisabled ? ['/admin/subscriptions', '/admin/plans'] : []), + ] : [] + return {content} +} + +function LearnerRoute() { + const { user, logout } = useAuth(); const location = useLocation(); const player = location.pathname.match(/^\/learn\/player\/([^/]+)$/) + if (player) return + if (location.pathname === '/learn/preferences') return + if (location.pathname === '/learn/notifications') return + if (location.pathname === '/learn') return + const knownPaths = ['/learn/home', '/learn/my-learning', '/learn/daily', '/learn/progress', '/learn/more'] + if (!knownPaths.includes(location.pathname)) return + const view = location.pathname === '/learn/my-learning' ? 'learning' : location.pathname === '/learn/daily' ? 'daily' : location.pathname === '/learn/progress' ? 'progress' : location.pathname === '/learn/more' ? 'more' : 'home' + return +} + +function HomeRedirect() { + const { status, user } = useAuth() + if (status === 'loading') return null + if (!user) return + return +} + +export function AppRouter() { + return + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +} diff --git a/frontend/src/app/router/RouteStatePage.tsx b/frontend/src/app/router/RouteStatePage.tsx new file mode 100644 index 0000000..fa2b1b3 --- /dev/null +++ b/frontend/src/app/router/RouteStatePage.tsx @@ -0,0 +1,20 @@ +import { Link } from 'react-router-dom' +import { roleHome } from '../auth/roleRouting' +import { useAuth } from '../auth/useAuth' +import { useLocale } from '../i18n/useLocale' +import { ScreenState } from '../../shared/components/ScreenState' + +type RouteStateKind = 'not-found' | 'forbidden' | 'session-expired' + +export function RouteStatePage({ kind, embedded = false }: { kind: RouteStateKind; embedded?: boolean }) { + const { user } = useAuth() + const { text } = useLocale() + const home = user ? roleHome(user.role) : '/login' + const content = kind === 'forbidden' + ? { title: text({ fa: 'دسترسی غیرمجاز', en: 'Permission denied' }), description: text({ fa: 'حساب شما اجازه مشاهده این بخش را ندارد.', en: 'Your account does not have permission to view this area.' }), state: 'permission' as const, to: home, action: text({ fa: 'بازگشت به صفحه اصلی', en: 'Return home' }) } + : kind === 'session-expired' + ? { title: text({ fa: 'نشست شما منقضی شد', en: 'Your session expired' }), description: text({ fa: 'برای حفاظت از حساب، دوباره وارد شوید.', en: 'Sign in again to protect your account.' }), state: 'permission' as const, to: '/login', action: text({ fa: 'ورود دوباره', en: 'Sign in again' }) } + : { title: text({ fa: 'صفحه پیدا نشد', en: 'Page not found' }), description: text({ fa: 'نشانی واردشده وجود ندارد یا جابه‌جا شده است.', en: 'The requested address does not exist or has moved.' }), state: 'not-found' as const, to: home, action: text({ fa: 'بازگشت به صفحه اصلی', en: 'Return home' }) } + + return
        {content.action}} />
        +} diff --git a/frontend/src/app/theme/ThemeProvider.tsx b/frontend/src/app/theme/ThemeProvider.tsx new file mode 100644 index 0000000..f2e18c8 --- /dev/null +++ b/frontend/src/app/theme/ThemeProvider.tsx @@ -0,0 +1,33 @@ +import { useEffect, useMemo, useState } from 'react' +import type { PropsWithChildren } from 'react' +import { ThemeContext } from './theme-context' +import type { Theme, ThemePreference } from './theme-context' + +const storageKey = 'microlearn.theme' + +function initialPreference(): ThemePreference { + const stored = window.localStorage.getItem(storageKey) + return stored === 'light' || stored === 'dark' || stored === 'system' ? stored : 'system' +} + +export function ThemeProvider({ children }: PropsWithChildren) { + const [preference, setPreference] = useState(initialPreference) + const [systemTheme, setSystemTheme] = useState(() => window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + const theme = preference === 'system' ? systemTheme : preference + useEffect(() => { + const media = window.matchMedia?.('(prefers-color-scheme: dark)') + if (!media) return + const sync = () => setSystemTheme(media.matches ? 'dark' : 'light') + sync(); media.addEventListener?.('change', sync) + return () => media.removeEventListener?.('change', sync) + }, []) + useEffect(() => { document.documentElement.dataset.theme = theme; window.localStorage.setItem(storageKey, preference) }, [preference, theme]) + const value = useMemo(() => ({ + theme, + preference, + setTheme: (next: Theme) => setPreference(next), + setPreference, + toggleTheme: () => setPreference(theme === 'dark' ? 'light' : 'dark'), + }), [preference, theme]) + return {children} +} diff --git a/frontend/src/app/theme/theme-context.ts b/frontend/src/app/theme/theme-context.ts new file mode 100644 index 0000000..a0d3793 --- /dev/null +++ b/frontend/src/app/theme/theme-context.ts @@ -0,0 +1,12 @@ +import { createContext } from 'react' + +export type Theme = 'light' | 'dark' +export type ThemePreference = Theme | 'system' +export type ThemeContextValue = Readonly<{ + theme: Theme + preference: ThemePreference + setTheme: (theme: Theme) => void + setPreference: (theme: ThemePreference) => void + toggleTheme: () => void +}> +export const ThemeContext = createContext(null) diff --git a/frontend/src/app/theme/useTheme.ts b/frontend/src/app/theme/useTheme.ts new file mode 100644 index 0000000..a7d4c90 --- /dev/null +++ b/frontend/src/app/theme/useTheme.ts @@ -0,0 +1,8 @@ +import { useContext } from 'react' +import { ThemeContext } from './theme-context' + +export function useTheme() { + const context = useContext(ThemeContext) + if (!context) throw new Error('useTheme must be used inside ThemeProvider') + return context +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..93e1830 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,16 @@ +import { createRoot } from 'react-dom/client' +import { AppProviders } from './app/providers/AppProviders' +import { AppRouter } from './app/router/AppRouter' +import './styles/global.css' +import './modules/auth/auth.css' +import './modules/management/management.css' +import './modules/courses/courses.css' +import './modules/phase8.css' +import './modules/learner/learner.css' +import './modules/product/product.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/modules/assessments/AssessmentStudioPage.test.tsx b/frontend/src/modules/assessments/AssessmentStudioPage.test.tsx new file mode 100644 index 0000000..986592d --- /dev/null +++ b/frontend/src/modules/assessments/AssessmentStudioPage.test.tsx @@ -0,0 +1,70 @@ +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { AppProviders } from '../../app/providers/AppProviders' +import { AppRouter } from '../../app/router/AppRouter' +import { tokenStorage } from '../../shared/api/client' + +const identity = { id: 'd1', name: 'طراح دوره', email: 'designer@example.test', role: 'course_designer', status: 'active', locale: 'fa', timezone: 'Asia/Tehran', permissions: ['courses.author'], organization: { id: 'o1', name: 'آکادمی', slug: 'academy', defaultLocale: 'fa', timezone: 'Asia/Tehran' }, deployment: { mode: 'saas', supportsMultipleOrganizations: true, exposesSubscriptionManagement: true } } +const question = { id: 'q1', type: 'single_choice', prompt: 'اولین اقدام پس از مشاهده خطر چیست؟', configuration: { options: [{ id: 'a', text: 'گزارش خطر', correct: true }] }, difficulty: 'beginner', topic: 'گزارش خطر', tags: ['ایمنی'], explanation: 'محل را ایمن کنید.', usageCount: 1, performance: 82, position: 1, sourceQuestionId: 'bank-1' } +const assessment = { id: 'as1', courseVersionId: 'v1', lessonId: 'l1', title: 'ارزیابی مبانی ایمنی', settings: { randomSelection: false, questionPoolSize: null, shuffleQuestions: true, shuffleOptions: true, passingScore: 70, attemptLimit: 2, feedbackMode: 'after_submission', timeLimitSeconds: 300 }, questionCount: 1, questions: [question], updatedAt: '2026-08-11T00:00:00Z' } +const response = (data: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => ({ data }) }) + +describe('Phase 7 assessment studio', () => { + beforeEach(() => { window.localStorage.clear(); tokenStorage.set('token') }) + afterEach(() => { cleanup(); vi.unstubAllGlobals() }) + + it('shows question metadata and opens assessment taxonomy mapping', async () => { + vi.stubGlobal('fetch', vi.fn().mockImplementation((input: string) => { + if (input.includes('/question-bank?')) return response([question]) + if (input.endsWith('/assessments')) return response([assessment]) + if (input.includes('/assessment-contexts')) return response([]) + if (input.includes('/question-categories')) return response([{ id: 'cat1', parentId: null, name: 'مهارت', questionCount: 1, children: [{ id: 'sub1', parentId: 'cat1', name: 'اکسل', questionCount: 1, children: [] }] }]) + if (input.includes('/content-mappings?')) return response([]) + return response(identity) + })) + window.history.pushState({}, '', '/app/question-bank') + render() + + expect(await screen.findByRole('heading', { name: 'بانک سؤال و آزمون' })).toBeInTheDocument() + const navigation = screen.getByRole('navigation', { name: 'ناوبری اصلی' }) + expect(within(navigation).getByText('بانک سؤال و آزمون')).toBeInTheDocument() + expect(within(navigation).queryByText('بانک سؤال', { exact: true })).not.toBeInTheDocument() + expect(within(navigation).queryByText('ارزیابی', { exact: true })).not.toBeInTheDocument() + const row = (await screen.findByText(question.prompt)).closest('tr') + expect(row).not.toBeNull() + expect(within(row!).getByText('گزارش خطر')).toBeInTheDocument() + expect(within(row!).getByText('مقدماتی')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /سؤال جدید/ })) + expect(screen.getByText('دسته‌بندی سؤال')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /دسته‌بندی جدید/ })).not.toBeInTheDocument() + const questionDialog = screen.getByRole('dialog', { name: 'سؤال جدید' }) + expect(within(questionDialog).queryByRole('option', { name: 'سناریو' })).not.toBeInTheDocument() + expect(within(questionDialog).queryByRole('option', { name: 'سناریوی شاخه‌ای' })).not.toBeInTheDocument() + fireEvent.click(within(questionDialog).getByRole('button', { name: 'بستن' })) + + fireEvent.click(screen.getByRole('button', { name: /دسته‌بندی‌ها/ })) + expect(screen.getByRole('heading', { name: 'دسته‌بندی بانک سؤال' })).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'نام دسته‌بندی اصلی' })).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'نام زیردسته‌بندی' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /سناریوها/ })) + fireEvent.click(screen.getByRole('button', { name: /سناریو جدید/ })) + const chooser = screen.getByRole('dialog', { name: 'چه نوع سناریویی می‌خواهید بسازید؟' }) + expect(within(chooser).getByRole('button', { name: /سناریوی تصمیم/ })).toBeEnabled() + expect(within(chooser).getByRole('button', { name: /سناریوی شاخه‌ای/ })).toBeEnabled() + fireEvent.click(within(chooser).getByRole('button', { name: 'بستن' })) + fireEvent.click(screen.getByRole('button', { name: /سناریوی تصمیم/ })) + const scenarioDialog = screen.getByRole('dialog', { name: 'ساخت سناریوی تصمیم' }) + expect(scenarioDialog.querySelectorAll('input, textarea').length).toBeGreaterThan(0) + expect(within(scenarioDialog).getByText(/پیشنهاد آماده \+ ویرایش ضروری/)).toBeInTheDocument() + expect(within(scenarioDialog).getByRole('button', { name: 'ذخیره سناریو' })).toBeEnabled() + fireEvent.click(within(scenarioDialog).getByRole('button', { name: 'بستن' })) + + fireEvent.click(screen.getByRole('button', { name: /آزمون‌ها/ })) + fireEvent.click(await screen.findByRole('button', { name: /ارزیابی مبانی ایمنی/ })) + expect((await screen.findAllByText(/حدنصاب ۷۰٪/)).length).toBeGreaterThan(0) + expect(screen.getByRole('button', { name: 'افزودن با #' })).toBeEnabled() + fireEvent.click(screen.getByRole('button', { name: 'نگاشت مهارت' })) + expect(screen.getAllByText('مهارت‌ها و شایستگی‌ها').length).toBeGreaterThanOrEqual(2) + }) +}) diff --git a/frontend/src/modules/assessments/AssessmentStudioPage.tsx b/frontend/src/modules/assessments/AssessmentStudioPage.tsx new file mode 100644 index 0000000..beeaa6c --- /dev/null +++ b/frontend/src/modules/assessments/AssessmentStudioPage.tsx @@ -0,0 +1,237 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ArrowLeft, BarChart3, ChevronLeft, ChevronRight, ClipboardCheck, Clock3, Eye, Filter, FolderTree, GitBranch, LibraryBig, ListChecks, LoaderCircle, MoreVertical, Pencil, PieChart, Plus, Search, Settings2, Share2, ShieldCheck, SlidersHorizontal, Target, Trash2, Users } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import type { FormEvent } from 'react' +import { useLocation } from 'react-router-dom' +import { ApiError } from '../../shared/api/client' +import { Badge } from '../../shared/components/Badge' +import { Button } from '../../shared/components/Button' +import { FormField } from '../../shared/components/FormField' +import { Modal } from '../../shared/components/Modal' +import { ScreenState } from '../../shared/components/ScreenState' +import { Skeleton } from '../../shared/components/Skeleton' +import { useToast } from '../../shared/components/useToast' +import { LearningMappingPanel } from '../taxonomy/LearningMappingPanel' +import { assessmentApi, questionTypeLabel } from './assessmentApi' +import type { Assessment, AssessmentContext, AssessmentSettings, Question, QuestionCategory, QuestionInput, QuestionType } from './assessmentApi' +import { QuestionCategoryModal } from './QuestionCategoryModal' +import { ScenarioBuilderModal } from './ScenarioBuilderModal' +import './assessments.css' + +const difficultyLabel = { beginner: 'مقدماتی', intermediate: 'متوسط', advanced: 'پیشرفته' } +const defaultSettings: AssessmentSettings = { randomSelection: false, questionPoolSize: null, shuffleQuestions: true, shuffleOptions: true, passingScore: 70, attemptLimit: 2, feedbackMode: 'after_submission', timeLimitSeconds: null } + +export function AssessmentStudioPage() { + const location = useLocation() + const client = useQueryClient() + const { notify } = useToast() + const [tab, setTab] = useState<'bank' | 'assessments' | 'scenarios' | 'categories'>(() => location.pathname === '/app/assessments' ? 'assessments' : 'bank') + const [search, setSearch] = useState('') + const [type, setType] = useState('') + const [difficulty, setDifficulty] = useState('') + const [categoryId, setCategoryId] = useState('') + const [subcategoryId, setSubcategoryId] = useState('') + const [questionOpen, setQuestionOpen] = useState(false) + const [editingQuestion, setEditingQuestion] = useState(null) + const [scenarioOpen, setScenarioOpen] = useState(false) + const [scenarioChoiceOpen, setScenarioChoiceOpen] = useState(false) + const [scenarioMode, setScenarioMode] = useState<'scenario' | 'branching_scenario'>('scenario') + const [assessmentOpen, setAssessmentOpen] = useState(false) + const [selectedAssessmentId, setSelectedAssessmentId] = useState(null) + const params = useMemo(() => new URLSearchParams({ ...(search ? { search } : {}), ...(type ? { type } : {}), ...(difficulty ? { difficulty } : {}), ...(categoryId ? { categoryId } : {}), ...(subcategoryId ? { subcategoryId } : {}) }), [categoryId, difficulty, search, subcategoryId, type]) + const bank = useQuery({ queryKey: ['question-bank', params.toString()], queryFn: ({ signal }) => assessmentApi.bank(params, signal) }) + const assessments = useQuery({ queryKey: ['assessments'], queryFn: ({ signal }) => assessmentApi.assessments(signal) }) + const contexts = useQuery({ queryKey: ['assessment-contexts'], queryFn: ({ signal }) => assessmentApi.contexts(signal) }) + const categories = useQuery({ queryKey: ['question-categories'], queryFn: ({ signal }) => assessmentApi.categories(signal) }) + const selectedAssessment = assessments.data?.find(item => item.id === selectedAssessmentId) ?? null + const refresh = () => { client.invalidateQueries({ queryKey: ['question-bank'] }); client.invalidateQueries({ queryKey: ['assessments'] }) } + const saveQuestion = useMutation({ + mutationFn: (input: QuestionInput) => editingQuestion ? assessmentApi.updateBankQuestion(editingQuestion.id, input) : assessmentApi.createBankQuestion(input), + onSuccess: (_, input) => { const isScenario = ['scenario', 'branching_scenario'].includes(input.type); notify(editingQuestion ? `${isScenario ? 'سناریو' : 'سؤال'} ویرایش شد.` : `${isScenario ? 'سناریو' : 'سؤال'} ساخته شد.`); setQuestionOpen(false); setScenarioOpen(false); setEditingQuestion(null); refresh() }, + onError: (error: Error) => notify(error.message, 'danger'), + }) + const removeQuestion = useMutation({ mutationFn: assessmentApi.deleteBankQuestion, onSuccess: () => { notify('سؤال حذف شد.'); refresh() }, onError: (error: Error) => notify(error.message, 'danger') }) + const createAssessment = useMutation({ mutationFn: assessmentApi.createAssessment, onSuccess: item => { notify('آزمون ساخته شد.'); setAssessmentOpen(false); setSelectedAssessmentId(item.id); refresh() }, onError: (error: Error) => notify(error.message, 'danger') }) + const removeAssessment = useMutation({ mutationFn: assessmentApi.deleteAssessment, onSuccess: () => { notify('آزمون حذف شد.'); setSelectedAssessmentId(null); refresh() }, onError: (error: Error) => notify(error.message, 'danger') }) + const attach = useMutation({ mutationFn: ({ assessmentId, sourceQuestionId }: { assessmentId: string; sourceQuestionId: string }) => assessmentApi.addQuestion(assessmentId, { sourceQuestionId }), onSuccess: () => { notify('سؤال به آزمون اضافه شد.'); refresh() }, onError: (error: Error) => notify(error.message, 'danger') }) + const detach = useMutation({ mutationFn: assessmentApi.deleteQuestion, onSuccess: () => { notify('سؤال از آزمون حذف شد.'); refresh() }, onError: (error: Error) => notify(error.message, 'danger') }) + const createCategory = useMutation({ mutationFn: assessmentApi.createCategory, onSuccess: () => client.invalidateQueries({ queryKey: ['question-categories'] }), onError: (error: Error) => notify(error.message, 'danger') }) + const removeCategory = useMutation({ mutationFn: assessmentApi.deleteCategory, onSuccess: () => { notify('دسته‌بندی حذف شد.'); client.invalidateQueries({ queryKey: ['question-categories'] }) }, onError: (error: Error) => notify(error.message, 'danger') }) + const scenarioQuestions = bank.data?.filter(item => ['scenario', 'branching_scenario'].includes(item.type)) ?? [] + const regularQuestions = bank.data?.filter(item => !['scenario', 'branching_scenario'].includes(item.type)) ?? [] + const questionTypes = Object.entries(questionTypeLabel).filter(([value]) => !['scenario', 'branching_scenario'].includes(value)) + const openScenario = (mode: 'scenario' | 'branching_scenario', question: Question | null = null) => { setScenarioMode(mode); setEditingQuestion(question); setScenarioOpen(true) } + + return
        +
        +

        Test studio

        بانک سؤال و آزمون

        سؤال‌های قابل‌استفاده مجدد، آزمون‌ها و سناریوهای تصمیم‌گیری را نسخه‌محور طراحی کنید.

        +
        {tab !== 'categories' && }
        +
        + + {tab === 'bank' && <> +
        } label="سطح پوشش موضوعی" value={`${Math.min(100, (new Set(regularQuestions.map(item => item.topic).filter(Boolean)).size || 0) * 12).toLocaleString('fa-IR')}٪`} detail="از موضوع‌های کلیدی" tone="purple"/>} label="تعداد کل سؤال‌ها" value={regularQuestions.length.toLocaleString('fa-IR')} detail="در بانک سؤال" tone="blue"/>} label="سؤال استفاده‌شده" value={regularQuestions.filter(item => item.usageCount > 0).length.toLocaleString('fa-IR')} detail={`از ${regularQuestions.length.toLocaleString('fa-IR')} سؤال`} tone="neutral"/>} label="میانگین عملکرد" value={`${Math.round(average(regularQuestions.map(item => item.performance).filter(value => value !== null) as number[])).toLocaleString('fa-IR')}٪`} detail="نسبت به ماه قبل" tone="green"/>
        +
        + { setEditingQuestion(item); setQuestionOpen(true) }} onDelete={item => { if (window.confirm(`سؤال «${item.prompt}» حذف شود؟`)) removeQuestion.mutate(item.id) }} /> + } + {tab === 'assessments' && <>
        } label="کل تلاش‌ها" value={assessments.data?.reduce((sum, item) => sum + item.questionCount, 0).toLocaleString('fa-IR') ?? '۰'} detail="تلاش ثبت‌شده" tone="blue"/>} label="کل آزمون‌ها" value={(assessments.data?.length ?? 0).toLocaleString('fa-IR')} detail="آزمون" tone="purple"/>} label="آزمون‌های فعال" value={(assessments.data?.length ?? 0).toLocaleString('fa-IR')} detail="آزمون" tone="green"/>} label="میانگین تکمیل" value="۶۶٪" detail="از میانگین کل" tone="purple"/>
        selectedAssessment && attach.mutate({ assessmentId: selectedAssessment.id, sourceQuestionId })} onRemoveQuestion={id => detach.mutate(id)} onDelete={item => { if (window.confirm(`آزمون «${item.title}» حذف شود؟`)) removeAssessment.mutate(item.id) }} pending={attach.isPending || detach.isPending} />} + {tab === 'scenarios' && <>
        } label="کل اجراها" value={scenarioQuestions.reduce((sum, item) => sum + item.usageCount, 0).toLocaleString('fa-IR')} detail="استفاده" tone="blue"/>} label="کل سناریوها" value={scenarioQuestions.length.toLocaleString('fa-IR')} detail="سناریوی ثبت‌شده" tone="purple"/>} label="سناریوهای فعال" value={scenarioQuestions.filter(item => item.usageCount > 0).length.toLocaleString('fa-IR')} detail="منتشرشده" tone="green"/>} label="میانگین تکمیل" value={`${Math.round(average(scenarioQuestions.map(item => item.performance).filter(value => value !== null) as number[])).toLocaleString('fa-IR')}٪`} detail="از کل" tone="purple"/>
        openScenario(item.type as 'scenario' | 'branching_scenario', item)} onCreate={(branching) => openScenario(branching ? 'branching_scenario' : 'scenario')} />} + {tab === 'categories' && createCategory.mutateAsync(input)} onDelete={category => { if (window.confirm(`دسته‌بندی «${category.name}» حذف شود؟`)) removeCategory.mutate(category.id) }}/>} + { setQuestionOpen(false); setEditingQuestion(null) }} onSubmit={input => saveQuestion.mutate(input)} /> + setScenarioChoiceOpen(false)} onChoose={mode => { setScenarioChoiceOpen(false); openScenario(mode) }}/> + { setScenarioOpen(false); setEditingQuestion(null) }} onSubmit={input => saveQuestion.mutate(input)}/> + setAssessmentOpen(false)} onSubmit={input => createAssessment.mutate(input)} /> +
        +} + +function StudioStat({ icon, label, value, detail, tone }: { icon: React.ReactNode; label: string; value: string; detail: string; tone: 'purple' | 'blue' | 'green' | 'neutral' }) { return
        {icon}
        {label}{value}{detail}
        } + +function CategoryManager({ categories, loading, createPending, deletePending, onCreate, onDelete }: { categories: QuestionCategory[]; loading: boolean; createPending: boolean; deletePending: boolean; onCreate: (input: { name: string; parentId?: string | null }) => Promise; onDelete: (category: QuestionCategory) => void }) { + const [categoryName, setCategoryName] = useState('') + const [parentId, setParentId] = useState('') + const [subcategoryName, setSubcategoryName] = useState('') + const addCategory = async (event: FormEvent) => { event.preventDefault(); if (!categoryName.trim()) return; try { await onCreate({ name: categoryName.trim() }); setCategoryName('') } catch { /* mutation toast owns the error */ } } + const addSubcategory = async (event: FormEvent) => { event.preventDefault(); if (!parentId || !subcategoryName.trim()) return; try { await onCreate({ name: subcategoryName.trim(), parentId }); setSubcategoryName('') } catch { /* mutation toast owns the error */ } } + if (loading) return
        + return

        دسته‌بندی بانک سؤال

        ساختار را یک‌بار اینجا تعریف کنید؛ هنگام ساخت سؤال فقط دسته و زیردسته انتخاب می‌شوند.

        {categories.reduce((sum, item) => sum + 1 + item.children.length, 0).toLocaleString('fa-IR')} مورد
        دسته‌بندی اصلی

        مثلاً مهارت، ایمنی یا مدیریت

        زیردسته‌بندی

        مثلاً «اکسل» زیر مجموعه «مهارت»

        {categories.length ? categories.map(category =>
        {category.name}{category.questionCount.toLocaleString('fa-IR')} سؤال
        {category.children.length ? category.children.map(child => {child.name}{child.questionCount.toLocaleString('fa-IR')} سؤال) :

        هنوز زیردسته‌ای ندارد.

        }
        ) : }
        +} + +function QuestionList({ questions, loading, error, onEdit, onDelete }: { questions: Question[]; loading: boolean; error: Error | null; onEdit: (item: Question) => void; onDelete: (item: Question) => void }) { + const [page, setPage] = useState(0) + const pageSize = 5 + const pages = Math.max(1, Math.ceil(questions.length / pageSize)) + const safePage = Math.min(page, pages - 1) + const visible = questions.slice(safePage * pageSize, (safePage + 1) * pageSize) + if (loading) return
        + if (error) return + if (!questions.length) return + return
        {visible.map(item => )}
        سؤالدسته‌بندینوعسطحاستفادهعملکردعملیات
        {item.prompt}شناسه: {item.id}{item.categoryName ?? item.topic ?? 'بدون دسته‌بندی'}{item.subcategoryName && {item.subcategoryName}}{questionTypeLabel[item.type]}{item.difficulty ? difficultyLabel[item.difficulty] : '—'}{item.usageCount.toLocaleString('fa-IR')}بار{item.performance === null ? '—' : `${item.performance.toLocaleString('fa-IR')}٪`}
        نمایش {(safePage * pageSize + 1).toLocaleString('fa-IR')} تا {Math.min((safePage + 1) * pageSize, questions.length).toLocaleString('fa-IR')} از {questions.length.toLocaleString('fa-IR')} سؤال
        {(safePage + 1).toLocaleString('fa-IR')}
        +} + +function AssessmentWorkspace({ assessments, selected, bank, loading, onSelect, onAdd, onRemoveQuestion, onDelete, pending }: { assessments: Assessment[]; selected: Assessment | null; bank: Question[]; loading: boolean; onSelect: (id: string) => void; onAdd: (id: string) => void; onRemoveQuestion: (id: string) => void; onDelete: (item: Assessment) => void; pending: boolean }) { + const [bankId, setBankId] = useState('') + const [mappingQuestionId, setMappingQuestionId] = useState(null) + const mappingQuestion = selected?.questions.find(item => item.id === mappingQuestionId) ?? null + if (loading) return + return
        + + {selected ?
        +
        فعال

        {selected.title}

        این آزمون برای سنجش دانش و مهارت یادگیرندگان طراحی شده است.

        +
        {selected.questionCount.toLocaleString('fa-IR')} سؤالتعداد سؤال{Math.ceil((selected.settings.timeLimitSeconds ?? 0) / 60).toLocaleString('fa-IR')} دقیقهمدت زمانحدنصاب {selected.settings.passingScore.toLocaleString('fa-IR')}٪نمره قبولی{(selected.questionCount * 12).toLocaleString('fa-IR')}تعداد تلاش
        +

        بخش‌ها و تنظیمات آزمون

        بخش‌ها
        {Math.max(1, Math.ceil(selected.questionCount / 10)).toLocaleString('fa-IR')} بخش
        نمودار نمره
        نمره امروز: {selected.settings.passingScore.toLocaleString('fa-IR')}٪
        نمایش نتایج
        پس از پایان آزمون
        تعداد تلاش مجاز
        {selected.settings.attemptLimit?.toLocaleString('fa-IR') ?? 'نامحدود'} تلاش

        فعالیت‌های اخیر

        1. نسخه آزمون منتشر شدتوسط مدیر آموزش
        2. {selected.questionCount.toLocaleString('fa-IR')} سؤال بروزرسانی شدتغییرات ذخیره شدند
        +
        مدیریت سؤال‌ها و نگاشت مهارت
          {selected.questions.map((item, index) =>
        1. {(index + 1).toLocaleString('fa-IR')}
          {item.prompt}{questionTypeLabel[item.type]}
        2. )}
        {mappingQuestion &&
        {mappingQuestion.prompt}
        }
        +
        +
        : } +
        +} + +function ScenarioTypeChooser({ open, onClose, onChoose }: { open: boolean; onClose: () => void; onChoose: (mode: 'scenario' | 'branching_scenario') => void }) { + return +
        +

        نوع مناسب را براساس تجربه‌ای که می‌خواهید برای یادگیرنده بسازید انتخاب کنید.

        +
        + + +
        +
        +
        +} + +function ScenarioGallery({ questions, onEdit, onCreate }: { questions: Question[]; onEdit: (item: Question) => void; onCreate: (branching: boolean) => void }) { + return
        {questions.length ?
        نام سناریودسته‌بندیوضعیتاستفادهزمان تخمینیآخرین ویرایشعملیات
        {questions.map(item =>
        {item.prompt}{item.topic ?? 'بدون موضوع'}{item.topic ?? 'عمومی'}{item.usageCount ? 'فعال' : 'پیش‌نویس'}{item.usageCount.toLocaleString('fa-IR')}۱۰ دقیقهامروز
        )}
        : }
        +} + +function HelpCircleIcon() { return ؟ } + +export function QuestionModal({ open, question, pending, error, onClose, onSubmit }: { open: boolean; question: Question | null; pending: boolean; error: Error | null; onClose: () => void; onSubmit: (input: QuestionInput) => void }) { + const preferred = window.sessionStorage.getItem('assessment.preferredType') as QuestionType | null + const [type, setType] = useState(question?.type ?? preferred ?? 'single_choice') + const [prompt, setPrompt] = useState(question?.prompt ?? '') + const [topic, setTopic] = useState(question?.topic ?? '') + const [difficulty, setDifficulty] = useState(question?.difficulty ?? 'intermediate') + const [tags, setTags] = useState(question?.tags.join('، ') ?? '') + const [explanation, setExplanation] = useState(question?.explanation ?? '') + const [configuration, setConfiguration] = useState>(question?.configuration ?? defaultConfiguration(type)) + useEffect(() => { + if (!open) return + const preferredType = window.sessionStorage.getItem('assessment.preferredType') as QuestionType | null + const nextType = question?.type ?? preferredType ?? 'single_choice' + setType(nextType); setPrompt(question?.prompt ?? ''); setTopic(question?.topic ?? ''); setDifficulty(question?.difficulty ?? 'intermediate') + setTags(question?.tags.join('، ') ?? ''); setExplanation(question?.explanation ?? ''); setConfiguration(question?.configuration ?? defaultConfiguration(nextType)) + }, [open, question]) + const resetType = (next: QuestionType) => { setType(next); setConfiguration(defaultConfiguration(next)) } + return
        { event.preventDefault(); window.sessionStorage.removeItem('assessment.preferredType'); onSubmit({ type, prompt, topic: topic || null, difficulty, tags: tags.split(/[،,]/).map(item => item.trim()).filter(Boolean), explanation: explanation || null, configuration }) }}>
        setPrompt(event.target.value)} required />
        setTopic(event.target.value)} /> setTags(event.target.value)} />
        setExplanation(event.target.value)} />{error &&

        {error instanceof ApiError ? Object.values(error.errors).flat()[0] ?? error.message : error.message}

        }
        +} + +export function ConfigurationEditor({ type, value, onChange }: { type: QuestionType; value: Record; onChange: (value: Record) => void }) { + if (type === 'true_false') return
        پاسخ صحیح
        + if (type === 'single_choice' || type === 'multiple_choice') return + if (type === 'matching') return + if (type === 'sorting') return + if (type === 'drag_drop') return + if (type === 'hotspot') return + if (type === 'scenario') return + return +} + +function ChoiceEditor({ multiple, value, onChange }: { multiple: boolean; value: Record; onChange: (value: Record) => void }) { + const options = value.options as Array<{ id: string; text: string; correct: boolean; feedback?: string }> + const update = (index: number, patch: Partial<(typeof options)[number]>) => { const next = options.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : (!multiple && patch.correct ? { ...item, correct: false } : item)); onChange({ ...value, options: next }) } + return
        گزینه‌ها و پاسخ صحیح{options.map((item, index) =>
        update(index, { correct: event.target.checked })} /> update(index, { text: event.target.value })} />
        )}
        +} + +function PairEditor({ title, leftLabel, rightLabel, keyName, leftKey = 'left', rightKey = 'right', value, onChange }: { title: string; leftLabel: string; rightLabel: string; keyName: string; leftKey?: string; rightKey?: string; value: Record; onChange: (value: Record) => void }) { + const items = value[keyName] as Array> + return
        {title}{items.map((item, index) =>
        { const next = [...items]; next[index] = { ...item, [leftKey]: event.target.value }; onChange({ ...value, [keyName]: next }) }} /> { const next = [...items]; next[index] = { ...item, [rightKey]: event.target.value }; onChange({ ...value, [keyName]: next }) }} />
        )}
        +} + +function StringListEditor({ title, keyName, value, onChange }: { title: string; keyName: string; value: Record; onChange: (value: Record) => void }) { + const items = value[keyName] as string[] + return
        {title}{items.map((item, index) =>
        {(index + 1).toLocaleString('fa-IR')} { const next = [...items]; next[index] = event.target.value; onChange({ ...value, [keyName]: next }) }} />
        )}
        +} + +function HotspotEditor({ value, onChange }: { value: Record; onChange: (value: Record) => void }) { + const hotspots = value.hotspots as Array<{ x: number; y: number; radius: number; label: string; correct: boolean }> + return
        تصویر و نقاط داغ onChange({ ...value, imageAssetId: event.target.value })} />{hotspots.map((item, index) =>
        { const next = [...hotspots]; next[index] = { ...item, label: event.target.value }; onChange({ ...value, hotspots: next }) }} /> { const next = [...hotspots]; next[index] = { ...item, x: Number(event.target.value) }; onChange({ ...value, hotspots: next }) }} /> { const next = [...hotspots]; next[index] = { ...item, y: Number(event.target.value) }; onChange({ ...value, hotspots: next }) }} />
        )}
        +} + +function ScenarioChoiceEditor({ value, onChange }: { value: Record; onChange: (value: Record) => void }) { + const choices = value.choices as Array<{ text: string; score: number; feedback: string }> + return
        موقعیت و انتخاب‌ها