Initial-MicroLearning-platform-with-MySQL-support
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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).
|
||||
|
پس از عرض: | ارتفاع: | اندازه: 239 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 124 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 117 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 133 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 161 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 106 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 34 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 186 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 56 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 74 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 71 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 131 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 272 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 123 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 141 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 127 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 155 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 35 KiB |
|
پس از عرض: | ارتفاع: | اندازه: 118 KiB |
|
|
@ -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
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## 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).
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
|
||||
class ImportSqliteDatabase extends Command
|
||||
{
|
||||
protected $signature = 'db:import-sqlite
|
||||
{path=database/database.sqlite : Path to the legacy SQLite database}
|
||||
{--chunk=500 : Rows copied per batch}';
|
||||
|
||||
protected $description = 'Import all compatible data from the legacy SQLite database into an empty MySQL database';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (DB::getDefaultConnection() !== 'mysql') {
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use App\Modules\Identity\Domain\Enums\AccountStatus;
|
||||
use App\Modules\Identity\Domain\Enums\UserRole;
|
||||
use App\Modules\Organizations\Domain\Organization;
|
||||
use App\Modules\Teams\Domain\Team;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, HasUlids, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\AI\Application;
|
||||
|
||||
use App\Modules\AI\Domain\AiProvider;
|
||||
use App\Modules\AI\Infrastructure\AiEndpointPolicy;
|
||||
use App\Modules\AI\Infrastructure\LocalStructuringProvider;
|
||||
use App\Modules\AI\Infrastructure\OpenAiCompatibleProvider;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
final class AiProviderResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LocalStructuringProvider $local,
|
||||
private readonly OpenAiCompatibleProvider $openAi,
|
||||
private readonly AiEndpointPolicy $endpointPolicy,
|
||||
) {}
|
||||
|
||||
public function forOrganization(string $organizationId): AiProvider
|
||||
{
|
||||
$connection = DB::table('ai_provider_connections')
|
||||
->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<string, mixed> */
|
||||
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<string, mixed> */
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\AI\Application;
|
||||
|
||||
use RuntimeException;
|
||||
use Smalot\PdfParser\Parser;
|
||||
use ZipArchive;
|
||||
|
||||
final class DocumentExtractor
|
||||
{
|
||||
/** @return list<array{locator:string, heading:?string, content:string}> */
|
||||
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<array{locator:string, heading:?string, content:string}> */
|
||||
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<array{locator:string, heading:?string, content:string}> */
|
||||
private function docx(string $path): array
|
||||
{
|
||||
$xml = $this->zipEntry($path, 'word/document.xml');
|
||||
$paragraphs = preg_split('/<w:p\b[^>]*>/u', $xml) ?: [];
|
||||
$items = [];
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
preg_match_all('/<w:t\b[^>]*>(.*?)<\/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<array{locator:string, heading:?string, content:string}> */
|
||||
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>(.*?)<\/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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\AI\Application;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
final class ProcessAiJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(public readonly string $jobId) {}
|
||||
|
||||
public function handle(DocumentExtractor $extractor, AiProviderResolver $providers): void
|
||||
{
|
||||
$job = DB::table('ai_jobs')->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\AI\Domain;
|
||||
|
||||
interface AiProvider
|
||||
{
|
||||
public function id(): string;
|
||||
|
||||
public function external(): bool;
|
||||
|
||||
public function chat(string $prompt, ?string $systemPrompt = null): string;
|
||||
|
||||
/** @param list<array{id:string, locator:?string, heading:?string, content:string}> $fragments
|
||||
* @param array<string, mixed> $options
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function structure(array $fragments, array $options): array;
|
||||
|
||||
/** @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function assist(string $operation, string $content, array $context): array;
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\AI\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\AI\Application\AiProviderResolver;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class AiChatController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContext $tenant,
|
||||
private readonly RolePermissions $permissions,
|
||||
private readonly AiProviderResolver $providers,
|
||||
) {}
|
||||
|
||||
public function chat(Request $request): JsonResponse
|
||||
{
|
||||
abort_unless($this->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],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\AI\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\AI\Infrastructure\AiEndpointPolicy;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class AiConnectionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContext $tenant,
|
||||
private readonly RolePermissions $permissions,
|
||||
private readonly AiEndpointPolicy $endpointPolicy,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\AI\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\AI\Application\AiProviderResolver;
|
||||
use App\Modules\AI\Application\ProcessAiJob;
|
||||
use App\Modules\Assets\Domain\Asset;
|
||||
use App\Modules\Courses\Domain\Block;
|
||||
use App\Modules\Courses\Domain\Course;
|
||||
use App\Modules\Courses\Domain\CourseModule;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Courses\Domain\Lesson;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Subscriptions\Domain\Subscription;
|
||||
use App\Modules\System\Domain\DeploymentCapabilities;
|
||||
use App\Modules\System\Domain\DeploymentMode;
|
||||
use App\Modules\Taxonomy\Domain\ContentTaxonomyMapping;
|
||||
use App\Modules\Taxonomy\Domain\Enums\MappableType;
|
||||
use App\Modules\Taxonomy\Domain\Enums\MappingConfirmationStatus;
|
||||
use App\Modules\Taxonomy\Domain\Enums\MappingSource;
|
||||
use App\Modules\Taxonomy\Domain\Enums\MappingType;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class AiStudioController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TenantContext $tenant, private readonly RolePermissions $permissions, private readonly AiProviderResolver $providers, private readonly DeploymentCapabilities $deployment) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->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<string, mixed> */
|
||||
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<string, mixed> */
|
||||
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'] ?? []);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\AI\Infrastructure;
|
||||
|
||||
use Closure;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class AiEndpointPolicy
|
||||
{
|
||||
/** @var array<string, list<int>> */
|
||||
private const LOCAL_PROVIDER_PORTS = [
|
||||
'ollama' => [11434],
|
||||
'lm_studio' => [1234],
|
||||
'vllm' => [8000],
|
||||
'localai' => [8080],
|
||||
];
|
||||
|
||||
private readonly Closure $resolver;
|
||||
|
||||
/** @var list<string> */
|
||||
private readonly array $customProviderHosts;
|
||||
|
||||
/**
|
||||
* @param (Closure(string): list<string>)|null $resolver
|
||||
* @param list<string>|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<int, list<string>>} */
|
||||
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<string> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\AI\Infrastructure;
|
||||
|
||||
use App\Modules\AI\Domain\AiProvider;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class LocalStructuringProvider implements AiProvider
|
||||
{
|
||||
public function id(): string
|
||||
{
|
||||
return 'local-structuring-v1';
|
||||
}
|
||||
|
||||
public function external(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function chat(string $prompt, ?string $systemPrompt = null): string
|
||||
{
|
||||
return trim(preg_replace('/\s+/u', ' ', strip_tags($prompt)) ?? '');
|
||||
}
|
||||
|
||||
public function structure(array $fragments, array $options): array
|
||||
{
|
||||
$language = $options['language'] ?? 'fa';
|
||||
$topic = trim((string) ($options['topic'] ?? ''));
|
||||
$title = $topic !== '' ? $topic : trim((string) ($fragments[0]['heading'] ?? ''));
|
||||
if ($title === '') {
|
||||
$title = $language === 'en' ? 'Imported learning draft' : 'پیشنویس محتوای واردشده';
|
||||
}
|
||||
$lessonCount = max(1, min(12, (int) ($options['lessonCount'] ?? count($fragments) ?: 1)));
|
||||
$selected = array_slice($fragments, 0, $lessonCount);
|
||||
if ($selected === []) {
|
||||
$selected = [['id' => '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' => '<p>'.nl2br(e($content)).'</p>']]],
|
||||
];
|
||||
}, $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.'];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\AI\Infrastructure;
|
||||
|
||||
use App\Modules\AI\Domain\AiProvider;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
final class OpenAiCompatibleProvider implements AiProvider
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiEndpointPolicy $endpointPolicy,
|
||||
private readonly ?array $connection = null,
|
||||
) {}
|
||||
|
||||
public function id(): string
|
||||
{
|
||||
return isset($this->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<string, mixed> */
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Analytics\Application;
|
||||
|
||||
use App\Modules\Learner\Domain\LearningEvent;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AnalyticsProjectionService
|
||||
{
|
||||
private const PROCESSOR = 'daily-metrics';
|
||||
|
||||
private const VERSION = 1;
|
||||
|
||||
public function process(LearningEvent $event): bool
|
||||
{
|
||||
return DB::transaction(function () use ($event): bool {
|
||||
$claimed = DB::table('analytics_event_projections')->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()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Analytics\Application;
|
||||
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Learner\Domain\LearningEvent;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CourseAnalyticsDashboard
|
||||
{
|
||||
public const DEFINITIONS = [
|
||||
'learners' => 'تعداد یادگیرندگان یکتایی که تا پایان بازه، نسخه انتخابی به آنها تخصیص یافته است.',
|
||||
'starts' => 'تعداد یادگیرندگان تخصیصیافتهای که تا پایان بازه حداقل یک رویداد یادگیری معتبر در این نسخه ثبت کردهاند.',
|
||||
'completions' => 'تعداد یادگیرندگان یکتایی که طبق قوانین Completion نسخه، دوره را تا پایان بازه تکمیل کردهاند.',
|
||||
'averageProgress' => 'میانگین درصد پیشرفت جاری Assignmentهای یکتای نسخه. بهدلیل نبود Snapshot تاریخی، تغییر دورهای آن محاسبه نمیشود.',
|
||||
'averageScore' => 'میانگین نمره تلاشهای تکمیلشده ارزیابی در بازه، پس از تبدیل نمره صفر تا یک به درصد.',
|
||||
'dropOff' => 'درصد شروعکنندگان یکتایی که تا پایان بازه دوره را تکمیل نکردهاند.',
|
||||
'lessonViews' => 'تعداد رویدادهای مشاهده درس یا بلوکهای آن در بازه انتخابی.',
|
||||
'lessonTime' => 'مجموع زمان معتبر رویدادهای درس تقسیم بر تعداد یادگیرندگان فعال همان درس.',
|
||||
'lessonCompletion' => 'نسبت یادگیرندگان یکتای تکمیلکننده درس به یادگیرندگان یکتای فعال در آن درس.',
|
||||
'lessonScore' => 'میانگین امتیاز ثبتشده در رویدادهای ارزیابی همان درس؛ نبود امتیاز با «—» نمایش داده میشود.',
|
||||
];
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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<string, mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Analytics\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Analytics\Application\CourseAnalyticsDashboard;
|
||||
use App\Modules\Courses\Domain\Course;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
final class CourseAnalyticsController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $course, RolePermissions $permissions, CourseAnalyticsDashboard $dashboard): JsonResponse
|
||||
{
|
||||
$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', (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<string, mixed> */
|
||||
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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Analytics\Jobs;
|
||||
|
||||
use App\Modules\Analytics\Application\AnalyticsProjectionService;
|
||||
use App\Modules\Learner\Domain\LearningEvent;
|
||||
use App\Modules\Monitoring\Application\MonitoringEngine;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
final class ProcessLearningEvent implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public readonly string $eventId)
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assessments\Application;
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class QuestionSchema
|
||||
{
|
||||
/** @return list<string> */
|
||||
public function types(): array
|
||||
{
|
||||
return ['single_choice', 'multiple_choice', 'true_false', 'matching', 'sorting', 'drag_drop', 'hotspot', 'scenario', 'branching_scenario'];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $configuration @return array<string, mixed> */
|
||||
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<string, mixed> $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' => ['سناریو باید حداقل یک گره نتیجه داشته باشد.']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assessments\Domain;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Assessment extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = ['organization_id', 'course_version_id', 'lesson_id', 'title', 'settings'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['settings' => 'array'];
|
||||
}
|
||||
|
||||
public function questions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Question::class)->orderBy('position');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assessments\Domain;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class AssessmentAttempt extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'organization_id', 'learner_id', 'course_version_id', 'assessment_id',
|
||||
'attempt_number', 'status', 'score', 'started_at', 'completed_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'attempt_number' => 'integer', 'score' => 'decimal:4',
|
||||
'started_at' => 'immutable_datetime', 'completed_at' => 'immutable_datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function questionResults(): HasMany
|
||||
{
|
||||
return $this->hasMany(QuestionResult::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assessments\Domain;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Question extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'organization_id', 'course_version_id', 'assessment_id', 'type', 'prompt',
|
||||
'configuration', 'difficulty', 'position', 'is_bank_item', 'source_question_id',
|
||||
'schema_version', 'topic', 'question_category_id', 'tags', 'explanation',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['configuration' => '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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assessments\Domain;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class QuestionCategory extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = ['organization_id', 'parent_id', 'name'];
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assessments\Domain;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class QuestionResult extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'organization_id', 'assessment_attempt_id', 'question_id', 'raw_value',
|
||||
'normalized_value', 'is_correct', 'occurred_at', 'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'raw_value' => '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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assessments\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Assessments\Application\QuestionSchema;
|
||||
use App\Modules\Assessments\Domain\Assessment;
|
||||
use App\Modules\Assessments\Domain\Question;
|
||||
use App\Modules\Assessments\Domain\QuestionResult;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
|
||||
use App\Modules\Courses\Domain\Lesson;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class AssessmentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContext $tenant,
|
||||
private readonly RolePermissions $permissions,
|
||||
private readonly QuestionSchema $schema,
|
||||
) {}
|
||||
|
||||
public function contexts(Request $request): JsonResponse
|
||||
{
|
||||
$this->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<string, mixed> */
|
||||
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<string, array<int, string>> */
|
||||
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<string, mixed> */
|
||||
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<string, mixed> */
|
||||
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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assessments\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Assessments\Application\QuestionSchema;
|
||||
use App\Modules\Assessments\Domain\Question;
|
||||
use App\Modules\Assessments\Domain\QuestionCategory;
|
||||
use App\Modules\Assessments\Domain\QuestionResult;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class QuestionBankController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TenantContext $tenant, private readonly RolePermissions $permissions, private readonly QuestionSchema $schema) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->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<string, mixed> */
|
||||
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<string, mixed> */
|
||||
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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assessments\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Assessments\Domain\QuestionCategory;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class QuestionCategoryController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TenantContext $tenant, private readonly RolePermissions $permissions) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assets\Application;
|
||||
|
||||
use App\Modules\Courses\Domain\Block;
|
||||
use App\Modules\Courses\Domain\Course;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class AssetUsage
|
||||
{
|
||||
/** @return array<string, int> */
|
||||
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<string, mixed>|list<mixed> $value @return list<string> */
|
||||
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<string, mixed>|list<mixed> $value @return array<string, mixed>|list<mixed> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assets\Domain;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Asset extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'organization_id', 'uploaded_by', 'kind', 'original_name', 'disk', 'path',
|
||||
'mime_type', 'size', 'sha256', 'alt_text', 'metadata',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['size' => 'integer', 'metadata' => 'array'];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assets\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Assets\Application\AssetUsage;
|
||||
use App\Modules\Assets\Domain\Asset;
|
||||
use App\Modules\Assets\Http\Requests\StoreAssetRequest;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
final class AssetController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContext $tenant,
|
||||
private readonly RolePermissions $permissions,
|
||||
private readonly AssetUsage $usage,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assets\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreAssetRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'file' => ['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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assignments\Application;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use ZipArchive;
|
||||
|
||||
final class AssignmentAudienceImport
|
||||
{
|
||||
/** @return array{userIds: list<string>, matched: int, missingEmails: list<string>} */
|
||||
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<list<string>> */
|
||||
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<list<string>> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assignments\Application;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Assignments\Domain\Assignment;
|
||||
use App\Modules\Identity\Domain\Enums\AccountStatus;
|
||||
use App\Modules\Identity\Domain\Enums\UserRole;
|
||||
use App\Modules\Teams\Domain\Team;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class AssignmentResolver
|
||||
{
|
||||
public function sync(Assignment $assignment): int
|
||||
{
|
||||
if ($assignment->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<int, User> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assignments\Domain;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Assignment extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = ['organization_id', 'assignable_type', 'assignable_id', 'target_type', 'target_id', 'target_value', 'status', 'mandatory', 'starts_at', 'due_at', 'recurring_months', 'reminder_days', 'escalation_policy', 'source', 'assigned_by', 'cancelled_at'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['mandatory' => '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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,337 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Assignments\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Assignments\Application\AssignmentAudienceImport;
|
||||
use App\Modules\Assignments\Application\AssignmentResolver;
|
||||
use App\Modules\Assignments\Domain\Assignment;
|
||||
use App\Modules\Collaboration\Application\NotificationOrchestrator;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\LearningPaths\Domain\LearningPathVersion;
|
||||
use App\Modules\Teams\Domain\Team;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class AssignmentController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TenantContext $tenant, private readonly RolePermissions $permissions, private readonly AssignmentResolver $resolver, private readonly AssignmentAudienceImport $audienceImport, private readonly NotificationOrchestrator $notifications) {}
|
||||
|
||||
public function importAudience(Request $request): JsonResponse
|
||||
{
|
||||
$this->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<string, mixed> $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<string, mixed> */
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Capability\Application;
|
||||
|
||||
use App\Modules\Capability\Domain\CapabilityScore;
|
||||
use App\Modules\Capability\Domain\CapabilitySnapshot;
|
||||
use App\Modules\Capability\Domain\Enums\ConfidenceLevel;
|
||||
use App\Modules\Evidence\Domain\Enums\EvidenceType;
|
||||
use App\Modules\Evidence\Domain\EvidenceRecord;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CapabilityScoreEngine
|
||||
{
|
||||
public function recalculate(string $organizationId, string $learnerId, string $taxonomyNodeId): CapabilityScore
|
||||
{
|
||||
$evidence = EvidenceRecord::query()
|
||||
->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<int, EvidenceRecord> $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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Capability\Application;
|
||||
|
||||
use App\Modules\Evidence\Domain\Events\AssessmentEvidenceCreated;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
final class RecalculateCapability implements ShouldQueue
|
||||
{
|
||||
public string $queue = 'analytics';
|
||||
|
||||
public function __construct(private readonly CapabilityScoreEngine $engine) {}
|
||||
|
||||
public function handle(AssessmentEvidenceCreated $event): void
|
||||
{
|
||||
$this->engine->recalculate($event->organizationId, $event->learnerId, $event->taxonomyNodeId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Capability\Domain;
|
||||
|
||||
use App\Modules\Capability\Domain\Enums\ConfidenceLevel;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CapabilityScore extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'organization_id', 'learner_id', 'taxonomy_node_id', 'score', 'confidence',
|
||||
'confidence_level', 'evidence_count', 'last_evidence_at', 'trend',
|
||||
'scoring_model_version', 'explanation', 'calculated_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'score' => 'decimal:2', 'confidence' => 'decimal:4',
|
||||
'confidence_level' => ConfidenceLevel::class, 'evidence_count' => 'integer',
|
||||
'last_evidence_at' => 'immutable_datetime', 'explanation' => 'array',
|
||||
'calculated_at' => 'immutable_datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Capability\Domain;
|
||||
|
||||
use App\Modules\Capability\Domain\Enums\ConfidenceLevel;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CapabilitySnapshot extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'organization_id', 'learner_id', 'taxonomy_node_id', 'score', 'confidence',
|
||||
'confidence_level', 'evidence_count', 'scoring_model_version', 'explanation', 'captured_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'score' => 'decimal:2', 'confidence' => 'decimal:4',
|
||||
'confidence_level' => ConfidenceLevel::class, 'evidence_count' => 'integer',
|
||||
'explanation' => 'array', 'captured_at' => 'immutable_datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Capability\Domain\Enums;
|
||||
|
||||
enum ConfidenceLevel: string
|
||||
{
|
||||
case Insufficient = 'insufficient';
|
||||
case Low = 'low';
|
||||
case Medium = 'medium';
|
||||
case High = 'high';
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Certificates\Application;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use Dompdf\Dompdf;
|
||||
use Endroid\QrCode\QrCode;
|
||||
use Endroid\QrCode\Writer\SvgWriter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class IssueCertificate
|
||||
{
|
||||
public function issue(User $learner, CourseVersion $version, ?string $templateId = null, ?int $expiresInMonths = null): string
|
||||
{
|
||||
$existing = DB::table('certificates')->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<string, mixed> $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 '<!doctype html><html lang="fa" dir="rtl"><head><meta charset="utf-8"><style>@page{margin:10mm}body{font-family:DejaVu Sans,sans-serif;color:#172033;margin:0}.certificate{height:180mm;border:4px solid '.$color.';padding:18mm;box-sizing:border-box;text-align:center;position:relative}.mark{font-size:18px;color:'.$color.'}.title{font-size:34px;color:'.$color.';margin:18px}.name{font-size:30px;margin:18px}.course{font-size:23px}.meta{position:absolute;bottom:16mm;left:18mm;right:18mm;display:flex;justify-content:space-between;text-align:right;font-size:12px}.qr{width:82px;height:82px}</style></head><body><main class="certificate"><div class="mark">MicroLearn · '.$safe($snapshot['organizationName'] ?? '').'</div><h1 class="title">گواهینامه پایان دوره</h1><p>گواهی میشود</p><div class="name">'.$safe($snapshot['learnerName']).'</div><p>دوره</p><div class="course">'.$safe($snapshot['courseTitle']).'</div><div class="meta"><div>شماره: '.$safe($number).'<br>تاریخ صدور: '.$safe($issued).'<br>امضاکننده: '.$safe($snapshot['signatory'] ?? 'مدیریت آموزش').'</div><img class="qr" src="'.$qrData.'"></div></main></body></html>';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Certificates\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Certificates\Application\IssueCertificate;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class CertificateController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TenantContext $tenant, private readonly RolePermissions $permissions, private readonly IssueCertificate $issuer) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Collaboration\Application;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class CollaborationChangeFeed
|
||||
{
|
||||
/** @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Collaboration\Application;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
final class NotificationOrchestrator
|
||||
{
|
||||
/** @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Collaboration\Application;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Courses\Domain\Block;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class SoftLockService
|
||||
{
|
||||
public const LEASE_SECONDS = 45;
|
||||
|
||||
public function assertEditable(Block $block, User $actor): void
|
||||
{
|
||||
$lock = DB::table('block_soft_locks')->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.']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Collaboration\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Modules\Assets\Domain\Asset;
|
||||
use App\Modules\Collaboration\Application\CollaborationChangeFeed;
|
||||
use App\Modules\Collaboration\Application\SoftLockService;
|
||||
use App\Modules\Courses\Domain\Block;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
|
||||
use App\Modules\Courses\Domain\Lesson;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Identity\Domain\Enums\UserRole;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class CollaborationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContext $tenant,
|
||||
private readonly RolePermissions $permissions,
|
||||
private readonly CollaborationChangeFeed $changes,
|
||||
) {}
|
||||
|
||||
public function snapshot(Request $request, string $version): JsonResponse
|
||||
{
|
||||
$versionModel = $this->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<string> $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<string> $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<string> $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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Collaboration\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class NotificationController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TenantContext $tenant) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$items = DB::table('in_app_notifications')->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]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Application;
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
final class BlockContractValidator
|
||||
{
|
||||
/** @param array<string, mixed> $input @return array<string, mixed> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Application;
|
||||
|
||||
final readonly class BlockDefinition
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $defaultData
|
||||
* @param array<string, list<mixed>> $rules
|
||||
* @param list<string> $capabilities
|
||||
* @param list<string> $webBehavior
|
||||
* @param array<string, string> $exportCompatibility
|
||||
* @param array<int, callable(array<string, mixed>): array<string, mixed>> $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<string, mixed> */
|
||||
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<string, mixed> $data @return array{version: int, data: array<string, mixed>} */
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Application;
|
||||
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class BlockRegistry
|
||||
{
|
||||
/** @var array<string, BlockDefinition> */
|
||||
private array $definitions;
|
||||
|
||||
public function __construct(private readonly RichTextSanitizer $richText)
|
||||
{
|
||||
$this->definitions = collect($this->definitions())
|
||||
->keyBy(fn (BlockDefinition $definition) => $definition->type)
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
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<string, mixed> $data @return array<string, mixed> */
|
||||
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<string, mixed> $data @return array{version: int, data: array<string, mixed>} */
|
||||
public function migrate(string $type, int $schemaVersion, array $data): array
|
||||
{
|
||||
return $this->definition($type)->migrate($schemaVersion, $data);
|
||||
}
|
||||
|
||||
/** @return list<BlockDefinition> */
|
||||
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' => '<p>متن خود را اینجا بنویسید.</p>'],
|
||||
['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']]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Application;
|
||||
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
|
||||
final class CourseVersionComparison
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
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<string, mixed> */
|
||||
private function identity(CourseVersion $version): array
|
||||
{
|
||||
return ['id' => $version->getKey(), 'number' => $version->version_number, 'status' => $version->status->value];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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().' ارزیابی';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Application;
|
||||
|
||||
use App\Modules\Assessments\Domain\Assessment;
|
||||
use App\Modules\Assessments\Domain\Question;
|
||||
use App\Modules\Courses\Domain\Block;
|
||||
use App\Modules\Courses\Domain\Course;
|
||||
use App\Modules\Courses\Domain\CourseModule;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
|
||||
use App\Modules\Courses\Domain\Lesson;
|
||||
use App\Modules\Taxonomy\Domain\ContentTaxonomyMapping;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class CourseVersionEditor
|
||||
{
|
||||
public function assertDraft(CourseVersion $version): void
|
||||
{
|
||||
if ($version->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<string, string> $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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Application;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use DOMNode;
|
||||
use DOMXPath;
|
||||
|
||||
final class RichTextSanitizer
|
||||
{
|
||||
private const ALLOWED = ['p', 'h1', 'h2', 'h3', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li', 'a', 'br'];
|
||||
|
||||
private const DISCARD = ['script', 'style', 'iframe', 'object', 'embed', 'svg', 'math', 'template'];
|
||||
|
||||
public function sanitize(string $html): string
|
||||
{
|
||||
$document = new DOMDocument('1.0', 'UTF-8');
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$document->loadHTML('<!doctype html><html><head><meta charset="utf-8"></head><body>'.$html.'</body></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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Domain;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Block extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'organization_id', 'course_version_id', 'lesson_id', 'type', 'schema_version',
|
||||
'data', 'style', 'behavior', 'responsive', 'accessibility', 'position', 'revision',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'schema_version' => 'integer', 'position' => 'integer', 'revision' => 'integer', 'data' => 'array',
|
||||
'style' => 'array', 'behavior' => 'array', 'responsive' => 'array', 'accessibility' => 'array',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Domain;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Assets\Domain\Asset;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class Course extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = ['organization_id', 'title', 'slug', 'status', 'cover_asset_id', 'created_by'];
|
||||
|
||||
public function versions(): HasMany
|
||||
{
|
||||
return $this->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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Domain;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class CourseModule extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $table = 'course_modules';
|
||||
|
||||
protected $fillable = ['organization_id', 'course_version_id', 'title', 'position', 'settings', 'is_locked'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['position' => 'integer', 'settings' => 'array', 'is_locked' => 'boolean'];
|
||||
}
|
||||
|
||||
public function lessons(): HasMany
|
||||
{
|
||||
return $this->hasMany(Lesson::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Domain;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Modules\Assessments\Domain\Assessment;
|
||||
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
|
||||
use DomainException;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class CourseVersion extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'organization_id', 'course_id', 'source_version_id', 'created_by', 'published_by', 'version_number', 'status',
|
||||
'title', 'description', 'settings', 'completion_rules', 'taxonomy_snapshot', 'published_at',
|
||||
'review_submitted_at', 'scheduled_publish_at', 'scheduled_unpublish_at', 'unpublished_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'status' => 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Domain\Enums;
|
||||
|
||||
enum CourseVersionStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case InReview = 'in_review';
|
||||
case Published = 'published';
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Domain;
|
||||
|
||||
use App\Modules\Assessments\Domain\Assessment;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Lesson extends Model
|
||||
{
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = ['organization_id', 'course_version_id', 'course_module_id', 'title', 'position', 'presentation_mode', 'settings', 'is_locked'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['position' => '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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Assets\Application\AssetUsage;
|
||||
use App\Modules\Assets\Domain\Asset;
|
||||
use App\Modules\Collaboration\Application\SoftLockService;
|
||||
use App\Modules\Courses\Application\BlockContractValidator;
|
||||
use App\Modules\Courses\Application\BlockRegistry;
|
||||
use App\Modules\Courses\Domain\Block;
|
||||
use App\Modules\Courses\Domain\Course;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
|
||||
use App\Modules\Courses\Domain\Lesson;
|
||||
use App\Modules\Courses\Http\Requests\ReorderBlocksRequest;
|
||||
use App\Modules\Courses\Http\Requests\StoreBlockRequest;
|
||||
use App\Modules\Courses\Http\Requests\UpdateBlockRequest;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class CourseBuilderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContext $tenant,
|
||||
private readonly RolePermissions $permissions,
|
||||
private readonly BlockRegistry $registry,
|
||||
private readonly BlockContractValidator $contract,
|
||||
private readonly AssetUsage $assetUsage,
|
||||
private readonly SoftLockService $softLocks,
|
||||
) {}
|
||||
|
||||
public function registry(Request $request): JsonResponse
|
||||
{
|
||||
$this->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<string, mixed> $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<string, mixed> $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<string, mixed> */
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,290 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Assets\Domain\Asset;
|
||||
use App\Modules\Courses\Application\CourseVersionEditor;
|
||||
use App\Modules\Courses\Domain\Course;
|
||||
use App\Modules\Courses\Domain\CourseModule;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Courses\Domain\Enums\CourseVersionStatus;
|
||||
use App\Modules\Courses\Domain\Lesson;
|
||||
use App\Modules\Courses\Http\Requests\StoreCourseRequest;
|
||||
use App\Modules\Courses\Http\Requests\UpdateCourseVersionRequest;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class CourseController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TenantContext $tenant,
|
||||
private readonly RolePermissions $permissions,
|
||||
private readonly CourseVersionEditor $versions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Courses\Application\CourseVersionEditor;
|
||||
use App\Modules\Courses\Domain\Block;
|
||||
use App\Modules\Courses\Domain\CourseModule;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Courses\Domain\Lesson;
|
||||
use App\Modules\Courses\Http\Requests\ReorderStructureRequest;
|
||||
use App\Modules\Courses\Http\Requests\StoreLessonRequest;
|
||||
use App\Modules\Courses\Http\Requests\StoreModuleRequest;
|
||||
use App\Modules\Courses\Http\Requests\UpdateLessonRequest;
|
||||
use App\Modules\Courses\Http\Requests\UpdateModuleRequest;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class CourseStructureController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TenantContext $tenant, private readonly RolePermissions $permissions, private readonly CourseVersionEditor $versions) {}
|
||||
|
||||
public function storeModule(StoreModuleRequest $request, string $version): JsonResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Http;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Courses\Application\CourseVersionComparison;
|
||||
use App\Modules\Courses\Domain\Course;
|
||||
use App\Modules\Courses\Domain\CourseVersion;
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use App\Modules\Tenancy\Application\TenantContext;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class CourseVersionController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TenantContext $tenant, private readonly RolePermissions $permissions, private readonly CourseVersionComparison $comparison) {}
|
||||
|
||||
public function index(Request $request, string $course): JsonResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ReorderBlocksRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return ['blockIds' => ['required', 'array'], 'blockIds.*' => ['required', 'string', 'distinct']];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ReorderStructureRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return ['ids' => ['required', 'array'], 'ids.*' => ['required', 'string', 'distinct']];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreBlockRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Http\Requests;
|
||||
|
||||
use App\Modules\Identity\Application\RolePermissions;
|
||||
use App\Modules\Identity\Domain\Enums\Permission;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreCourseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->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'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Modules\Courses\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreLessonRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return ['title' => ['required', 'string', 'max:180'], 'presentationMode' => ['sometimes', Rule::in(['flow', 'slides'])]];
|
||||
}
|
||||
}
|
||||