{title}
+ {body &&{body}
} +-
+ {previewEntries.map(([label, value]) => (
+
- {label} +
- {String(value)} +
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..75d1b34 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: mbstring, pdo_sqlite, pdo_mysql + coverage: none + - run: composer install --no-interaction --prefer-dist --no-progress + - run: composer validate --strict --no-check-publish + - run: vendor/bin/pint --test + - run: php artisan test + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npx oxlint src + - run: npm run build + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + + dependency-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: composer audit --locked --working-dir=backend + - run: npm audit --prefix frontend --audit-level=high diff --git a/backend/.env.example b/backend/.env.example index c0660ea..4ba2363 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,11 +27,23 @@ DB_CONNECTION=sqlite # DB_USERNAME=root # DB_PASSWORD= +# Controlled SQLite -> MySQL migration (keep separate from the active connection) +MIGRATION_SQLITE_PATH= +MIGRATION_MYSQL_HOST=127.0.0.1 +MIGRATION_MYSQL_PORT=3306 +MIGRATION_MYSQL_DATABASE=pm_migration +MIGRATION_MYSQL_USERNAME= +MIGRATION_MYSQL_PASSWORD= + SESSION_DRIVER=database SESSION_LIFETIME=120 SESSION_ENCRYPT=false SESSION_PATH=/ SESSION_DOMAIN=null +SESSION_SECURE_COOKIE=false + +SANCTUM_TOKEN_EXPIRATION=480 +SANCTUM_STATEFUL_DOMAINS=localhost,localhost:5173,127.0.0.1,127.0.0.1:8000 BROADCAST_CONNECTION=log FILESYSTEM_DISK=local diff --git a/backend/app/Console/Commands/TransferSqliteToMysql.php b/backend/app/Console/Commands/TransferSqliteToMysql.php new file mode 100644 index 0000000..1eba331 --- /dev/null +++ b/backend/app/Console/Commands/TransferSqliteToMysql.php @@ -0,0 +1,268 @@ + ['role_id', 'user_id'], + 'permission_role' => ['permission_id', 'role_id'], + 'project_user' => ['project_id', 'user_id'], + 'sprint_task' => ['sprint_id', 'task_id'], + 'sprint_members' => ['sprint_id', 'user_id'], + 'meeting_user' => ['meeting_id', 'user_id'], + 'department_user' => ['department_id', 'user_id'], + ]; + + public function handle(): int + { + $sourcePath = config('database.connections.migration_sqlite.database'); + if (! is_string($sourcePath) || ! is_file($sourcePath)) { + $this->error('SQLite source file does not exist.'); + + return self::FAILURE; + } + + if (config('database.connections.migration_mysql.driver') !== 'mysql') { + $this->error('The migration target must use the mysql driver.'); + + return self::FAILURE; + } + + $source = DB::connection('migration_sqlite'); + $target = DB::connection('migration_mysql'); + + try { + $target->select('SELECT 1'); + } catch (Throwable $exception) { + $this->error('MySQL target connection failed: '.$exception->getCode()); + + return self::FAILURE; + } + + $tables = $this->transferableTables($source); + if ($tables === []) { + $this->warn('No transferable tables were found.'); + + return self::SUCCESS; + } + + $this->table(['Mode', 'Source', 'Target', 'Tables'], [[ + $this->option('execute') ? 'EXECUTE' : 'DRY RUN', + basename($sourcePath), + (string) config('database.connections.migration_mysql.database'), + count($tables), + ]]); + + if (! $this->option('execute')) { + foreach ($tables as $table) { + $this->line(sprintf('%s: %d rows', $table, $source->table($table)->count())); + } + + return self::SUCCESS; + } + + $chunkSize = max(10, min((int) $this->option('chunk'), 2000)); + Schema::connection('migration_mysql')->disableForeignKeyConstraints(); + + try { + if ($this->option('truncate')) { + foreach (array_reverse($tables) as $table) { + $target->table($table)->truncate(); + } + } + + foreach ($tables as $table) { + $this->transferTable($source, $target, $table, $chunkSize); + } + } finally { + Schema::connection('migration_mysql')->enableForeignKeyConstraints(); + } + + $failed = false; + foreach ($tables as $table) { + $sourceCount = $source->table($table)->count(); + $targetCount = $target->table($table)->count(); + $sourceDigest = $this->tableDigest($source, $table); + $targetDigest = $this->tableDigest($target, $table); + $match = $sourceCount === $targetCount && hash_equals($sourceDigest, $targetDigest); + $failed = $failed || ! $match; + $this->line(sprintf( + '%s source=%d target=%d content=%s %s', + $table, + $sourceCount, + $targetCount, + hash_equals($sourceDigest, $targetDigest) ? 'MATCH' : 'MISMATCH', + $match ? 'OK' : 'MISMATCH' + )); + } + + if ($failed) { + $this->error('Transfer completed with count mismatches.'); + + return self::FAILURE; + } + + $this->info('Transfer and row-count validation completed successfully.'); + + return self::SUCCESS; + } + + private function transferableTables(ConnectionInterface $source): array + { + $sourceTables = collect($source->select( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ))->pluck('name'); + $targetTables = collect(Schema::connection('migration_mysql')->getTableListing()) + ->map(fn (string $table) => str_contains($table, '.') ? str($table)->afterLast('.')->toString() : $table); + + $missing = $sourceTables->diff($targetTables)->reject( + fn (string $table) => in_array($table, self::EXCLUDED_TABLES, true) + ); + if ($missing->isNotEmpty()) { + throw new RuntimeException('Target schema is missing tables: '.$missing->implode(', ')); + } + + return $sourceTables + ->intersect($targetTables) + ->reject(fn (string $table) => in_array($table, self::EXCLUDED_TABLES, true)) + ->values() + ->all(); + } + + private function transferTable( + ConnectionInterface $source, + ConnectionInterface $target, + string $table, + int $chunkSize + ): void { + $columns = collect(Schema::connection('migration_mysql')->getColumnListing($table)); + $keys = $this->keyColumns($source, $table); + $orderColumn = $keys[0] ?? $columns->first(); + $processed = 0; + + $source->table($table) + ->orderBy($orderColumn) + ->chunk($chunkSize, function ($rows) use ($target, $table, $columns, $keys, &$processed) { + $payload = collect($rows)->map( + fn ($row) => collect((array) $row)->only($columns)->all() + )->all(); + + if ($payload === []) { + return; + } + + if ($keys !== []) { + $target->table($table)->upsert($payload, $keys, $columns->diff($keys)->all()); + } else { + foreach ($payload as $row) { + $target->table($table)->updateOrInsert($row, $row); + } + } + $processed += count($payload); + }); + + $this->info(sprintf('%s: %d rows transferred', $table, $processed)); + } + + private function keyColumns(ConnectionInterface $source, string $table): array + { + if (isset(self::NATURAL_KEYS[$table])) { + return self::NATURAL_KEYS[$table]; + } + + return collect($source->select("PRAGMA table_info('".str_replace("'", "''", $table)."')")) + ->filter(fn ($column) => (int) $column->pk > 0) + ->sortBy(fn ($column) => (int) $column->pk) + ->pluck('name') + ->all(); + } + + private function tableDigest(ConnectionInterface $connection, string $table): string + { + $columns = Schema::connection($connection->getName())->getColumnListing($table); + sort($columns); + $order = $this->keyColumns(DB::connection('migration_sqlite'), $table); + if ($order === []) { + $order = $columns; + } + + $query = $connection->table($table)->select($columns); + foreach ($order as $column) { + $query->orderBy($column); + } + + $context = hash_init('sha256'); + foreach ($query->cursor() as $row) { + $normalized = []; + foreach ($columns as $column) { + $normalized[$column] = $this->normalizeValue($row->{$column}); + } + hash_update($context, json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)."\n"); + } + + return hash_final($context); + } + + private function normalizeValue(mixed $value): mixed + { + if ($value === null) { + return null; + } + + $string = (string) $value; + if (preg_match('/^\d{4}-\d{2}-\d{2} 00:00:00$/', $string)) { + return substr($string, 0, 10); + } + + $decoded = json_decode($string, true); + if (json_last_error() === JSON_ERROR_NONE && (str_starts_with($string, '{') || str_starts_with($string, '['))) { + return $this->normalizeJson($decoded); + } + + if (preg_match('/^-?\d+\.\d+$/', $string)) { + return rtrim(rtrim($string, '0'), '.'); + } + + return $string; + } + + private function normalizeJson(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + if (! array_is_list($value)) { + ksort($value); + } + + return array_map(fn ($item) => $this->normalizeJson($item), $value); + } +} diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php index 62d5b22..8fbeec6 100644 --- a/backend/app/Http/Controllers/Api/AuthController.php +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -3,18 +3,21 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; -use App\Http\Requests\LoginRequest; use App\Http\Requests\ChangePasswordRequest; +use App\Http\Requests\LoginRequest; use App\Http\Requests\UpdateProfileRequest; use App\Http\Resources\UserResource; use App\Models\User; use App\Services\ActivityLogService; +use Illuminate\Auth\Events\PasswordReset; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class AuthController extends Controller { @@ -28,7 +31,7 @@ class AuthController extends Controller $user = $this->findUserForLogin($identifier); - if (!$user || !Hash::check($credentials['password'], $user->password)) { + if (! $user || ! Hash::check($credentials['password'], $user->password)) { $this->logAuthEvent(null, 'failed_login', 'تلاش ناموفق ورود', $request, $identifier); return response()->json([ @@ -187,7 +190,7 @@ class AuthController extends Controller try { $user = $request->user(); - if (!Hash::check($request->current_password, $user->password)) { + if (! Hash::check($request->current_password, $user->password)) { return response()->json([ 'success' => false, 'message' => 'رمز عبور فعلی اشتباه است', @@ -195,10 +198,12 @@ class AuthController extends Controller } $user->update(['password' => Hash::make($request->new_password)]); + $user->tokens()->delete(); return response()->json([ 'success' => true, 'message' => 'رمز عبور با موفقیت تغییر یافت', + 'reauthentication_required' => true, ]); } catch (\Exception $e) { return response()->json([ @@ -210,9 +215,45 @@ class AuthController extends Controller public function forgotPassword(Request $request): JsonResponse { + $data = $request->validate(['email' => 'required|email']); + Password::sendResetLink(['email' => strtolower($data['email'])]); + return response()->json([ 'success' => true, - 'message' => 'لینک بازیابی رمز عبور به ایمیل شما ارسال شد', + 'message' => 'اگر حسابی با این ایمیل وجود داشته باشد، لینک بازیابی ارسال میشود.', + ]); + } + + public function resetPassword(Request $request): JsonResponse + { + $data = $request->validate([ + 'token' => 'required|string', + 'email' => 'required|email', + 'password' => 'required|string|min:12|confirmed', + ]); + + $status = Password::reset( + $data, + function (User $user, string $password) { + $user->forceFill([ + 'password' => Hash::make($password), + 'remember_token' => Str::random(60), + ])->save(); + $user->tokens()->delete(); + event(new PasswordReset($user)); + } + ); + + if ($status !== Password::PASSWORD_RESET) { + return response()->json([ + 'success' => false, + 'message' => 'توکن بازیابی نامعتبر یا منقضی شده است.', + ], 422); + } + + return response()->json([ + 'success' => true, + 'message' => 'رمز عبور تغییر کرد. دوباره وارد شوید.', ]); } diff --git a/backend/app/Http/Controllers/Api/BacklogController.php b/backend/app/Http/Controllers/Api/BacklogController.php index 340578e..a976b8f 100644 --- a/backend/app/Http/Controllers/Api/BacklogController.php +++ b/backend/app/Http/Controllers/Api/BacklogController.php @@ -8,15 +8,24 @@ use App\Http\Resources\BacklogItemResource; use App\Http\Resources\TaskResource; use App\Models\BacklogItem; use App\Models\Task; +use App\Services\ResourceAccessService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class BacklogController extends Controller { + public function __construct(private readonly ResourceAccessService $resourceAccess) {} + public function index(Request $request): JsonResponse { try { $query = BacklogItem::with(['project', 'assignedSprint', 'creator']); + if (! $this->resourceAccess->canSeeAll($request->user())) { + $query->where(function ($scope) use ($request) { + $scope->where('created_by', $request->user()->id) + ->orWhereIn('project_id', $this->resourceAccess->projects($request->user())->select('projects.id')); + }); + } if ($request->filled('project_id')) { $query->where('project_id', $request->project_id); @@ -31,8 +40,10 @@ class BacklogController extends Controller $query->where('priority', $request->priority); } - $perPage = $request->input('per_page', 15); - $sortBy = $request->input('sort_by', 'created_at'); + $perPage = min(max((int) $request->input('per_page', 15), 1), 100); + $sortBy = in_array($request->input('sort_by'), ['id', 'title', 'priority', 'status', 'created_at', 'updated_at'], true) + ? $request->input('sort_by') + : 'created_at'; $sortDir = $request->input('sort_dir', 'desc'); $items = $query->orderBy($sortBy, $sortDir)->paginate($perPage); @@ -77,6 +88,13 @@ class BacklogController extends Controller { try { $data = $request->validated(); + if (! empty($data['project_id'])) { + abort_unless( + $this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(), + 403, + 'شما به پروژه انتخابشده دسترسی ندارید.' + ); + } $data['created_by'] = $request->user()->id; $item = BacklogItem::create($data); $item->load(['project', 'assignedSprint', 'creator']); @@ -109,7 +127,7 @@ class BacklogController extends Controller $backlogItem->update($request->only([ 'title', 'description', 'type', 'priority', - 'estimated_effort', 'status', 'assigned_sprint_id' + 'estimated_effort', 'status', 'assigned_sprint_id', ])); $backlogItem->load(['project', 'assignedSprint', 'creator']); diff --git a/backend/app/Http/Controllers/Api/CalendarController.php b/backend/app/Http/Controllers/Api/CalendarController.php new file mode 100644 index 0000000..ce70271 --- /dev/null +++ b/backend/app/Http/Controllers/Api/CalendarController.php @@ -0,0 +1,38 @@ +validate([ + 'from' => 'nullable|date', + 'to' => 'nullable|date|after_or_equal:from', + 'types' => 'nullable|array', + 'types.*' => 'string|in:meeting,task,sprint,project,action_item', + ]); + + $from = Carbon::parse($data['from'] ?? now()->startOfMonth())->startOfDay(); + $to = Carbon::parse($data['to'] ?? now()->endOfMonth())->endOfDay(); + + if ($from->diffInDays($to) > 93) { + return response()->json(['success' => false, 'message' => 'بازه تقویم نمیتواند بیشتر از ۹۳ روز باشد.'], 422); + } + + return response()->json([ + 'success' => true, + 'data' => $this->calendarService->events($request->user(), $from, $to, $data['types'] ?? []), + 'meta' => ['from' => $from->toDateString(), 'to' => $to->toDateString()], + 'message' => 'رویدادهای تقویم', + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/ChecklistController.php b/backend/app/Http/Controllers/Api/ChecklistController.php index c335f7b..3a0c60c 100644 --- a/backend/app/Http/Controllers/Api/ChecklistController.php +++ b/backend/app/Http/Controllers/Api/ChecklistController.php @@ -98,7 +98,7 @@ class ChecklistController extends Controller public function toggleComplete(Checklist $checklist): JsonResponse { try { - $checklist->update(['is_completed' => !$checklist->is_completed]); + $checklist->update(['is_completed' => ! $checklist->is_completed]); return response()->json([ 'success' => true, diff --git a/backend/app/Http/Controllers/Api/CommentController.php b/backend/app/Http/Controllers/Api/CommentController.php index 70bf3f1..2be5e8f 100644 --- a/backend/app/Http/Controllers/Api/CommentController.php +++ b/backend/app/Http/Controllers/Api/CommentController.php @@ -6,9 +6,13 @@ use App\Http\Controllers\Controller; use App\Http\Requests\StoreCommentRequest; use App\Http\Resources\CommentResource; use App\Models\Comment; +use App\Models\Meeting; +use App\Models\Project; +use App\Models\Task; use App\Models\User; use App\Services\ActivityLogService; use App\Services\NotificationService; +use App\Services\ResourceAccessService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -17,12 +21,13 @@ class CommentController extends Controller public function __construct( protected ActivityLogService $activityLogService, protected NotificationService $notificationService, + protected ResourceAccessService $resourceAccess, ) {} public function index(Request $request): JsonResponse { try { - $query = Comment::with(['user', 'files']); + $query = $this->resourceAccess->comments($request->user())->with(['user', 'files']); if ($request->filled('commentable_type')) { $query->where('commentable_type', $request->commentable_type); @@ -57,11 +62,18 @@ class CommentController extends Controller { try { $data = $request->validated(); + $allowed = match ($data['commentable_type']) { + Project::class => $this->resourceAccess->projects($request->user())->whereKey($data['commentable_id'])->exists(), + Task::class => $this->resourceAccess->tasks($request->user())->whereKey($data['commentable_id'])->exists(), + Meeting::class => $this->resourceAccess->meetings($request->user())->whereKey($data['commentable_id'])->exists(), + default => false, + }; + abort_unless($allowed, 403, 'شما به محل انتخابشده برای نظر دسترسی ندارید.'); $data['user_id'] = $request->user()->id; $comment = Comment::create($data); $comment->load('user'); - if (!empty($data['mentioned_user_id'])) { + if (! empty($data['mentioned_user_id'])) { $mentionedUser = User::find($data['mentioned_user_id']); if ($mentionedUser && $mentionedUser->id !== $request->user()->id) { $this->notificationService->create( @@ -80,7 +92,7 @@ class CommentController extends Controller } preg_match_all('/@(\w+)/', $comment->body, $matches); - if (!empty($matches[1])) { + if (! empty($matches[1])) { $mentionedUsers = User::whereIn('name', $matches[1])->get(); foreach ($mentionedUsers as $mentionedUser) { $mentionedUser->notifications()->create([ diff --git a/backend/app/Http/Controllers/Api/DashboardController.php b/backend/app/Http/Controllers/Api/DashboardController.php index ecc780b..1de8654 100644 --- a/backend/app/Http/Controllers/Api/DashboardController.php +++ b/backend/app/Http/Controllers/Api/DashboardController.php @@ -46,4 +46,22 @@ class DashboardController extends Controller ], 500); } } + + public function monitoring(Request $request): JsonResponse + { + try { + return response()->json([ + 'success' => true, + 'data' => $this->dashboardService->getMonitoring($request->user()), + 'message' => 'مرکز مانیتورینگ', + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => 'خطا در دریافت اطلاعات مانیتورینگ', + ], 500); + } + } } diff --git a/backend/app/Http/Controllers/Api/DepartmentController.php b/backend/app/Http/Controllers/Api/DepartmentController.php index 022474a..fa1093e 100644 --- a/backend/app/Http/Controllers/Api/DepartmentController.php +++ b/backend/app/Http/Controllers/Api/DepartmentController.php @@ -22,14 +22,14 @@ class DepartmentController extends Controller $departmentIds = $departments->pluck('id'); $tree = $departments - ->filter(fn($department) => $department->parent_id === null || !$departmentIds->contains($department->parent_id)) + ->filter(fn ($department) => $department->parent_id === null || ! $departmentIds->contains($department->parent_id)) ->values() - ->map(fn($d) => $this->formatNode($d, $departments)); + ->map(fn ($d) => $this->formatNode($d, $departments)); return response()->json([ 'success' => true, 'data' => $tree, - 'flat' => $departments->values()->map(fn($d) => [ + 'flat' => $departments->values()->map(fn ($d) => [ 'id' => $d->id, 'name' => $d->name, 'type' => $d->type ?? 'department', @@ -50,6 +50,7 @@ class DepartmentController extends Controller private function formatNode($dept, $all): array { $children = $all->where('parent_id', $dept->id)->values(); + return [ 'id' => $dept->id, 'name' => $dept->name, @@ -62,7 +63,7 @@ class DepartmentController extends Controller 'is_active' => $dept->is_active, 'sort_order' => $dept->sort_order, 'users_count' => $dept->members()->count() ?: $dept->users()->count(), - 'children' => $children->map(fn($c) => $this->formatNode($c, $all)), + 'children' => $children->map(fn ($c) => $this->formatNode($c, $all)), ]; } @@ -82,7 +83,7 @@ class DepartmentController extends Controller $data = $request->only(['name', 'description', 'type', 'parent_id', 'manager_id', 'is_active', 'sort_order']); $data['type'] = $data['type'] ?? 'department'; $data['is_active'] = $data['is_active'] ?? true; - if (!empty($data['manager_id'])) { + if (! empty($data['manager_id'])) { $data['manager_changed_at'] = now(); } $dept = Department::create($data); @@ -117,6 +118,7 @@ class DepartmentController extends Controller { try { $department->load(['manager', 'parent', 'children']); + return response()->json([ 'success' => true, 'data' => $department, @@ -152,11 +154,11 @@ class DepartmentController extends Controller $changed = collect($payload) ->except('manager_changed_at') - ->filter(fn($value, $key) => array_key_exists($key, $old) && $old[$key] != $department->{$key}) - ->map(fn($value, $key) => ['old' => $old[$key], 'new' => $department->{$key}]) + ->filter(fn ($value, $key) => array_key_exists($key, $old) && $old[$key] != $department->{$key}) + ->map(fn ($value, $key) => ['old' => $old[$key], 'new' => $department->{$key}]) ->all(); - if (!empty($changed)) { + if (! empty($changed)) { $this->activityLogService->log( $request->user()?->id, array_key_exists('manager_id', $changed) ? 'change_department_manager' : 'update_department', diff --git a/backend/app/Http/Controllers/Api/FileController.php b/backend/app/Http/Controllers/Api/FileController.php index ab584de..509e15d 100644 --- a/backend/app/Http/Controllers/Api/FileController.php +++ b/backend/app/Http/Controllers/Api/FileController.php @@ -4,8 +4,12 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Resources\FileResource; +use App\Models\Comment; use App\Models\File; +use App\Models\Meeting; use App\Models\Project; +use App\Models\Task; +use App\Services\ResourceAccessService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -14,10 +18,12 @@ use Illuminate\Validation\ValidationException; class FileController extends Controller { + public function __construct(private readonly ResourceAccessService $resourceAccess) {} + public function index(Request $request): JsonResponse { try { - $query = File::with('user'); + $query = $this->resourceAccess->files($request->user())->with('user'); if ($request->filled('fileable_type')) { $query->where('fileable_type', $request->fileable_type); @@ -66,17 +72,26 @@ class FileController extends Controller $fileableId = $request->project_id; } - if (!$fileableType || !$fileableId) { + if (! $fileableType || ! $fileableId) { return response()->json([ 'success' => false, 'message' => 'انتخاب محل آپلود فایل الزامی است', ], 422); } + $allowed = match ($fileableType) { + Project::class => $this->resourceAccess->projects($request->user())->whereKey($fileableId)->exists(), + Task::class => $this->resourceAccess->tasks($request->user())->whereKey($fileableId)->exists(), + Meeting::class => $this->resourceAccess->meetings($request->user())->whereKey($fileableId)->exists(), + Comment::class => $this->resourceAccess->comments($request->user())->whereKey($fileableId)->exists(), + default => false, + }; + abort_unless($allowed, 403, 'شما به محل انتخابشده برای فایل دسترسی ندارید.'); + $uploadedFile = $request->file('file'); $originalName = $uploadedFile->getClientOriginalName(); $extension = strtolower($uploadedFile->getClientOriginalExtension()); - $name = Str::uuid()->toString() . ($extension ? ".{$extension}" : ''); + $name = Str::uuid()->toString().($extension ? ".{$extension}" : ''); $path = $uploadedFile->storeAs('files', $name, 'local'); $file = File::create([ @@ -111,7 +126,7 @@ class FileController extends Controller try { $disk = Storage::disk('local')->exists($file->path) ? 'local' : 'public'; - if (!Storage::disk($disk)->exists($file->path)) { + if (! Storage::disk($disk)->exists($file->path)) { return response()->json([ 'success' => false, 'message' => 'فایل یافت نشد', diff --git a/backend/app/Http/Controllers/Api/MeetingController.php b/backend/app/Http/Controllers/Api/MeetingController.php index daa29e3..cc40b31 100644 --- a/backend/app/Http/Controllers/Api/MeetingController.php +++ b/backend/app/Http/Controllers/Api/MeetingController.php @@ -4,25 +4,34 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\StoreMeetingRequest; -use App\Http\Resources\MeetingResource; use App\Http\Resources\MeetingActionItemResource; +use App\Http\Resources\MeetingResource; use App\Http\Resources\TaskResource; +use App\Models\ActionItem; +use App\Models\Blocker; +use App\Models\Decision; use App\Models\Meeting; use App\Models\MeetingActionItem; +use App\Models\MeetingType; use App\Models\Task; use App\Models\User; use App\Services\NotificationService; +use App\Services\ResourceAccessService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class MeetingController extends Controller { - public function __construct(protected NotificationService $notificationService) {} + public function __construct( + protected NotificationService $notificationService, + protected ResourceAccessService $resourceAccess, + ) {} public function index(Request $request): JsonResponse { try { - $query = Meeting::with(['project', 'creator', 'participants']); + $query = $this->resourceAccess->meetings($request->user()) + ->with(['project', 'sprint', 'type', 'creator', 'participants']); if ($request->filled('project_id')) { $query->where('project_id', $request->project_id); @@ -36,6 +45,12 @@ class MeetingController extends Controller if ($request->filled('meeting_type')) { $query->where('meeting_type', $request->meeting_type); } + if ($request->filled('sprint_id')) { + $query->where('sprint_id', $request->sprint_id); + } + if ($request->filled('status')) { + $query->where('status', $request->status); + } $perPage = $request->input('per_page', 15); $sortBy = $request->input('sort_by', 'date'); @@ -64,7 +79,11 @@ class MeetingController extends Controller public function show(Meeting $meeting): JsonResponse { try { - $meeting->load(['project', 'creator', 'participants', 'actionItems.assignee']); + $meeting->load([ + 'project', 'sprint', 'type', 'creator', 'owner', 'facilitator', + 'participants', 'actionItems.assignee', 'structuredDecisions', + 'structuredActionItems', 'blockers', 'files', + ]); return response()->json([ 'success' => true, @@ -83,9 +102,17 @@ class MeetingController extends Controller { try { $data = $request->validated(); + if (! empty($data['project_id'])) { + abort_unless( + $this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(), + 403, + 'شما به پروژه انتخابشده دسترسی ندارید.' + ); + } $data['created_by'] = $request->user()->id; + $data['owner_id'] ??= $request->user()->id; $meeting = Meeting::create($data); - $meeting->load(['project', 'creator']); + $meeting->load(['project', 'sprint', 'type', 'creator', 'owner']); return response()->json([ 'success' => true, @@ -103,24 +130,43 @@ class MeetingController extends Controller public function update(Request $request, Meeting $meeting): JsonResponse { try { - $request->validate([ + $data = $request->validate([ 'title' => 'sometimes|required|string|max:255', 'date' => 'sometimes|required|date', 'start_time' => 'nullable', 'end_time' => 'nullable', 'meeting_type' => 'sometimes|required|string|max:50', + 'meeting_type_id' => 'nullable|exists:meeting_types,id', + 'project_id' => 'nullable|exists:projects,id', + 'sprint_id' => 'nullable|exists:sprints,id', + 'status' => 'sometimes|string|in:draft,scheduled,in_progress,completed,cancelled,postponed', 'location' => 'nullable|string', - 'meeting_link' => 'nullable|string', + 'meeting_link' => 'nullable|url', + 'objective' => 'nullable|string', 'agenda' => 'nullable|string', 'notes' => 'nullable|string', + 'summary' => 'nullable|string', 'decisions' => 'nullable|string', + 'owner_id' => 'nullable|exists:users,id', + 'facilitator_id' => 'nullable|exists:users,id', + 'reminder_minutes' => 'nullable|integer|min:0|max:10080', + 'recurrence_rule' => 'nullable|array', ]); + if (isset($data['project_id'])) { + abort_unless( + $this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(), + 403, + 'شما به پروژه انتخابشده دسترسی ندارید.' + ); + } - $meeting->update($request->only([ - 'title', 'date', 'start_time', 'end_time', 'meeting_type', - 'location', 'meeting_link', 'agenda', 'notes', 'decisions' - ])); - $meeting->load(['project', 'creator']); + $meeting->update(collect($data)->only([ + 'title', 'project_id', 'sprint_id', 'date', 'start_time', 'end_time', + 'meeting_type', 'meeting_type_id', 'status', 'location', 'meeting_link', + 'objective', 'agenda', 'notes', 'summary', 'decisions', 'owner_id', + 'facilitator_id', 'reminder_minutes', 'recurrence_rule', + ])->all()); + $meeting->load(['project', 'sprint', 'type', 'creator', 'owner', 'facilitator']); return response()->json([ 'success' => true, @@ -277,6 +323,11 @@ class MeetingController extends Controller $request->validate([ 'project_id' => 'required|exists:projects,id', ]); + abort_unless( + $this->resourceAccess->projects($request->user())->whereKey($request->project_id)->exists(), + 403, + 'شما به پروژه انتخابشده دسترسی ندارید.' + ); $task = Task::create([ 'title' => $actionItem->title, @@ -303,4 +354,131 @@ class MeetingController extends Controller ], 500); } } + + public function workspace(Meeting $meeting): JsonResponse + { + $meeting->load([ + 'project', 'sprint', 'type', 'creator', 'owner', 'facilitator', + 'participants', 'actionItems.assignee', 'structuredDecisions', + 'structuredActionItems', 'blockers', 'files', + ]); + + return response()->json([ + 'success' => true, + 'data' => new MeetingResource($meeting), + 'message' => 'فضای کاری جلسه', + ]); + } + + public function start(Request $request, Meeting $meeting): JsonResponse + { + if (! in_array($meeting->status, ['draft', 'scheduled', 'postponed'], true)) { + return response()->json(['success' => false, 'message' => 'این جلسه قابل شروع نیست'], 422); + } + + $meeting->update(['status' => 'in_progress', 'started_at' => now()]); + + return response()->json([ + 'success' => true, + 'data' => new MeetingResource($meeting->fresh()), + 'message' => 'جلسه شروع شد', + ]); + } + + public function complete(Request $request, Meeting $meeting): JsonResponse + { + $data = $request->validate([ + 'summary' => 'nullable|string', + 'force' => 'nullable|boolean', + ]); + if ($meeting->status !== 'in_progress') { + return response()->json(['success' => false, 'message' => 'فقط جلسه در حال برگزاری قابل تکمیل است'], 422); + } + if (blank($data['summary'] ?? $meeting->summary) && ! $request->boolean('force')) { + return response()->json(['success' => false, 'message' => 'برای تکمیل جلسه، خلاصه را ثبت کنید یا تأیید ویژه بدهید.'], 422); + } + + $meeting->update([ + 'status' => 'completed', + 'completed_at' => now(), + 'summary' => $data['summary'] ?? $meeting->summary, + ]); + + return response()->json([ + 'success' => true, + 'data' => new MeetingResource($meeting->fresh()), + 'message' => 'جلسه تکمیل شد', + ]); + } + + public function addDecision(Request $request, Meeting $meeting): JsonResponse + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'status' => 'nullable|in:proposed,pending,approved,rejected,reversed,superseded', + 'owner_id' => 'nullable|exists:users,id', + 'rationale' => 'nullable|string', + 'impact' => 'nullable|string', + ]); + + $decision = Decision::create($data + [ + 'meeting_id' => $meeting->id, + 'sprint_id' => $meeting->sprint_id, + 'project_id' => $meeting->project_id, + 'created_by' => $request->user()->id, + ]); + + return response()->json(['success' => true, 'data' => $decision, 'message' => 'تصمیم ثبت شد'], 201); + } + + public function addStructuredActionItem(Request $request, Meeting $meeting): JsonResponse + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'owner_id' => 'required|exists:users,id', + 'due_date' => 'required|date', + 'priority' => 'nullable|in:low,medium,high,urgent', + ]); + + $item = ActionItem::create($data + [ + 'meeting_id' => $meeting->id, + 'sprint_id' => $meeting->sprint_id, + 'project_id' => $meeting->project_id, + 'created_by' => $request->user()->id, + ]); + + return response()->json(['success' => true, 'data' => $item, 'message' => 'اقدام ثبت شد'], 201); + } + + public function addBlocker(Request $request, Meeting $meeting): JsonResponse + { + $data = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'severity' => 'nullable|in:low,medium,high,critical', + 'owner_id' => 'nullable|exists:users,id', + 'task_id' => 'nullable|exists:tasks,id', + 'due_date' => 'nullable|date', + ]); + + $blocker = Blocker::create($data + [ + 'meeting_id' => $meeting->id, + 'sprint_id' => $meeting->sprint_id, + 'project_id' => $meeting->project_id, + 'raised_by' => $request->user()->id, + ]); + + return response()->json(['success' => true, 'data' => $blocker, 'message' => 'مانع ثبت شد'], 201); + } + + public function types(): JsonResponse + { + return response()->json([ + 'success' => true, + 'data' => MeetingType::where('is_active', true)->orderBy('name')->get(), + 'message' => 'انواع جلسه', + ]); + } } diff --git a/backend/app/Http/Controllers/Api/NotificationController.php b/backend/app/Http/Controllers/Api/NotificationController.php index fbb25a4..5d5a260 100644 --- a/backend/app/Http/Controllers/Api/NotificationController.php +++ b/backend/app/Http/Controllers/Api/NotificationController.php @@ -15,6 +15,12 @@ class NotificationController extends Controller { try { $query = $request->user()->notifications(); + $archived = $request->boolean('archived'); + $query->when($archived, fn ($q) => $q->whereNotNull('archived_at')) + ->when(! $archived, fn ($q) => $q->whereNull('archived_at')); + if ($request->boolean('unread')) { + $query->whereNull('read_at'); + } $perPage = $request->input('per_page', 15); $notifications = $query->orderBy('created_at', 'desc')->paginate($perPage); @@ -27,7 +33,8 @@ class NotificationController extends Controller 'last_page' => $notifications->lastPage(), 'per_page' => $notifications->perPage(), 'total' => $notifications->total(), - 'unread_count' => $request->user()->notifications()->whereNull('read_at')->count(), + 'unread_count' => $request->user()->notifications()->whereNull('archived_at')->whereNull('read_at')->count(), + 'archived_count' => $request->user()->notifications()->whereNotNull('archived_at')->count(), ], 'message' => 'لیست اعلانها', ]); @@ -107,4 +114,55 @@ class NotificationController extends Controller ], 500); } } + + public function archive(Request $request, Notification $notification): JsonResponse + { + if ($notification->user_id !== $request->user()->id) { + return response()->json(['success' => false, 'message' => 'شما اجازه آرشیو این اعلان را ندارید'], 403); + } + + $notification->update([ + 'archived_at' => now(), + 'read_at' => $notification->read_at ?? now(), + 'is_read' => true, + ]); + + return response()->json([ + 'success' => true, + 'data' => new NotificationResource($notification->fresh()), + 'message' => 'اعلان آرشیو شد', + ]); + } + + public function restore(Request $request, Notification $notification): JsonResponse + { + if ($notification->user_id !== $request->user()->id) { + return response()->json(['success' => false, 'message' => 'شما اجازه بازیابی این اعلان را ندارید'], 403); + } + + $notification->update(['archived_at' => null]); + + return response()->json([ + 'success' => true, + 'data' => new NotificationResource($notification->fresh()), + 'message' => 'اعلان از آرشیو خارج شد', + ]); + } + + public function preview(Request $request, Notification $notification): JsonResponse + { + if ($notification->user_id !== $request->user()->id) { + return response()->json(['success' => false, 'message' => 'شما اجازه مشاهده این اعلان را ندارید'], 403); + } + + if (! $notification->read_at) { + $notification->update(['read_at' => now(), 'is_read' => true]); + } + + return response()->json([ + 'success' => true, + 'data' => new NotificationResource($notification->fresh()), + 'message' => 'جزئیات اعلان', + ]); + } } diff --git a/backend/app/Http/Controllers/Api/ProjectController.php b/backend/app/Http/Controllers/Api/ProjectController.php index 6bb1ad3..5f7073e 100644 --- a/backend/app/Http/Controllers/Api/ProjectController.php +++ b/backend/app/Http/Controllers/Api/ProjectController.php @@ -11,6 +11,7 @@ use App\Models\User; use App\Services\ActivityLogService; use App\Services\NotificationService; use App\Services\ProjectProgressService; +use App\Services\ResourceAccessService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -20,12 +21,14 @@ class ProjectController extends Controller protected ActivityLogService $activityLogService, protected NotificationService $notificationService, protected ProjectProgressService $projectProgressService, + protected ResourceAccessService $resourceAccess, ) {} public function index(Request $request): JsonResponse { try { - $query = Project::with(['projectManager', 'creator', 'department']); + $query = $this->resourceAccess->projects($request->user()) + ->with(['projectManager', 'creator', 'department']); if ($request->filled('status')) { $query->where('status', $request->status); @@ -225,7 +228,7 @@ class ProjectController extends Controller $project->members()->syncWithoutDetaching([ $request->user_id => [ - 'role_in_project' => $request->role_in_project ?? 'member', + 'role_in_project' => $request->role_in_project ?? 'member', ], ]); diff --git a/backend/app/Http/Controllers/Api/PwaController.php b/backend/app/Http/Controllers/Api/PwaController.php index 0c09d48..a7dc67a 100644 --- a/backend/app/Http/Controllers/Api/PwaController.php +++ b/backend/app/Http/Controllers/Api/PwaController.php @@ -5,15 +5,16 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Resources\CommentResource; use App\Http\Resources\FileResource; -use App\Models\Notification; use App\Models\ActivityLog; use App\Models\Comment; use App\Models\File; +use App\Models\Notification; use App\Models\Project; use App\Models\Sprint; use App\Models\Task; use App\Models\User; use App\Services\ProjectProgressService; +use App\Services\ResourceAccessService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -22,13 +23,16 @@ use Illuminate\Validation\Rule; class PwaController extends Controller { - public function __construct(protected ProjectProgressService $projectProgressService) {} + public function __construct( + protected ProjectProgressService $projectProgressService, + protected ResourceAccessService $resourceAccess, + ) {} public function profile(Request $request): JsonResponse { $user = $request->user()->loadMissing(['roles.permissions', 'dept', 'departments']); $permissions = $user->roles - ->flatMap(fn($role) => $role->permissions->pluck('name')) + ->flatMap(fn ($role) => $role->permissions->pluck('name')) ->unique() ->values(); @@ -44,14 +48,14 @@ class PwaController extends Controller 'department' => $user->dept?->name ?: $user->department, 'status' => $user->status ?: 'active', 'avatar' => $user->avatar, - 'avatar_url' => $user->avatar ? asset('storage/' . $user->avatar) : null, - 'roles' => $user->roles->map(fn($role) => [ + 'avatar_url' => $user->avatar ? asset('storage/'.$user->avatar) : null, + 'roles' => $user->roles->map(fn ($role) => [ 'id' => $role->id, 'name' => $role->name, 'display_name' => $role->display_name, ])->values(), 'primary_role' => $user->roles->first()?->display_name ?: $user->roles->first()?->name, - 'departments' => $user->departments->map(fn($department) => [ + 'departments' => $user->departments->map(fn ($department) => [ 'id' => $department->id, 'name' => $department->name, 'role_in_team' => $department->pivot?->role_in_team, @@ -77,8 +81,8 @@ class PwaController extends Controller ->with(['projectManager:id,name,job_title,avatar', 'members:id,name,job_title,avatar']) ->withCount([ 'tasks', - 'tasks as done_tasks_count' => fn($taskQuery) => $taskQuery->where('status', 'done'), - 'tasks as overdue_tasks_count' => fn($taskQuery) => $taskQuery + 'tasks as done_tasks_count' => fn ($taskQuery) => $taskQuery->where('status', 'done'), + 'tasks as overdue_tasks_count' => fn ($taskQuery) => $taskQuery ->whereDate('due_date', '<', now()->toDateString()) ->whereNotIn('status', ['done', 'canceled', 'cancelled']), ]); @@ -88,12 +92,12 @@ class PwaController extends Controller 'active' => $query->where('status', 'active'), 'waiting' => $query->whereIn('status', ['waiting', 'pending']), 'completed' => $query->whereIn('status', ['completed', 'done']), - 'delayed' => $query->whereHas('tasks', fn($taskQuery) => $taskQuery + 'delayed' => $query->whereHas('tasks', fn ($taskQuery) => $taskQuery ->whereDate('due_date', '<', now()->toDateString()) ->whereNotIn('status', ['done', 'canceled', 'cancelled'])), 'mine' => $query->where(function ($scopeQuery) use ($user) { $scopeQuery->where('project_manager_id', $user->id) - ->orWhereHas('members', fn($memberQuery) => $memberQuery->where('users.id', $user->id)); + ->orWhereHas('members', fn ($memberQuery) => $memberQuery->where('users.id', $user->id)); }), default => null, }; @@ -114,7 +118,7 @@ class PwaController extends Controller return response()->json([ 'success' => true, - 'data' => collect($projects->items())->map(fn(Project $project) => $this->projectCard($project))->values(), + 'data' => collect($projects->items())->map(fn (Project $project) => $this->projectCard($project))->values(), 'meta' => [ 'current_page' => $projects->currentPage(), 'last_page' => $projects->lastPage(), @@ -133,7 +137,7 @@ class PwaController extends Controller $project->load([ 'projectManager:id,name,job_title,avatar', 'members:id,name,job_title,avatar', - 'tasks' => fn($taskQuery) => $taskQuery + 'tasks' => fn ($taskQuery) => $taskQuery ->with(['assignee:id,name,job_title']) ->orderByRaw("case priority when 'urgent' then 0 when 'high' then 1 when 'medium' then 2 else 3 end") ->orderByRaw('case when due_date is null then 1 else 0 end') @@ -141,11 +145,11 @@ class PwaController extends Controller ->limit(8), ])->loadCount([ 'tasks', - 'tasks as done_tasks_count' => fn($taskQuery) => $taskQuery->where('status', 'done'), - 'tasks as overdue_tasks_count' => fn($taskQuery) => $taskQuery + 'tasks as done_tasks_count' => fn ($taskQuery) => $taskQuery->where('status', 'done'), + 'tasks as overdue_tasks_count' => fn ($taskQuery) => $taskQuery ->whereDate('due_date', '<', $today) ->whereNotIn('status', ['done', 'canceled', 'cancelled']), - 'tasks as today_tasks_count' => fn($taskQuery) => $taskQuery->whereDate('due_date', $today), + 'tasks as today_tasks_count' => fn ($taskQuery) => $taskQuery->whereDate('due_date', $today), 'members', ]); @@ -154,7 +158,7 @@ class PwaController extends Controller ->latest() ->limit(6) ->get(['id', 'action', 'description', 'created_at']) - ->map(fn(ActivityLog $activity) => [ + ->map(fn (ActivityLog $activity) => [ 'id' => $activity->id, 'action' => $activity->action, 'description' => $activity->description, @@ -167,12 +171,12 @@ class PwaController extends Controller 'description' => $project->description, 'members_count' => $project->members_count, 'today_tasks_count' => $project->today_tasks_count, - 'tasks' => $project->tasks->map(fn(Task $task) => $this->taskCard($task))->values(), - 'members' => $project->members->map(fn(User $member) => [ + 'tasks' => $project->tasks->map(fn (Task $task) => $this->taskCard($task))->values(), + 'members' => $project->members->map(fn (User $member) => [ 'id' => $member->id, 'name' => $member->name, 'job_title' => $member->job_title, - 'avatar_url' => $member->avatar ? asset('storage/' . $member->avatar) : null, + 'avatar_url' => $member->avatar ? asset('storage/'.$member->avatar) : null, 'role_in_project' => $member->pivot?->role_in_project, ])->values(), 'activities' => $activities, @@ -199,8 +203,8 @@ class PwaController extends Controller $project->load(['projectManager:id,name,job_title,avatar', 'members:id,name,job_title,avatar']) ->loadCount([ 'tasks', - 'tasks as done_tasks_count' => fn($taskQuery) => $taskQuery->where('status', 'done'), - 'tasks as overdue_tasks_count' => fn($taskQuery) => $taskQuery + 'tasks as done_tasks_count' => fn ($taskQuery) => $taskQuery->where('status', 'done'), + 'tasks as overdue_tasks_count' => fn ($taskQuery) => $taskQuery ->whereDate('due_date', '<', now()->toDateString()) ->whereNotIn('status', ['done', 'canceled', 'cancelled']), ]); @@ -239,7 +243,7 @@ class PwaController extends Controller return response()->json([ 'success' => true, - 'data' => collect($notifications->items())->map(fn(Notification $notification) => $this->notificationPayload($notification))->values(), + 'data' => collect($notifications->items())->map(fn (Notification $notification) => $this->notificationPayload($notification))->values(), 'meta' => [ 'current_page' => $notifications->currentPage(), 'last_page' => $notifications->lastPage(), @@ -360,7 +364,7 @@ class PwaController extends Controller $search = trim($request->input('search')); $query->where(function ($innerQuery) use ($search) { $innerQuery->where('title', 'like', "%{$search}%") - ->orWhereHas('project', fn($projectQuery) => $projectQuery->where('title', 'like', "%{$search}%")); + ->orWhereHas('project', fn ($projectQuery) => $projectQuery->where('title', 'like', "%{$search}%")); }); } @@ -377,7 +381,7 @@ class PwaController extends Controller return response()->json([ 'success' => true, - 'data' => collect($tasks->items())->map(fn(Task $task) => $this->taskCard($task))->values(), + 'data' => collect($tasks->items())->map(fn (Task $task) => $this->taskCard($task))->values(), 'meta' => [ 'current_page' => $tasks->currentPage(), 'last_page' => $tasks->lastPage(), @@ -467,7 +471,7 @@ class PwaController extends Controller $uploadedFile = $request->file('file'); $extension = strtolower($uploadedFile->getClientOriginalExtension()); - $name = Str::uuid()->toString() . ($extension ? ".{$extension}" : ''); + $name = Str::uuid()->toString().($extension ? ".{$extension}" : ''); $path = $uploadedFile->storeAs('files', $name, 'local'); $file = File::create([ @@ -506,7 +510,7 @@ class PwaController extends Controller { $user = $request->user()->loadMissing(['roles.permissions']); - if (!$user->hasPermission('tasks.create')) { + if (! $user->hasPermission('tasks.create')) { return response()->json([ 'success' => false, 'message' => 'شما مجوز ایجاد تسک را ندارید.', @@ -515,11 +519,11 @@ class PwaController extends Controller $projectQuery = Project::query() ->with(['members:id,name,job_title,avatar', 'projectManager:id,name,job_title,avatar']) - ->when(!$user->hasPermission('reports.view'), function ($query) use ($user) { + ->when(! $user->hasPermission('reports.view'), function ($query) use ($user) { $query->where(function ($scopeQuery) use ($user) { $scopeQuery->where('project_manager_id', $user->id) ->orWhere('created_by', $user->id) - ->orWhereHas('members', fn($memberQuery) => $memberQuery->where('users.id', $user->id)); + ->orWhereHas('members', fn ($memberQuery) => $memberQuery->where('users.id', $user->id)); }); }) ->where(function ($query) { @@ -529,15 +533,15 @@ class PwaController extends Controller $projects = $projectQuery->limit(100)->get(); $projectUserIds = $projects - ->flatMap(fn(Project $project) => $project->members->pluck('id')->push($project->project_manager_id)) + ->flatMap(fn (Project $project) => $project->members->pluck('id')->push($project->project_manager_id)) ->filter() ->unique() ->values(); $users = User::query() - ->when(!$user->hasPermission('reports.view'), function ($query) use ($user, $projectUserIds) { + ->when(! $user->hasPermission('reports.view'), function ($query) use ($user, $projectUserIds) { $query->where('id', $user->id) - ->when($projectUserIds->isNotEmpty(), fn($innerQuery) => $innerQuery->orWhereIn('id', $projectUserIds)); + ->when($projectUserIds->isNotEmpty(), fn ($innerQuery) => $innerQuery->orWhereIn('id', $projectUserIds)); }) ->where(function ($query) { $query->where('status', 'active')->orWhereNull('status'); @@ -549,7 +553,7 @@ class PwaController extends Controller return response()->json([ 'success' => true, 'data' => [ - 'projects' => $projects->map(fn(Project $project) => [ + 'projects' => $projects->map(fn (Project $project) => [ 'id' => $project->id, 'title' => $project->title, 'members' => $project->members @@ -557,13 +561,13 @@ class PwaController extends Controller ->filter() ->unique('id') ->values() - ->map(fn(User $member) => [ + ->map(fn (User $member) => [ 'id' => $member->id, 'name' => $member->name, 'job_title' => $member->job_title, ]), ])->values(), - 'users' => $users->map(fn(User $optionUser) => [ + 'users' => $users->map(fn (User $optionUser) => [ 'id' => $optionUser->id, 'name' => $optionUser->name, 'job_title' => $optionUser->job_title, @@ -585,13 +589,13 @@ class PwaController extends Controller $query = Sprint::query()->with('project:id,title,status'); - if (!$user->hasPermission('reports.view')) { + if (! $user->hasPermission('reports.view')) { $visibleProjectIds = $this->visibleProjectsQuery($user)->pluck('id'); $query->where(function ($scopeQuery) use ($user, $visibleProjectIds) { $scopeQuery - ->when($visibleProjectIds->isNotEmpty(), fn($innerQuery) => $innerQuery->whereIn('project_id', $visibleProjectIds)) - ->orWhereHas('members', fn($memberQuery) => $memberQuery->where('users.id', $user->id)) + ->when($visibleProjectIds->isNotEmpty(), fn ($innerQuery) => $innerQuery->whereIn('project_id', $visibleProjectIds)) + ->orWhereHas('members', fn ($memberQuery) => $memberQuery->where('users.id', $user->id)) ->orWhereHas('tasks', function ($taskQuery) use ($user) { $taskQuery ->where('assignee_id', $user->id) @@ -611,7 +615,7 @@ class PwaController extends Controller return response()->json([ 'success' => true, - 'data' => $sprints->map(fn(Sprint $sprint) => $this->pwaSprintPayload($sprint, $user))->values(), + 'data' => $sprints->map(fn (Sprint $sprint) => $this->pwaSprintPayload($sprint, $user))->values(), 'message' => 'اسپرینتهای موبایل', ]); } @@ -644,7 +648,7 @@ class PwaController extends Controller ->orderBy('due_date') ->limit(5) ->get() - ->map(fn(Task $task) => $this->taskPreview($task)) + ->map(fn (Task $task) => $this->taskPreview($task)) ->values(); $notifications = Notification::query() @@ -652,7 +656,7 @@ class PwaController extends Controller ->latest() ->limit(5) ->get() - ->map(fn(Notification $notification) => [ + ->map(fn (Notification $notification) => [ 'id' => $notification->id, 'type' => $notification->type, 'title' => $notification->title, @@ -666,7 +670,7 @@ class PwaController extends Controller ->values(); $permissions = $user->roles - ->flatMap(fn($role) => $role->permissions->pluck('name')) + ->flatMap(fn ($role) => $role->permissions->pluck('name')) ->unique() ->values(); @@ -677,8 +681,8 @@ class PwaController extends Controller $manageableProjectIds = $this->manageableProjectIds($user); $teamTaskQuery = Task::query() - ->when($manageableProjectIds->isNotEmpty(), fn($query) => $query->whereIn('project_id', $manageableProjectIds)) - ->when($manageableProjectIds->isEmpty() && !$user->hasPermission('reports.view'), fn($query) => $query->whereRaw('1 = 0')); + ->when($manageableProjectIds->isNotEmpty(), fn ($query) => $query->whereIn('project_id', $manageableProjectIds)) + ->when($manageableProjectIds->isEmpty() && ! $user->hasPermission('reports.view'), fn ($query) => $query->whereRaw('1 = 0')); $managerSummary = [ 'overdue_team_tasks' => (clone $teamTaskQuery) @@ -689,7 +693,7 @@ class PwaController extends Controller 'active_sprint' => Sprint::query() ->with('project:id,title') ->where('status', 'active') - ->when($manageableProjectIds->isNotEmpty(), fn($query) => $query->whereIn('project_id', $manageableProjectIds)) + ->when($manageableProjectIds->isNotEmpty(), fn ($query) => $query->whereIn('project_id', $manageableProjectIds)) ->latest() ->first()?->only(['id', 'title', 'project_id', 'start_date', 'end_date']), 'delayed_members' => (clone $teamTaskQuery) @@ -756,11 +760,11 @@ class PwaController extends Controller $permissionSet = $permissions->flip(); $summary = collect($items) - ->filter(fn($item) => collect($item['permissions'])->contains(fn($permission) => $permissionSet->has($permission))) - ->map(fn($item) => ['label' => $item['label']]) + ->filter(fn ($item) => collect($item['permissions'])->contains(fn ($permission) => $permissionSet->has($permission))) + ->map(fn ($item) => ['label' => $item['label']]) ->values(); - $isAdmin = $user->roles->contains(fn($role) => in_array($role->name, ['admin', 'super_admin', 'system_admin'], true)) + $isAdmin = $user->roles->contains(fn ($role) => in_array($role->name, ['admin', 'super_admin', 'system_admin'], true)) || $permissionSet->has('roles.edit') || $permissionSet->has('settings.edit'); @@ -784,18 +788,7 @@ class PwaController extends Controller private function visibleTasksQuery(User $user) { - if ($user->hasPermission('reports.view')) { - return Task::query(); - } - - $manageableProjectIds = $this->manageableProjectIds($user); - - return Task::query()->where(function ($query) use ($user, $manageableProjectIds) { - $query->where('assignee_id', $user->id) - ->orWhere('reporter_id', $user->id) - ->orWhere('created_by', $user->id) - ->when($manageableProjectIds->isNotEmpty(), fn($innerQuery) => $innerQuery->orWhereIn('project_id', $manageableProjectIds)); - }); + return $this->resourceAccess->tasks($user); } private function abortUnlessTaskVisible(Request $request, Task $task): void @@ -807,15 +800,7 @@ class PwaController extends Controller private function visibleProjectsQuery(User $user) { - if ($user->hasPermission('reports.view')) { - return Project::query(); - } - - return Project::query()->where(function ($query) use ($user) { - $query->where('project_manager_id', $user->id) - ->orWhere('created_by', $user->id) - ->orWhereHas('members', fn($memberQuery) => $memberQuery->where('users.id', $user->id)); - }); + return $this->resourceAccess->projects($user); } private function abortUnlessProjectVisible(Request $request, Project $project): void @@ -844,10 +829,10 @@ class PwaController extends Controller 'overdue' => $project->overdue_tasks_count ?? 0, ], 'members_preview' => $project->relationLoaded('members') - ? $project->members->take(4)->map(fn(User $member) => [ + ? $project->members->take(4)->map(fn (User $member) => [ 'id' => $member->id, 'name' => $member->name, - 'avatar_url' => $member->avatar ? asset('storage/' . $member->avatar) : null, + 'avatar_url' => $member->avatar ? asset('storage/'.$member->avatar) : null, ])->values() : [], ]; @@ -928,7 +913,7 @@ class PwaController extends Controller { $today = now()->toDateString(); $tasks = $this->visibleTasksQuery($user) - ->whereHas('sprints', fn($sprintQuery) => $sprintQuery->where('sprints.id', $sprint->id)) + ->whereHas('sprints', fn ($sprintQuery) => $sprintQuery->where('sprints.id', $sprint->id)) ->with(['project:id,title,status', 'assignee:id,name,job_title']) ->withCount(['comments', 'files']) ->orderByRaw("case priority when 'urgent' then 0 when 'high' then 1 when 'medium' then 2 else 3 end") @@ -940,7 +925,7 @@ class PwaController extends Controller $done = $tasks->where('status', 'done')->count(); $inProgress = $tasks->whereIn('status', ['in_progress', 'review'])->count(); $overdue = $tasks - ->filter(fn(Task $task) => $task->due_date && $task->due_date->format('Y-m-d') < $today && !in_array($task->status, ['done', 'canceled', 'cancelled'], true)) + ->filter(fn (Task $task) => $task->due_date && $task->due_date->format('Y-m-d') < $today && ! in_array($task->status, ['done', 'canceled', 'cancelled'], true)) ->count(); $progress = $total > 0 ? (int) round(($done / $total) * 100) : 0; $remainingDays = $sprint->end_date ? now()->startOfDay()->diffInDays($sprint->end_date->copy()->startOfDay(), false) : null; @@ -964,7 +949,7 @@ class PwaController extends Controller 'in_progress' => $inProgress, 'overdue' => $overdue, ], - 'tasks' => $tasks->map(fn(Task $task) => $this->taskCard($task))->values(), + 'tasks' => $tasks->map(fn (Task $task) => $this->taskCard($task))->values(), 'capabilities' => [ 'can_create_task' => $user->hasPermission('tasks.create'), 'can_update_task_status' => $user->hasPermission('tasks.edit'), diff --git a/backend/app/Http/Controllers/Api/ReportController.php b/backend/app/Http/Controllers/Api/ReportController.php index ff9370c..60405c8 100644 --- a/backend/app/Http/Controllers/Api/ReportController.php +++ b/backend/app/Http/Controllers/Api/ReportController.php @@ -33,7 +33,7 @@ class ReportController extends Controller { try { $data = $this->reportService->delayedTasks($request->only([ - 'date_from', 'date_to', 'project_id', 'user_id', 'status' + 'date_from', 'date_to', 'project_id', 'user_id', 'status', ])); return response()->json([ @@ -53,7 +53,7 @@ class ReportController extends Controller { try { $data = $this->reportService->teamPerformance($request->only([ - 'date_from', 'date_to', 'user_id' + 'date_from', 'date_to', 'user_id', ])); return response()->json([ @@ -91,7 +91,7 @@ class ReportController extends Controller { try { $data = $this->reportService->sprintProgress($request->only([ - 'project_id', 'status' + 'project_id', 'status', ])); return response()->json([ @@ -111,7 +111,7 @@ class ReportController extends Controller { try { $data = $this->reportService->timeEstimate($request->only([ - 'date_from', 'date_to', 'project_id', 'user_id', 'status' + 'date_from', 'date_to', 'project_id', 'user_id', 'status', ])); return response()->json([ @@ -131,7 +131,7 @@ class ReportController extends Controller { try { $data = $this->reportService->recentActivities($request->only([ - 'date_from', 'date_to', 'project_id', 'user_id', 'action' + 'date_from', 'date_to', 'project_id', 'user_id', 'action', ])); return response()->json([ @@ -151,7 +151,7 @@ class ReportController extends Controller { try { $data = $this->reportService->riskyProjects($request->only([ - 'status', 'project_id' + 'status', 'project_id', ])); return response()->json([ diff --git a/backend/app/Http/Controllers/Api/RoleController.php b/backend/app/Http/Controllers/Api/RoleController.php index 59669f8..409e37e 100644 --- a/backend/app/Http/Controllers/Api/RoleController.php +++ b/backend/app/Http/Controllers/Api/RoleController.php @@ -5,8 +5,8 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Requests\StoreRoleRequest; use App\Http\Requests\UpdateRoleRequest; -use App\Http\Resources\RoleResource; use App\Http\Resources\PermissionResource; +use App\Http\Resources\RoleResource; use App\Models\Role; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -95,7 +95,7 @@ class RoleController extends Controller $permissionIds = $data['permissions'] ?? null; unset($data['permissions']); - if (array_key_exists('guard_name', $data) && !$data['guard_name']) { + if (array_key_exists('guard_name', $data) && ! $data['guard_name']) { $data['guard_name'] = 'web'; } diff --git a/backend/app/Http/Controllers/Api/SearchController.php b/backend/app/Http/Controllers/Api/SearchController.php index ea4171d..9f6e18e 100644 --- a/backend/app/Http/Controllers/Api/SearchController.php +++ b/backend/app/Http/Controllers/Api/SearchController.php @@ -3,21 +3,21 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Http\Resources\BacklogItemResource; +use App\Http\Resources\MeetingResource; use App\Http\Resources\ProjectResource; use App\Http\Resources\TaskResource; use App\Http\Resources\UserResource; -use App\Http\Resources\MeetingResource; -use App\Http\Resources\BacklogItemResource; -use App\Models\Project; -use App\Models\Task; -use App\Models\User; -use App\Models\Meeting; use App\Models\BacklogItem; +use App\Models\User; +use App\Services\ResourceAccessService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; class SearchController extends Controller { + public function __construct(private readonly ResourceAccessService $resourceAccess) {} + public function search(Request $request): JsonResponse { try { @@ -25,11 +25,25 @@ class SearchController extends Controller $keyword = $request->q; $limit = $request->input('limit', 5); - $projects = Project::where('title', 'like', "%{$keyword}%")->limit($limit)->get(); - $tasks = Task::where('title', 'like', "%{$keyword}%")->limit($limit)->get(); - $users = User::where('name', 'like', "%{$keyword}%")->orWhere('email', 'like', "%{$keyword}%")->limit($limit)->get(); - $meetings = Meeting::where('title', 'like', "%{$keyword}%")->limit($limit)->get(); - $backlogItems = BacklogItem::where('title', 'like', "%{$keyword}%")->limit($limit)->get(); + $projects = $this->resourceAccess->projects($request->user()) + ->where('title', 'like', "%{$keyword}%")->limit($limit)->get(); + $tasks = $this->resourceAccess->tasks($request->user()) + ->where('title', 'like', "%{$keyword}%")->limit($limit)->get(); + $meetings = $this->resourceAccess->meetings($request->user()) + ->where('title', 'like', "%{$keyword}%")->limit($limit)->get(); + $backlogItems = BacklogItem::where('title', 'like', "%{$keyword}%") + ->where(function ($query) use ($request) { + $query->whereNull('project_id') + ->orWhereIn('project_id', $this->resourceAccess->projects($request->user())->select('projects.id')); + })->limit($limit)->get(); + $canViewTeam = $request->user()->loadMissing('roles.permissions')->roles->contains('name', 'admin') + || $request->user()->hasPermission('team.view'); + $users = $canViewTeam + ? User::where(function ($query) use ($keyword) { + $query->where('name', 'like', "%{$keyword}%") + ->orWhere('email', 'like', "%{$keyword}%"); + })->limit($limit)->get() + : collect(); return response()->json([ 'success' => true, diff --git a/backend/app/Http/Controllers/Api/SprintController.php b/backend/app/Http/Controllers/Api/SprintController.php index 771917a..2350173 100644 --- a/backend/app/Http/Controllers/Api/SprintController.php +++ b/backend/app/Http/Controllers/Api/SprintController.php @@ -5,8 +5,12 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; use App\Http\Resources\SprintResource; use App\Http\Resources\TaskResource; +use App\Models\Meeting; +use App\Models\MeetingType; use App\Models\Sprint; use App\Models\Task; +use App\Services\ResourceAccessService; +use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -14,12 +18,15 @@ use Illuminate\Validation\ValidationException; class SprintController extends Controller { + public function __construct(private readonly ResourceAccessService $resourceAccess) {} + private array $statuses = ['planning', 'active', 'completed', 'cancelled']; public function index(Request $request): JsonResponse { try { - $query = Sprint::with(['project', 'tasks', 'members']); + $query = $this->resourceAccess->sprints($request->user()) + ->with(['project', 'tasks', 'members']); if ($request->filled('project_id')) { $query->where('project_id', $request->project_id); @@ -28,7 +35,7 @@ class SprintController extends Controller $query->where('status', $request->status); } if ($request->boolean('mine')) { - $query->whereHas('members', fn($q) => $q->where('users.id', $request->user()->id)); + $query->whereHas('members', fn ($q) => $q->where('users.id', $request->user()->id)); } $perPage = min((int) $request->input('per_page', 15), 100); @@ -81,15 +88,48 @@ class SprintController extends Controller { try { $data = $this->validatedSprintData($request); + abort_unless( + $this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(), + 403, + 'شما به پروژه انتخابشده دسترسی ندارید.' + ); $memberIds = $data['member_ids'] ?? []; + $meetingSetup = $data['meeting_setup'] ?? []; unset($data['member_ids']); + unset($data['meeting_setup']); $data['created_by'] = $request->user()->id; $data['status'] = 'planning'; - $sprint = DB::transaction(function () use ($data, $memberIds) { + $sprint = DB::transaction(function () use ($data, $memberIds, $meetingSetup, $request) { $sprint = Sprint::create($data); $sprint->members()->sync($memberIds); + foreach ($meetingSetup as $setup) { + $type = MeetingType::where('key', $setup['type_key'])->where('is_active', true)->first(); + if (! $type) { + continue; + } + Meeting::create([ + 'title' => $setup['title'] ?? $type->default_title ?? $type->name, + 'project_id' => $sprint->project_id, + 'sprint_id' => $sprint->id, + 'meeting_type_id' => $type->id, + 'meeting_type' => $type->key, + 'date' => $setup['date'], + 'start_time' => $setup['start_time'] ?? null, + 'end_time' => $setup['end_time'] ?? null, + 'location' => $setup['location'] ?? null, + 'meeting_link' => $setup['meeting_link'] ?? null, + 'agenda' => $type->agenda_template, + 'status' => 'scheduled', + 'owner_id' => $setup['owner_id'] ?? $request->user()->id, + 'facilitator_id' => $setup['facilitator_id'] ?? null, + 'reminder_minutes' => $setup['reminder_minutes'] ?? 30, + 'recurrence_rule' => $setup['recurrence_rule'] ?? $type->recurrence_rule, + 'created_by' => $request->user()->id, + ]); + } + return $sprint; }); @@ -111,6 +151,13 @@ class SprintController extends Controller { try { $data = $this->validatedSprintData($request, true); + if (isset($data['project_id'])) { + abort_unless( + $this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(), + 403, + 'شما به پروژه انتخابشده دسترسی ندارید.' + ); + } $memberIds = $data['member_ids'] ?? null; unset($data['member_ids']); @@ -206,7 +253,7 @@ class SprintController extends Controller 'status' => 'required|string|in:waiting,todo,in_progress,review,done', ]); - if (!$sprint->tasks()->where('tasks.id', $task->id)->exists()) { + if (! $sprint->tasks()->where('tasks.id', $task->id)->exists()) { return response()->json(['success' => false, 'message' => 'این تسک در Sprint انتخابشده نیست'], 404); } @@ -227,7 +274,7 @@ class SprintController extends Controller public function updateStatus(Request $request, Sprint $sprint): JsonResponse { try { - $request->validate(['status' => 'required|string|in:' . implode(',', $this->statuses)]); + $request->validate(['status' => 'required|string|in:'.implode(',', $this->statuses)]); $sprint->update(['status' => $request->status]); return response()->json([ @@ -330,7 +377,7 @@ class SprintController extends Controller public function cancel(Sprint $sprint): JsonResponse { try { - if (!in_array($sprint->status, ['planning', 'active'], true)) { + if (! in_array($sprint->status, ['planning', 'active'], true)) { return response()->json(['success' => false, 'message' => 'این Sprint قابل لغو نیست'], 422); } @@ -364,8 +411,8 @@ class SprintController extends Controller 'completion_percentage' => $totalTasks > 0 ? round(($completedTasks / $totalTasks) * 100, 2) : 0, 'completed_by_member' => $tasks ->where('status', 'done') - ->groupBy(fn($task) => $task->assignee?->name ?? 'بدون مسئول') - ->map(fn($items, $name) => ['name' => $name, 'completed' => $items->count()]) + ->groupBy(fn ($task) => $task->assignee?->name ?? 'بدون مسئول') + ->map(fn ($items, $name) => ['name' => $name, 'completed' => $items->count()]) ->values(), 'unfinished_tasks' => TaskResource::collection($tasks->where('status', '!=', 'done')->values()), ], @@ -407,6 +454,66 @@ class SprintController extends Controller } } + public function workspace(Sprint $sprint): JsonResponse + { + $sprint->load([ + 'project', 'tasks.assignee', 'members', 'retrospective', + 'meetings.type', 'meetings.participants', 'decisions', 'actionItems', + 'blockers', 'scopeChanges', + ]); + + $tasks = $sprint->tasks; + $total = $tasks->count(); + $completed = $tasks->where('status', 'done')->count(); + $blocked = $tasks->where('is_blocked', true)->count() + $sprint->blockers->whereNotIn('status', ['resolved', 'closed'])->count(); + $overdue = $tasks->filter(fn (Task $task) => $task->due_date?->isPast() && $task->status !== 'done')->count(); + $progress = $total > 0 ? round(($completed / $total) * 100) : 0; + $today = Carbon::today(); + $duration = max($sprint->start_date?->diffInDays($sprint->end_date) ?? 1, 1); + $elapsed = max($sprint->start_date?->diffInDays($today, false) ?? 0, 0); + $expected = min(100, round(($elapsed / $duration) * 100)); + $health = 'on_track'; + if ($sprint->end_date?->isPast() && $progress < 100) { + $health = 'off_track'; + } elseif ($blocked > 1 || $overdue > 2 || $progress + 20 < $expected) { + $health = 'at_risk'; + } + $upcomingMeeting = $sprint->meetings + ->whereNotIn('status', ['completed', 'cancelled']) + ->filter(fn (Meeting $meeting) => $meeting->date?->gte($today)) + ->sortBy(fn (Meeting $meeting) => $meeting->date?->format('Y-m-d').' '.$meeting->start_time) + ->first(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'sprint' => new SprintResource($sprint), + 'health' => [ + 'status' => $health, + 'progress' => $progress, + 'expected_progress' => $expected, + 'remaining_days' => max($today->diffInDays($sprint->end_date, false), 0), + 'open_blockers' => $blocked, + 'overdue_tasks' => $overdue, + ], + 'progress' => [ + 'total_tasks' => $total, + 'completed_tasks' => $completed, + 'in_progress_tasks' => $tasks->where('status', 'in_progress')->count(), + 'blocked_tasks' => $blocked, + 'overdue_tasks' => $overdue, + ], + 'upcoming_meeting' => $upcomingMeeting, + 'meetings' => $sprint->meetings->values(), + 'decisions' => $sprint->decisions, + 'action_items' => $sprint->actionItems, + 'blockers' => $sprint->blockers, + 'scope_changes' => $sprint->scopeChanges, + ], + 'message' => 'فضای کاری Sprint', + ]); + } + private function validatedSprintData(Request $request, bool $partial = false): array { $required = $partial ? 'sometimes|required' : 'required'; @@ -420,6 +527,18 @@ class SprintController extends Controller 'end_date' => "{$required}|date|after_or_equal:start_date", 'member_ids' => 'nullable|array', 'member_ids.*' => 'exists:users,id', + 'meeting_setup' => 'nullable|array', + 'meeting_setup.*.type_key' => 'required|string|exists:meeting_types,key', + 'meeting_setup.*.title' => 'nullable|string|max:255', + 'meeting_setup.*.date' => 'required|date', + 'meeting_setup.*.start_time' => 'nullable|date_format:H:i', + 'meeting_setup.*.end_time' => 'nullable|date_format:H:i|after:meeting_setup.*.start_time', + 'meeting_setup.*.location' => 'nullable|string|max:255', + 'meeting_setup.*.meeting_link' => 'nullable|url', + 'meeting_setup.*.owner_id' => 'nullable|exists:users,id', + 'meeting_setup.*.facilitator_id' => 'nullable|exists:users,id', + 'meeting_setup.*.reminder_minutes' => 'nullable|integer|min:0|max:10080', + 'meeting_setup.*.recurrence_rule' => 'nullable|array', ]); } } diff --git a/backend/app/Http/Controllers/Api/TaskController.php b/backend/app/Http/Controllers/Api/TaskController.php index cedf9f4..9361bee 100644 --- a/backend/app/Http/Controllers/Api/TaskController.php +++ b/backend/app/Http/Controllers/Api/TaskController.php @@ -10,6 +10,7 @@ use App\Models\Task; use App\Services\ActivityLogService; use App\Services\NotificationService; use App\Services\ProjectProgressService; +use App\Services\ResourceAccessService; use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -20,12 +21,15 @@ class TaskController extends Controller protected ActivityLogService $activityLogService, protected NotificationService $notificationService, protected ProjectProgressService $projectProgressService, + protected ResourceAccessService $resourceAccess, ) {} public function index(Request $request): JsonResponse { try { - $query = Task::with(['project', 'assignee', 'reporter'])->withCount(['comments', 'files']); + $query = $this->resourceAccess->tasks($request->user()) + ->with(['project', 'assignee', 'reporter']) + ->withCount(['comments', 'files']); if ($request->filled('project_id')) { $query->where('project_id', $request->project_id); @@ -92,6 +96,11 @@ class TaskController extends Controller { try { $data = $request->validated(); + abort_unless( + $this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(), + 403, + 'شما به پروژه انتخابشده دسترسی ندارید.' + ); $data['reporter_id'] = $request->user()->id; $data['created_by'] = $request->user()->id; $task = Task::create($data); @@ -142,8 +151,16 @@ class TaskController extends Controller public function update(UpdateTaskRequest $request, Task $task): JsonResponse { try { + $validated = $request->validated(); + if (isset($validated['project_id'])) { + abort_unless( + $this->resourceAccess->projects($request->user())->whereKey($validated['project_id'])->exists(), + 403, + 'شما به پروژه انتخابشده دسترسی ندارید.' + ); + } $oldStatus = $task->status; - $task->update($request->validated()); + $task->update($validated); if ($task->wasChanged('status')) { $this->activityLogService->log( diff --git a/backend/app/Http/Controllers/Api/UserController.php b/backend/app/Http/Controllers/Api/UserController.php index ef5cba2..a4ccdaf 100644 --- a/backend/app/Http/Controllers/Api/UserController.php +++ b/backend/app/Http/Controllers/Api/UserController.php @@ -6,8 +6,8 @@ use App\Http\Controllers\Controller; use App\Http\Requests\StoreUserRequest; use App\Http\Requests\UpdateUserRequest; use App\Http\Resources\UserResource; -use App\Models\User; use App\Models\Task; +use App\Models\User; use App\Services\WorkloadService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -26,7 +26,7 @@ class UserController extends Controller $search = $request->search; $query->where(function ($q) use ($search) { $q->where('name', 'like', "%{$search}%") - ->orWhere('email', 'like', "%{$search}%"); + ->orWhere('email', 'like', "%{$search}%"); }); } @@ -103,13 +103,13 @@ class UserController extends Controller { try { $data = $request->validated(); - if (!empty($data['password'])) { + if (! empty($data['password'])) { $data['password'] = Hash::make($data['password']); } else { unset($data['password']); } $user->update($data); - if (!empty($data['role_id'])) { + if (! empty($data['role_id'])) { $user->roles()->sync([$data['role_id']]); } $user->load(['role', 'roles.permissions']); diff --git a/backend/app/Http/Middleware/EnsureResourceAccess.php b/backend/app/Http/Middleware/EnsureResourceAccess.php new file mode 100644 index 0000000..afde854 --- /dev/null +++ b/backend/app/Http/Middleware/EnsureResourceAccess.php @@ -0,0 +1,71 @@ +user(); + if (! $user) { + return response()->json(['success' => false, 'message' => 'Unauthenticated.'], 401); + } + + $route = $request->route(); + $checks = [ + 'project' => fn ($model) => $model instanceof Project && $this->access->canAccessProject($user, $model), + 'task' => fn ($model) => $model instanceof Task && $this->access->canAccessTask($user, $model), + 'sprint' => fn ($model) => $model instanceof Sprint && $this->access->canAccessSprint($user, $model), + 'meeting' => fn ($model) => $model instanceof Meeting && $this->access->canAccessMeeting($user, $model), + 'comment' => fn ($model) => $model instanceof Comment && $this->access->canAccessComment($user, $model), + 'file' => fn ($model) => $model instanceof File && $this->access->canAccessFile($user, $model), + 'backlogItem' => fn ($model) => $model instanceof BacklogItem + && ($this->access->canSeeAll($user) + || $model->created_by === $user->id + || ($model->project_id !== null + && $this->access->projects($user)->whereKey($model->project_id)->exists())), + 'checklist' => fn ($model) => $model instanceof Checklist && $this->access->tasks($user)->whereKey($model->task_id)->exists(), + 'subtask' => fn ($model) => $model instanceof Subtask && $this->access->tasks($user)->whereKey($model->task_id)->exists(), + 'notification' => fn ($model) => $model instanceof Notification && $model->user_id === $user->id, + ]; + + foreach ($checks as $parameter => $allowed) { + $model = $route?->parameter($parameter); + if ($model !== null && ! $allowed($model)) { + abort(403, 'شما به این منبع دسترسی ندارید.'); + } + } + + $actionItem = $route?->parameter('actionItem'); + $meeting = $route?->parameter('meeting'); + if ($actionItem instanceof MeetingActionItem) { + abort_unless( + $meeting instanceof Meeting + && $actionItem->meeting_id === $meeting->id + && $this->access->canAccessMeeting($user, $meeting), + 403, + 'این اقدام متعلق به جلسه انتخابشده نیست.' + ); + } + + return $next($request); + } +} diff --git a/backend/app/Http/Middleware/EnsureUserHasPermission.php b/backend/app/Http/Middleware/EnsureUserHasPermission.php index cad248c..16d371a 100644 --- a/backend/app/Http/Middleware/EnsureUserHasPermission.php +++ b/backend/app/Http/Middleware/EnsureUserHasPermission.php @@ -12,7 +12,7 @@ class EnsureUserHasPermission { $user = $request->user(); - if (!$user) { + if (! $user) { return response()->json([ 'success' => false, 'message' => 'Unauthenticated.', @@ -23,7 +23,7 @@ class EnsureUserHasPermission $isAdmin = $user->roles->contains('name', 'admin'); - if (!$isAdmin && !$user->hasPermission($permission)) { + if (! $isAdmin && ! $user->hasPermission($permission)) { return response()->json([ 'success' => false, 'message' => 'شما دسترسی لازم برای انجام این عملیات را ندارید', diff --git a/backend/app/Http/Middleware/SecurityHeaders.php b/backend/app/Http/Middleware/SecurityHeaders.php index 37a63c3..ae373de 100644 --- a/backend/app/Http/Middleware/SecurityHeaders.php +++ b/backend/app/Http/Middleware/SecurityHeaders.php @@ -18,7 +18,7 @@ class SecurityHeaders $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); $response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); - if (!$response->headers->has('Content-Security-Policy')) { + if (! $response->headers->has('Content-Security-Policy')) { $response->headers->set( 'Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'" diff --git a/backend/app/Http/Requests/ChangePasswordRequest.php b/backend/app/Http/Requests/ChangePasswordRequest.php index f9e8829..0e179f7 100644 --- a/backend/app/Http/Requests/ChangePasswordRequest.php +++ b/backend/app/Http/Requests/ChangePasswordRequest.php @@ -15,7 +15,7 @@ class ChangePasswordRequest extends FormRequest { return [ 'current_password' => 'required', - 'new_password' => 'required|min:8|confirmed', + 'new_password' => 'required|string|min:12|confirmed', ]; } } diff --git a/backend/app/Http/Requests/StoreMeetingRequest.php b/backend/app/Http/Requests/StoreMeetingRequest.php index dde2d15..008bd1c 100644 --- a/backend/app/Http/Requests/StoreMeetingRequest.php +++ b/backend/app/Http/Requests/StoreMeetingRequest.php @@ -16,10 +16,23 @@ class StoreMeetingRequest extends FormRequest return [ 'title' => 'required|string|max:255', 'project_id' => 'nullable|exists:projects,id', + 'sprint_id' => 'nullable|exists:sprints,id', + 'meeting_type_id' => 'nullable|exists:meeting_types,id', 'date' => 'required|date', - 'start_time' => 'nullable', - 'end_time' => 'nullable', + 'start_time' => 'nullable|date_format:H:i', + 'end_time' => 'nullable|date_format:H:i|after:start_time', 'meeting_type' => 'required|string|max:50', + 'status' => 'nullable|string|in:draft,scheduled,in_progress,completed,cancelled,postponed', + 'objective' => 'nullable|string|max:2000', + 'agenda' => 'nullable|string', + 'notes' => 'nullable|string', + 'summary' => 'nullable|string', + 'location' => 'nullable|string|max:255', + 'meeting_link' => 'nullable|url|max:2048', + 'owner_id' => 'nullable|exists:users,id', + 'facilitator_id' => 'nullable|exists:users,id', + 'reminder_minutes' => 'nullable|integer|min:0|max:10080', + 'recurrence_rule' => 'nullable|array', ]; } } diff --git a/backend/app/Http/Requests/StoreTaskRequest.php b/backend/app/Http/Requests/StoreTaskRequest.php index 559851f..06180ba 100644 --- a/backend/app/Http/Requests/StoreTaskRequest.php +++ b/backend/app/Http/Requests/StoreTaskRequest.php @@ -54,7 +54,7 @@ class StoreTaskRequest extends FormRequest $user = $this->user()?->loadMissing('roles.permissions'); $project = Project::with('members:id')->find($this->input('project_id')); - if (!$user || !$project) { + if (! $user || ! $project) { return; } @@ -63,7 +63,7 @@ class StoreTaskRequest extends FormRequest || (int) $project->created_by === (int) $user->id || $project->members->contains('id', $user->id); - if (!$canUseProject) { + if (! $canUseProject) { $validator->errors()->add('project_id', 'شما به این پروژه دسترسی ندارید.'); } @@ -76,7 +76,7 @@ class StoreTaskRequest extends FormRequest || (int) $project->project_manager_id === (int) $assignee->id ); - if (!$assigneeAllowed) { + if (! $assigneeAllowed) { $validator->errors()->add('assignee_id', 'مسئول انتخابشده برای این پروژه مجاز نیست.'); } } diff --git a/backend/app/Http/Requests/UpdateProfileRequest.php b/backend/app/Http/Requests/UpdateProfileRequest.php index 2682d2e..e27e174 100644 --- a/backend/app/Http/Requests/UpdateProfileRequest.php +++ b/backend/app/Http/Requests/UpdateProfileRequest.php @@ -24,7 +24,7 @@ class UpdateProfileRequest extends FormRequest { return [ 'name' => 'required|string|max:255', - 'email' => 'required|email|unique:users,email,' . $this->user()->id, + 'email' => 'required|email|unique:users,email,'.$this->user()->id, 'phone' => 'nullable|string|max:50', 'job_title' => 'nullable|string|max:255', 'department' => 'nullable|string|max:255', diff --git a/backend/app/Http/Requests/UpdateRoleRequest.php b/backend/app/Http/Requests/UpdateRoleRequest.php index adb85cd..74f2c00 100644 --- a/backend/app/Http/Requests/UpdateRoleRequest.php +++ b/backend/app/Http/Requests/UpdateRoleRequest.php @@ -14,8 +14,9 @@ class UpdateRoleRequest extends FormRequest public function rules(): array { $roleId = $this->route('role'); + return [ - 'name' => 'sometimes|required|string|max:255|unique:roles,name,' . $roleId, + 'name' => 'sometimes|required|string|max:255|unique:roles,name,'.$roleId, 'display_name' => 'sometimes|required|string|max:255', 'description' => 'nullable|string|max:1000', 'guard_name' => 'nullable|string|max:50', diff --git a/backend/app/Http/Requests/UpdateUserRequest.php b/backend/app/Http/Requests/UpdateUserRequest.php index 0c53af4..baeff7b 100644 --- a/backend/app/Http/Requests/UpdateUserRequest.php +++ b/backend/app/Http/Requests/UpdateUserRequest.php @@ -30,9 +30,10 @@ class UpdateUserRequest extends FormRequest { $userId = $this->route('user'); $userId = is_object($userId) ? $userId->id : $userId; + return [ 'name' => 'sometimes|required|string|max:255', - 'email' => 'sometimes|required|email|unique:users,email,' . $userId, + 'email' => 'sometimes|required|email|unique:users,email,'.$userId, 'phone' => 'nullable|string|max:50', 'job_title' => 'nullable|string|max:255', 'department' => 'nullable|string|max:255', diff --git a/backend/app/Http/Resources/MeetingResource.php b/backend/app/Http/Resources/MeetingResource.php index 321441d..d3e4818 100644 --- a/backend/app/Http/Resources/MeetingResource.php +++ b/backend/app/Http/Resources/MeetingResource.php @@ -14,19 +14,38 @@ class MeetingResource extends JsonResource 'title' => $this->title, 'project_id' => $this->project_id, 'project' => new ProjectResource($this->whenLoaded('project')), + 'sprint_id' => $this->sprint_id, + 'sprint' => new SprintResource($this->whenLoaded('sprint')), + 'meeting_type_id' => $this->meeting_type_id, + 'type' => $this->whenLoaded('type'), 'date' => $this->date?->format('Y-m-d'), 'start_time' => $this->start_time, 'end_time' => $this->end_time, 'location' => $this->location, 'meeting_link' => $this->meeting_link, 'meeting_type' => $this->meeting_type, + 'status' => $this->status ?? 'scheduled', + 'objective' => $this->objective, 'agenda' => $this->agenda, 'notes' => $this->notes, + 'summary' => $this->summary, 'decisions' => $this->decisions, + 'structured_decisions' => $this->whenLoaded('structuredDecisions'), + 'structured_action_items' => $this->whenLoaded('structuredActionItems'), + 'blockers' => $this->whenLoaded('blockers'), + 'files' => FileResource::collection($this->whenLoaded('files')), 'participants' => UserResource::collection($this->whenLoaded('participants')), 'action_items' => MeetingActionItemResource::collection($this->whenLoaded('actionItems')), 'created_by' => $this->created_by, 'creator' => new UserResource($this->whenLoaded('creator')), + 'owner_id' => $this->owner_id, + 'owner' => new UserResource($this->whenLoaded('owner')), + 'facilitator_id' => $this->facilitator_id, + 'facilitator' => new UserResource($this->whenLoaded('facilitator')), + 'reminder_minutes' => $this->reminder_minutes, + 'recurrence_rule' => $this->recurrence_rule, + 'started_at' => $this->started_at, + 'completed_at' => $this->completed_at, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/backend/app/Http/Resources/NotificationResource.php b/backend/app/Http/Resources/NotificationResource.php index 840aaf0..ecda18b 100644 --- a/backend/app/Http/Resources/NotificationResource.php +++ b/backend/app/Http/Resources/NotificationResource.php @@ -9,6 +9,20 @@ class NotificationResource extends JsonResource { public function toArray(Request $request): array { + $entity = class_basename((string) $this->notifiable_type); + $targetUrl = match ($entity) { + 'Meeting' => "/meetings/{$this->notifiable_id}/workspace", + 'Sprint' => "/sprints/{$this->notifiable_id}/workspace", + 'Task' => "/tasks?preview={$this->notifiable_id}", + 'Project' => "/projects/{$this->notifiable_id}", + 'ActionItem' => '/meetings', + default => null, + }; + $dataUrl = is_array($this->data) ? ($this->data['url'] ?? null) : null; + if (! $targetUrl && is_string($dataUrl) && str_starts_with($dataUrl, '/')) { + $targetUrl = $dataUrl; + } + return [ 'id' => $this->id, 'type' => $this->type, @@ -20,6 +34,16 @@ class NotificationResource extends JsonResource 'notifiable_id' => $this->notifiable_id, 'is_read' => $this->is_read, 'read_at' => $this->read_at, + 'archived_at' => $this->archived_at, + 'entity_type' => $entity ? strtolower($entity) : null, + 'entity_id' => $this->notifiable_id, + 'target_url' => $targetUrl, + 'can_navigate' => (bool) $targetUrl, + 'preview' => [ + 'title' => $this->title ?: 'اعلان', + 'body' => $this->body ?: ($this->data['message'] ?? null), + 'type' => $this->type, + ], 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/backend/app/Http/Resources/ProjectResource.php b/backend/app/Http/Resources/ProjectResource.php index 5344af3..894639a 100644 --- a/backend/app/Http/Resources/ProjectResource.php +++ b/backend/app/Http/Resources/ProjectResource.php @@ -17,7 +17,7 @@ class ProjectResource extends JsonResource 'project_manager_id' => $this->project_manager_id, 'project_manager' => new UserResource($this->whenLoaded('projectManager')), 'department_id' => $this->department_id, - 'department' => $this->whenLoaded('department', fn() => [ + 'department' => $this->whenLoaded('department', fn () => [ 'id' => $this->department?->id, 'name' => $this->department?->name, 'type' => $this->department?->type, diff --git a/backend/app/Http/Resources/SprintResource.php b/backend/app/Http/Resources/SprintResource.php index 2058643..4033d6d 100644 --- a/backend/app/Http/Resources/SprintResource.php +++ b/backend/app/Http/Resources/SprintResource.php @@ -9,8 +9,8 @@ class SprintResource extends JsonResource { public function toArray(Request $request): array { - $totalTasks = $this->whenLoaded('tasks', fn() => $this->tasks->count(), 0); - $completedTasks = $this->whenLoaded('tasks', fn() => $this->tasks->where('status', 'done')->count(), 0); + $totalTasks = $this->whenLoaded('tasks', fn () => $this->tasks->count(), 0); + $completedTasks = $this->whenLoaded('tasks', fn () => $this->tasks->where('status', 'done')->count(), 0); $remainingTasks = max($totalTasks - $completedTasks, 0); return [ @@ -25,7 +25,7 @@ class SprintResource extends JsonResource 'status' => $this->status, 'tasks' => TaskResource::collection($this->whenLoaded('tasks')), 'members' => UserResource::collection($this->whenLoaded('members')), - 'retrospective' => $this->whenLoaded('retrospective', fn() => [ + 'retrospective' => $this->whenLoaded('retrospective', fn () => [ 'went_well' => $this->retrospective?->went_well, 'problems' => $this->retrospective?->problems, 'improvements' => $this->retrospective?->improvements, diff --git a/backend/app/Http/Resources/UserResource.php b/backend/app/Http/Resources/UserResource.php index a51b2dc..32667f8 100644 --- a/backend/app/Http/Resources/UserResource.php +++ b/backend/app/Http/Resources/UserResource.php @@ -17,8 +17,8 @@ class UserResource extends JsonResource 'job_title' => $this->job_title, 'department' => $this->department, 'department_id' => $this->department_id, - 'department_name' => $this->when($this->relationLoaded('dept'), fn() => $this->dept?->name), - 'departments' => $this->when($this->relationLoaded('departments'), fn() => $this->departments->map(fn($department) => [ + 'department_name' => $this->when($this->relationLoaded('dept'), fn () => $this->dept?->name), + 'departments' => $this->when($this->relationLoaded('departments'), fn () => $this->departments->map(fn ($department) => [ 'id' => $department->id, 'name' => $department->name, 'type' => $department->type, @@ -29,7 +29,7 @@ class UserResource extends JsonResource 'status' => $this->status, 'skills' => $this->skills, 'avatar' => $this->avatar, - 'avatar_url' => $this->avatar ? asset('storage/' . $this->avatar) : null, + 'avatar_url' => $this->avatar ? asset('storage/'.$this->avatar) : null, 'role_id' => $this->role_id, 'role' => new RoleResource($this->whenLoaded('role')), 'roles' => RoleResource::collection($this->whenLoaded('roles')), diff --git a/backend/app/Models/ActionItem.php b/backend/app/Models/ActionItem.php new file mode 100644 index 0000000..8ce329f --- /dev/null +++ b/backend/app/Models/ActionItem.php @@ -0,0 +1,31 @@ + 'date']; + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function sprint(): BelongsTo + { + return $this->belongsTo(Sprint::class); + } + + public function meeting(): BelongsTo + { + return $this->belongsTo(Meeting::class); + } +} diff --git a/backend/app/Models/ActivityLog.php b/backend/app/Models/ActivityLog.php index ca0aa3d..29ca8cb 100644 --- a/backend/app/Models/ActivityLog.php +++ b/backend/app/Models/ActivityLog.php @@ -9,7 +9,7 @@ class ActivityLog extends Model { protected $fillable = [ 'user_id', 'action', 'description', 'subject_type', 'subject_id', - 'project_id', 'task_id', 'properties' + 'project_id', 'task_id', 'properties', ]; protected $casts = [ diff --git a/backend/app/Models/BacklogItem.php b/backend/app/Models/BacklogItem.php index 9220a00..b6eac8f 100644 --- a/backend/app/Models/BacklogItem.php +++ b/backend/app/Models/BacklogItem.php @@ -9,7 +9,7 @@ class BacklogItem extends Model { protected $fillable = [ 'title', 'description', 'type', 'project_id', 'priority', - 'estimated_effort', 'status', 'assigned_sprint_id', 'created_by' + 'estimated_effort', 'status', 'assigned_sprint_id', 'created_by', ]; public function project(): BelongsTo diff --git a/backend/app/Models/Blocker.php b/backend/app/Models/Blocker.php new file mode 100644 index 0000000..970e3cc --- /dev/null +++ b/backend/app/Models/Blocker.php @@ -0,0 +1,18 @@ + 'date', + 'resolved_at' => 'datetime', + ]; +} diff --git a/backend/app/Models/Decision.php b/backend/app/Models/Decision.php new file mode 100644 index 0000000..290e33b --- /dev/null +++ b/backend/app/Models/Decision.php @@ -0,0 +1,18 @@ + 'date', + 'review_date' => 'date', + ]; +} diff --git a/backend/app/Models/Meeting.php b/backend/app/Models/Meeting.php index 29cd0ae..db9dee0 100644 --- a/backend/app/Models/Meeting.php +++ b/backend/app/Models/Meeting.php @@ -11,12 +11,17 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; class Meeting extends Model { protected $fillable = [ - 'title', 'project_id', 'date', 'start_time', 'end_time', 'location', - 'meeting_link', 'meeting_type', 'agenda', 'notes', 'decisions', 'created_by' + 'title', 'project_id', 'sprint_id', 'meeting_type_id', 'date', 'start_time', + 'end_time', 'location', 'meeting_link', 'meeting_type', 'status', 'objective', + 'agenda', 'notes', 'summary', 'decisions', 'reminder_minutes', 'recurrence_rule', + 'started_at', 'completed_at', 'created_by', 'owner_id', 'facilitator_id', ]; protected $casts = [ 'date' => 'date', + 'recurrence_rule' => 'array', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', ]; public function project(): BelongsTo @@ -29,6 +34,26 @@ class Meeting extends Model return $this->belongsTo(User::class, 'created_by'); } + public function sprint(): BelongsTo + { + return $this->belongsTo(Sprint::class); + } + + public function type(): BelongsTo + { + return $this->belongsTo(MeetingType::class, 'meeting_type_id'); + } + + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_id'); + } + + public function facilitator(): BelongsTo + { + return $this->belongsTo(User::class, 'facilitator_id'); + } + public function participants(): BelongsToMany { return $this->belongsToMany(User::class, 'meeting_user')->withTimestamps(); @@ -39,6 +64,21 @@ class Meeting extends Model return $this->hasMany(MeetingActionItem::class); } + public function structuredActionItems(): HasMany + { + return $this->hasMany(ActionItem::class); + } + + public function structuredDecisions(): HasMany + { + return $this->hasMany(Decision::class); + } + + public function blockers(): HasMany + { + return $this->hasMany(Blocker::class); + } + public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); diff --git a/backend/app/Models/MeetingActionItem.php b/backend/app/Models/MeetingActionItem.php index 503236c..17cc9ba 100644 --- a/backend/app/Models/MeetingActionItem.php +++ b/backend/app/Models/MeetingActionItem.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MeetingActionItem extends Model { protected $fillable = [ - 'meeting_id', 'title', 'assigned_to', 'due_date', 'is_completed', 'converted_to_task_id' + 'meeting_id', 'title', 'assigned_to', 'due_date', 'is_completed', 'converted_to_task_id', ]; protected $casts = [ diff --git a/backend/app/Models/MeetingType.php b/backend/app/Models/MeetingType.php new file mode 100644 index 0000000..21d4e71 --- /dev/null +++ b/backend/app/Models/MeetingType.php @@ -0,0 +1,26 @@ + 'array', + 'recurrence_rule' => 'array', + 'settings' => 'array', + 'is_active' => 'boolean', + ]; + + public function meetings(): HasMany + { + return $this->hasMany(Meeting::class); + } +} diff --git a/backend/app/Models/Notification.php b/backend/app/Models/Notification.php index 512e8a7..6fcbc81 100644 --- a/backend/app/Models/Notification.php +++ b/backend/app/Models/Notification.php @@ -3,12 +3,15 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletes; class Notification extends Model { + use SoftDeletes; + protected $fillable = [ 'user_id', 'title', 'body', 'type', 'notifiable_type', 'notifiable_id', - 'data', 'is_read', 'read_at', 'remind_at', 'dismissed_at' + 'data', 'is_read', 'read_at', 'remind_at', 'dismissed_at', 'archived_at', ]; protected $casts = [ @@ -17,5 +20,6 @@ class Notification extends Model 'read_at' => 'datetime', 'remind_at' => 'datetime', 'dismissed_at' => 'datetime', + 'archived_at' => 'datetime', ]; } diff --git a/backend/app/Models/Project.php b/backend/app/Models/Project.php index 35f15a9..3c4813d 100644 --- a/backend/app/Models/Project.php +++ b/backend/app/Models/Project.php @@ -12,7 +12,7 @@ class Project extends Model protected $fillable = [ 'title', 'description', 'client', 'project_manager_id', 'department_id', 'start_date', 'end_date', 'priority', 'status', 'progress', 'risk_level', 'budget', 'estimated_hours', - 'actual_hours', 'tags', 'notes', 'is_archived', 'created_by' + 'actual_hours', 'tags', 'notes', 'is_archived', 'created_by', ]; protected $casts = [ diff --git a/backend/app/Models/Setting.php b/backend/app/Models/Setting.php index 0628d46..50f7bf4 100644 --- a/backend/app/Models/Setting.php +++ b/backend/app/Models/Setting.php @@ -2,8 +2,8 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Model; class Setting extends Model { diff --git a/backend/app/Models/Sprint.php b/backend/app/Models/Sprint.php index e32a4fd..3ced2f6 100644 --- a/backend/app/Models/Sprint.php +++ b/backend/app/Models/Sprint.php @@ -52,4 +52,29 @@ class Sprint extends Model { return $this->hasMany(BacklogItem::class, 'assigned_sprint_id'); } + + public function meetings(): HasMany + { + return $this->hasMany(Meeting::class); + } + + public function decisions(): HasMany + { + return $this->hasMany(Decision::class); + } + + public function actionItems(): HasMany + { + return $this->hasMany(ActionItem::class); + } + + public function blockers(): HasMany + { + return $this->hasMany(Blocker::class); + } + + public function scopeChanges(): HasMany + { + return $this->hasMany(SprintScopeChange::class); + } } diff --git a/backend/app/Models/SprintScopeChange.php b/backend/app/Models/SprintScopeChange.php new file mode 100644 index 0000000..b74b95f --- /dev/null +++ b/backend/app/Models/SprintScopeChange.php @@ -0,0 +1,16 @@ + 'array', + 'new_state' => 'array', + 'capacity_impact' => 'decimal:2', + ]; +} diff --git a/backend/app/Models/Task.php b/backend/app/Models/Task.php index 4c32667..61a87f6 100644 --- a/backend/app/Models/Task.php +++ b/backend/app/Models/Task.php @@ -12,7 +12,7 @@ class Task extends Model protected $fillable = [ 'title', 'description', 'project_id', 'assignee_id', 'reporter_id', 'priority', 'status', 'blocker_type', 'blocker_note', 'start_date', 'due_date', 'estimated_time', - 'actual_time', 'tags', 'sort_order', 'created_by' + 'actual_time', 'tags', 'sort_order', 'created_by', ]; protected $casts = [ diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index e321f10..5fdbf11 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -63,6 +63,7 @@ class User extends Authenticatable return true; } } + return false; } @@ -73,6 +74,7 @@ class User extends Authenticatable return true; } } + return false; } diff --git a/backend/app/Services/CalendarService.php b/backend/app/Services/CalendarService.php new file mode 100644 index 0000000..ef180ef --- /dev/null +++ b/backend/app/Services/CalendarService.php @@ -0,0 +1,182 @@ + $types === [] || in_array($type, $types, true); + $events = collect(); + + if ($enabled('meeting')) { + $this->access->meetings($user)->with(['project:id,title', 'sprint:id,title']) + ->whereBetween('date', [$from->toDateString(), $to->toDateString()]) + ->orderBy('date') + ->orderBy('start_time') + ->get() + ->each(function (Meeting $meeting) use ($events) { + $start = $meeting->date->format('Y-m-d').'T'.($meeting->start_time ?: '00:00:00'); + $end = $meeting->end_time ? $meeting->date->format('Y-m-d').'T'.$meeting->end_time : null; + $events->push([ + 'id' => "meeting-{$meeting->id}", + 'event_type' => 'meeting', + 'entity_type' => 'meeting', + 'entity_id' => $meeting->id, + 'title' => $meeting->title, + 'start_at' => $start, + 'end_at' => $end, + 'all_day' => ! $meeting->start_time, + 'status' => $meeting->status ?? 'scheduled', + 'color_token' => 'info', + 'project' => $meeting->project?->only(['id', 'title']), + 'sprint' => $meeting->sprint?->only(['id', 'title']), + 'preview' => [ + 'زمان' => $meeting->start_time ? substr($meeting->start_time, 0, 5) : 'تمام روز', + 'مکان' => $meeting->location, + 'هدف' => $meeting->objective, + ], + 'target_url' => "/meetings/{$meeting->id}/workspace", + ]); + }); + } + + if ($enabled('task')) { + $this->access->tasks($user)->with('project:id,title') + ->whereNotNull('due_date') + ->whereBetween('due_date', [$from->toDateString(), $to->toDateString()]) + ->where('status', '!=', 'done') + ->orderBy('due_date') + ->get() + ->each(fn (Task $task) => $events->push([ + 'id' => "task-{$task->id}", + 'event_type' => 'task', + 'entity_type' => 'task', + 'entity_id' => $task->id, + 'title' => $task->title, + 'start_at' => $task->due_date?->format('Y-m-d'), + 'end_at' => null, + 'all_day' => true, + 'status' => $task->status, + 'color_token' => $task->priority === 'urgent' ? 'danger' : 'warning', + 'project' => $task->project?->only(['id', 'title']), + 'sprint' => null, + 'preview' => [ + 'وضعیت' => $task->status, + 'اولویت' => $task->priority, + ], + 'target_url' => "/tasks?preview={$task->id}", + ])); + } + + if ($enabled('sprint')) { + $this->access->sprints($user)->with('project:id,title') + ->where(function ($query) use ($from, $to) { + $query->whereBetween('start_date', [$from->toDateString(), $to->toDateString()]) + ->orWhereBetween('end_date', [$from->toDateString(), $to->toDateString()]); + }) + ->get() + ->each(function (Sprint $sprint) use ($events, $from, $to) { + foreach ([['start_date', 'شروع'], ['end_date', 'پایان']] as [$field, $label]) { + $date = $sprint->{$field}; + if (! $date || $date->lt($from) || $date->gt($to)) { + continue; + } + $events->push([ + 'id' => "sprint-{$field}-{$sprint->id}", + 'event_type' => 'sprint', + 'entity_type' => 'sprint', + 'entity_id' => $sprint->id, + 'title' => "{$label} {$sprint->title}", + 'start_at' => $date->format('Y-m-d'), + 'end_at' => null, + 'all_day' => true, + 'status' => $sprint->status, + 'color_token' => $field === 'start_date' ? 'success' : 'primary', + 'project' => $sprint->project?->only(['id', 'title']), + 'sprint' => $sprint->only(['id', 'title']), + 'preview' => [ + 'هدف' => $sprint->goal, + 'ظرفیت' => $sprint->capacity_hours, + ], + 'target_url' => "/sprints/{$sprint->id}/workspace", + ]); + } + }); + } + + if ($enabled('project')) { + $this->access->projects($user)->where(function ($query) use ($from, $to) { + $query->whereBetween('start_date', [$from->toDateString(), $to->toDateString()]) + ->orWhereBetween('end_date', [$from->toDateString(), $to->toDateString()]); + })->get()->each(function (Project $project) use ($events, $from, $to) { + foreach ([['start_date', 'شروع'], ['end_date', 'پایان']] as [$field, $label]) { + $date = $project->{$field}; + if (! $date || Carbon::parse($date)->lt($from) || Carbon::parse($date)->gt($to)) { + continue; + } + $events->push([ + 'id' => "project-{$field}-{$project->id}", + 'event_type' => 'project', + 'entity_type' => 'project', + 'entity_id' => $project->id, + 'title' => "{$label} {$project->title}", + 'start_at' => Carbon::parse($date)->format('Y-m-d'), + 'end_at' => null, + 'all_day' => true, + 'status' => $project->status, + 'color_token' => 'secondary', + 'project' => $project->only(['id', 'title']), + 'sprint' => null, + 'preview' => ['پیشرفت' => "{$project->progress}%"], + 'target_url' => "/projects/{$project->id}", + ]); + } + }); + } + + if ($enabled('action_item') && class_exists(ActionItem::class)) { + ActionItem::with(['project:id,title', 'sprint:id,title']) + ->when(! $this->access->canSeeAll($user), function ($query) use ($user) { + $query->where(function ($scope) use ($user) { + $scope->where('owner_id', $user->id) + ->orWhere('created_by', $user->id) + ->orWhereIn('project_id', $this->access->projects($user)->select('projects.id')); + }); + }) + ->whereNotNull('due_date') + ->whereBetween('due_date', [$from->toDateString(), $to->toDateString()]) + ->whereNotIn('status', ['completed', 'cancelled']) + ->get() + ->each(fn (ActionItem $item) => $events->push([ + 'id' => "action-item-{$item->id}", + 'event_type' => 'action_item', + 'entity_type' => 'action_item', + 'entity_id' => $item->id, + 'title' => $item->title, + 'start_at' => $item->due_date?->format('Y-m-d'), + 'end_at' => null, + 'all_day' => true, + 'status' => $item->status, + 'color_token' => $item->due_date?->isPast() ? 'danger' : 'warning', + 'project' => $item->project?->only(['id', 'title']), + 'sprint' => $item->sprint?->only(['id', 'title']), + 'preview' => ['اولویت' => $item->priority, 'وضعیت' => $item->status], + 'target_url' => $item->sprint_id ? "/sprints/{$item->sprint_id}/workspace?tab=action-items" : '/meetings', + ])); + } + + return $events->sortBy('start_at')->values(); + } +} diff --git a/backend/app/Services/DashboardService.php b/backend/app/Services/DashboardService.php index ca2bce4..d9e9f77 100644 --- a/backend/app/Services/DashboardService.php +++ b/backend/app/Services/DashboardService.php @@ -2,11 +2,14 @@ namespace App\Services; -use App\Models\Project; -use App\Models\Task; -use App\Models\Meeting; +use App\Models\ActionItem; use App\Models\ActivityLog; +use App\Models\Blocker; +use App\Models\Meeting; +use App\Models\Project; use App\Models\Sprint; +use App\Models\Task; +use App\Models\User; use Carbon\Carbon; class DashboardService @@ -34,7 +37,7 @@ class DashboardService ->orderBy('due_date') ->limit(10) ->get() - ->map(fn($t) => [ + ->map(fn ($t) => [ 'id' => $t->id, 'title' => $t->title, 'project' => $t->project?->title, @@ -46,7 +49,7 @@ class DashboardService ->orderBy('created_at', 'desc') ->limit(10) ->get() - ->map(fn($log) => [ + ->map(fn ($log) => [ 'id' => $log->id, 'description' => $log->description, 'user' => $log->user?->name ?? '?', @@ -67,7 +70,7 @@ class DashboardService if ($user) { $sprint = Sprint::with(['project', 'tasks.assignee']) ->where('status', 'active') - ->whereHas('members', fn($query) => $query->where('users.id', $user->id)) + ->whereHas('members', fn ($query) => $query->where('users.id', $user->id)) ->orderBy('end_date') ->first(); @@ -84,7 +87,7 @@ class DashboardService 'my_tasks' => $sprint->tasks ->where('assignee_id', $user->id) ->values() - ->map(fn($task) => [ + ->map(fn ($task) => [ 'id' => $task->id, 'title' => $task->title, 'status' => $task->status, @@ -94,7 +97,7 @@ class DashboardService } $todayMeetings = Meeting::whereDate('date', Carbon::today())->count(); - $teamMembers = \App\Models\User::count(); + $teamMembers = User::count(); $projectProgresses = Project::where('is_archived', false) ->select(['id', 'title', 'progress', 'status']) @@ -159,4 +162,161 @@ class DashboardService 'monthly_tasks' => $monthlyTasks, ]; } + + public function getMonitoring($user = null): array + { + $today = Carbon::today(); + $weekEnd = $today->copy()->addDays(7); + + $projects = Project::query() + ->where('is_archived', false) + ->withCount([ + 'tasks as total_tasks_count', + 'tasks as completed_tasks_count' => fn ($query) => $query->where('status', 'done'), + 'tasks as overdue_tasks_count' => fn ($query) => $query + ->whereDate('due_date', '<', $today) + ->whereNotIn('status', ['done', 'canceled']), + 'tasks as blocked_tasks_count' => fn ($query) => $query->where('is_blocked', true), + ]) + ->orderByRaw("CASE WHEN risk_level = 'critical' THEN 0 WHEN risk_level = 'high' THEN 1 ELSE 2 END") + ->limit(8) + ->get() + ->map(function (Project $project) { + $health = 'on_track'; + if ($project->risk_level === 'critical' || $project->overdue_tasks_count >= 5) { + $health = 'off_track'; + } elseif ($project->risk_level === 'high' || $project->overdue_tasks_count > 0 || $project->blocked_tasks_count > 0) { + $health = 'at_risk'; + } + + return [ + 'id' => $project->id, + 'title' => $project->title, + 'status' => $project->status, + 'risk_level' => $project->risk_level, + 'progress' => $project->progress, + 'health' => $health, + 'total_tasks' => $project->total_tasks_count, + 'completed_tasks' => $project->completed_tasks_count, + 'overdue_tasks' => $project->overdue_tasks_count, + 'blocked_tasks' => $project->blocked_tasks_count, + ]; + }); + + $sprints = Sprint::with(['project:id,title']) + ->withCount([ + 'tasks as total_tasks_count', + 'tasks as completed_tasks_count' => fn ($query) => $query->where('status', 'done'), + 'tasks as blocked_tasks_count' => fn ($query) => $query->where('is_blocked', true), + ]) + ->where('status', 'active') + ->orderBy('end_date') + ->limit(6) + ->get() + ->map(function (Sprint $sprint) use ($today) { + $progress = $sprint->total_tasks_count > 0 + ? round(($sprint->completed_tasks_count / $sprint->total_tasks_count) * 100) + : 0; + $elapsed = max($sprint->start_date?->diffInDays($today, false) ?? 0, 0); + $duration = max($sprint->start_date?->diffInDays($sprint->end_date) ?? 1, 1); + $expected = min(100, round(($elapsed / $duration) * 100)); + $health = $sprint->blocked_tasks_count > 1 || $progress + 20 < $expected ? 'at_risk' : 'on_track'; + if ($sprint->end_date?->isPast() && $progress < 100) { + $health = 'off_track'; + } + + return [ + 'id' => $sprint->id, + 'title' => $sprint->title, + 'project' => $sprint->project?->title, + 'progress' => $progress, + 'expected_progress' => $expected, + 'remaining_days' => max($today->diffInDays($sprint->end_date, false), 0), + 'health' => $health, + 'blocked_tasks' => $sprint->blocked_tasks_count, + ]; + }); + + $workload = User::query() + ->where('status', 'active') + ->withCount([ + 'tasks as open_tasks_count' => fn ($query) => $query->whereNotIn('status', ['done', 'canceled']), + 'tasks as overdue_tasks_count' => fn ($query) => $query + ->whereDate('due_date', '<', $today) + ->whereNotIn('status', ['done', 'canceled']), + ]) + ->orderByDesc('open_tasks_count') + ->limit(8) + ->get(['id', 'name']) + ->map(fn (User $member) => [ + 'id' => $member->id, + 'name' => $member->name, + 'open_tasks' => $member->open_tasks_count, + 'overdue_tasks' => $member->overdue_tasks_count, + 'load' => $member->open_tasks_count >= 10 ? 'overloaded' : ($member->open_tasks_count >= 6 ? 'high' : 'balanced'), + ]); + + $attention = collect(); + Task::with('project:id,title') + ->whereNotIn('status', ['done', 'canceled']) + ->where(function ($query) use ($today, $weekEnd) { + $query->where('is_blocked', true) + ->orWhereDate('due_date', '<', $today) + ->orWhereBetween('due_date', [$today, $weekEnd]); + }) + ->orderBy('due_date') + ->limit(10) + ->get() + ->each(fn (Task $task) => $attention->push([ + 'id' => "task-{$task->id}", + 'type' => $task->is_blocked ? 'blocker' : ($task->due_date?->isPast() ? 'overdue' : 'deadline'), + 'title' => $task->title, + 'context' => $task->project?->title, + 'date' => $task->due_date?->format('Y-m-d'), + 'target_url' => "/tasks?preview={$task->id}", + ])); + + $upcomingMeetings = Meeting::with(['project:id,title', 'sprint:id,title']) + ->where(function ($query) use ($today) { + $query->whereDate('date', '>', $today) + ->orWhere(function ($sameDay) use ($today) { + $sameDay->whereDate('date', $today)->whereTime('start_time', '>=', now()->format('H:i:s')); + }); + }) + ->whereNotIn('status', ['cancelled', 'completed']) + ->orderBy('date') + ->orderBy('start_time') + ->limit(5) + ->get() + ->map(fn (Meeting $meeting) => [ + 'id' => $meeting->id, + 'title' => $meeting->title, + 'date' => $meeting->date?->format('Y-m-d'), + 'time' => $meeting->start_time ? substr($meeting->start_time, 0, 5) : null, + 'project' => $meeting->project?->title, + 'sprint' => $meeting->sprint?->title, + 'target_url' => "/meetings/{$meeting->id}/workspace", + ]); + + return [ + 'generated_at' => now()->toIso8601String(), + 'summary' => [ + 'active_projects' => Project::where('is_archived', false)->where('status', '!=', 'done')->count(), + 'projects_at_risk' => $projects->whereIn('health', ['at_risk', 'off_track'])->count(), + 'open_tasks' => Task::whereNotIn('status', ['done', 'canceled'])->count(), + 'overdue_tasks' => Task::whereDate('due_date', '<', $today)->whereNotIn('status', ['done', 'canceled'])->count(), + 'blocked_tasks' => Task::where('is_blocked', true)->whereNotIn('status', ['done', 'canceled'])->count(), + 'active_sprints' => Sprint::where('status', 'active')->count(), + 'meetings_today' => Meeting::whereDate('date', $today)->where('status', '!=', 'cancelled')->count(), + 'overdue_action_items' => ActionItem::whereDate('due_date', '<', $today)->whereNotIn('status', ['completed', 'cancelled'])->count(), + 'open_blockers' => Blocker::whereNotIn('status', ['resolved', 'closed'])->count(), + ], + 'projects' => $projects, + 'sprints' => $sprints, + 'workload' => $workload, + 'attention' => $attention->values(), + 'upcoming_meetings' => $upcomingMeetings, + 'trends' => $this->getChartData()['monthlyTasks'], + ]; + } } diff --git a/backend/app/Services/ProjectProgressService.php b/backend/app/Services/ProjectProgressService.php index 41014ef..d1570e9 100644 --- a/backend/app/Services/ProjectProgressService.php +++ b/backend/app/Services/ProjectProgressService.php @@ -14,6 +14,7 @@ class ProjectProgressService if ($totalTasks === 0) { $project->update(['progress' => 0]); + return 0; } diff --git a/backend/app/Services/ReportService.php b/backend/app/Services/ReportService.php index 38ad7f0..752775d 100644 --- a/backend/app/Services/ReportService.php +++ b/backend/app/Services/ReportService.php @@ -2,10 +2,10 @@ namespace App\Services; -use App\Models\Project; -use App\Models\Task; -use App\Models\Sprint; use App\Models\ActivityLog; +use App\Models\Project; +use App\Models\Sprint; +use App\Models\Task; use App\Models\User; use Carbon\Carbon; @@ -13,12 +13,13 @@ class ReportService { protected function applyDateFilters($query, $filters, $dateField = 'created_at') { - if (!empty($filters['date_from'])) { + if (! empty($filters['date_from'])) { $query->where($dateField, '>=', $filters['date_from']); } - if (!empty($filters['date_to'])) { + if (! empty($filters['date_to'])) { $query->where($dateField, '<=', $filters['date_to']); } + return $query; } @@ -26,17 +27,17 @@ class ReportService { $query = Project::query(); - if (!empty($filters['status'])) { + if (! empty($filters['status'])) { $query->where('status', $filters['status']); } - if (!empty($filters['project_id'])) { + if (! empty($filters['project_id'])) { $query->where('id', $filters['project_id']); } $projects = $query->get(); $grouped = $projects->groupBy('status'); - return $grouped->map(fn($items, $status) => [ + return $grouped->map(fn ($items, $status) => [ 'status' => $status === 'planning' ? 'برنامهریزی' : ($status === 'in_progress' ? 'در حال انجام' : ($status === 'done' ? 'تکمیل شده' : ($status === 'on_hold' ? 'متوقف' : $status))), 'count' => $items->count(), 'label' => $status, @@ -52,17 +53,17 @@ class ReportService $query = $this->applyDateFilters($query, $filters, 'due_date'); - if (!empty($filters['project_id'])) { + if (! empty($filters['project_id'])) { $query->where('project_id', $filters['project_id']); } - if (!empty($filters['user_id'])) { + if (! empty($filters['user_id'])) { $query->where('assignee_id', $filters['user_id']); } - if (!empty($filters['status'])) { + if (! empty($filters['status'])) { $query->where('status', $filters['status']); } - return $query->orderBy('due_date')->get()->map(fn($t) => [ + return $query->orderBy('due_date')->get()->map(fn ($t) => [ 'id' => $t->id, 'title' => $t->title, 'project' => ['title' => $t->project?->title], @@ -79,15 +80,15 @@ class ReportService public function teamPerformance($filters = []) { $query = User::with(['tasks' => function ($q) use ($filters) { - if (!empty($filters['date_from'])) { + if (! empty($filters['date_from'])) { $q->where('created_at', '>=', $filters['date_from']); } - if (!empty($filters['date_to'])) { + if (! empty($filters['date_to'])) { $q->where('created_at', '<=', $filters['date_to']); } }]); - if (!empty($filters['user_id'])) { + if (! empty($filters['user_id'])) { $query->where('id', $filters['user_id']); } @@ -98,6 +99,7 @@ class ReportService $completedTasks = $user->tasks->where('status', 'done')->count(); $delayedTasks = $user->tasks->where('due_date', '<', Carbon::now())->where('status', '!=', 'done')->count(); $inProgress = $user->tasks->whereNotIn('status', ['done', 'canceled'])->count(); + return [ 'name' => $user->name, 'completed' => $completedTasks, @@ -112,7 +114,7 @@ class ReportService { $query = User::query(); - if (!empty($filters['user_id'])) { + if (! empty($filters['user_id'])) { $query->where('id', $filters['user_id']); } @@ -140,10 +142,10 @@ class ReportService { $query = Sprint::with(['project:id,title', 'tasks']); - if (!empty($filters['project_id'])) { + if (! empty($filters['project_id'])) { $query->where('project_id', $filters['project_id']); } - if (!empty($filters['status'])) { + if (! empty($filters['status'])) { $query->where('status', $filters['status']); } @@ -152,6 +154,7 @@ class ReportService return $sprints->map(function ($sprint) { $totalTasks = $sprint->tasks->count(); $completedTasks = $sprint->tasks->where('status', 'done')->count(); + return [ 'id' => $sprint->id, 'title' => $sprint->title, @@ -171,13 +174,13 @@ class ReportService $query = Task::with('project'); $query = $this->applyDateFilters($query, $filters); - if (!empty($filters['project_id'])) { + if (! empty($filters['project_id'])) { $query->where('project_id', $filters['project_id']); } - if (!empty($filters['user_id'])) { + if (! empty($filters['user_id'])) { $query->where('assignee_id', $filters['user_id']); } - if (!empty($filters['status'])) { + if (! empty($filters['status'])) { $query->where('status', $filters['status']); } @@ -185,6 +188,7 @@ class ReportService return $tasks->map(function ($task) { $diff = ($task->estimated_time ?? 0) - ($task->actual_time ?? 0); + return [ 'id' => $task->id, 'title' => $task->title, @@ -205,17 +209,17 @@ class ReportService $query = ActivityLog::with('user'); $query = $this->applyDateFilters($query, $filters); - if (!empty($filters['project_id'])) { + if (! empty($filters['project_id'])) { $query->where('project_id', $filters['project_id']); } - if (!empty($filters['user_id'])) { + if (! empty($filters['user_id'])) { $query->where('user_id', $filters['user_id']); } - if (!empty($filters['action'])) { + if (! empty($filters['action'])) { $query->where('action', $filters['action']); } - return $query->orderBy('created_at', 'desc')->limit(50)->get()->map(fn($log) => [ + return $query->orderBy('created_at', 'desc')->limit(50)->get()->map(fn ($log) => [ 'id' => $log->id, 'description' => $log->description, 'user' => $log->user?->name ?? '?', @@ -229,14 +233,14 @@ class ReportService { $query = Project::whereIn('risk_level', ['high', 'critical'])->where('is_archived', false); - if (!empty($filters['status'])) { + if (! empty($filters['status'])) { $query->where('status', $filters['status']); } - if (!empty($filters['project_id'])) { + if (! empty($filters['project_id'])) { $query->where('id', $filters['project_id']); } - return $query->with('projectManager:id,name')->get()->map(fn($p) => [ + return $query->with('projectManager:id,name')->get()->map(fn ($p) => [ 'id' => $p->id, 'title' => $p->title, 'risk_level' => $p->risk_level, diff --git a/backend/app/Services/ResourceAccessService.php b/backend/app/Services/ResourceAccessService.php new file mode 100644 index 0000000..fb0fab7 --- /dev/null +++ b/backend/app/Services/ResourceAccessService.php @@ -0,0 +1,163 @@ +loadMissing('roles.permissions'); + + return $user->roles->contains('name', 'admin'); + } + + public function projects(User $user): Builder + { + $query = Project::query(); + if ($this->canSeeAll($user)) { + return $query; + } + + return $query->where(function (Builder $scope) use ($user) { + $scope->where('project_manager_id', $user->id) + ->orWhere('created_by', $user->id) + ->orWhereHas('members', fn (Builder $members) => $members->where('users.id', $user->id)); + }); + } + + public function tasks(User $user): Builder + { + $query = Task::query(); + if ($this->canSeeAll($user)) { + return $query; + } + + return $query->where(function (Builder $scope) use ($user) { + $scope->where('assignee_id', $user->id) + ->orWhere('reporter_id', $user->id) + ->orWhere('created_by', $user->id) + ->orWhereIn('project_id', $this->projects($user)->select('projects.id')); + }); + } + + public function sprints(User $user): Builder + { + $query = Sprint::query(); + if ($this->canSeeAll($user)) { + return $query; + } + + return $query->where(function (Builder $scope) use ($user) { + $scope->where('created_by', $user->id) + ->orWhereHas('members', fn (Builder $members) => $members->where('users.id', $user->id)) + ->orWhereIn('project_id', $this->projects($user)->select('projects.id')); + }); + } + + public function meetings(User $user): Builder + { + $query = Meeting::query(); + if ($this->canSeeAll($user)) { + return $query; + } + + return $query->where(function (Builder $scope) use ($user) { + $scope->where('created_by', $user->id) + ->orWhere('owner_id', $user->id) + ->orWhere('facilitator_id', $user->id) + ->orWhereHas('participants', fn (Builder $participants) => $participants->where('users.id', $user->id)) + ->orWhereIn('project_id', $this->projects($user)->select('projects.id')); + }); + } + + public function comments(User $user): Builder + { + $query = Comment::query(); + if ($this->canSeeAll($user)) { + return $query; + } + + return $query->where(function (Builder $scope) use ($user) { + $scope->where('user_id', $user->id) + ->orWhere(function (Builder $tasks) use ($user) { + $tasks->where('commentable_type', Task::class) + ->whereIn('commentable_id', $this->tasks($user)->select('tasks.id')); + }) + ->orWhere(function (Builder $projects) use ($user) { + $projects->where('commentable_type', Project::class) + ->whereIn('commentable_id', $this->projects($user)->select('projects.id')); + }) + ->orWhere(function (Builder $meetings) use ($user) { + $meetings->where('commentable_type', Meeting::class) + ->whereIn('commentable_id', $this->meetings($user)->select('meetings.id')); + }); + }); + } + + public function files(User $user): Builder + { + $query = File::query(); + if ($this->canSeeAll($user)) { + return $query; + } + + return $query->where(function (Builder $scope) use ($user) { + $scope->where('user_id', $user->id) + ->orWhere(function (Builder $projects) use ($user) { + $projects->where('fileable_type', Project::class) + ->whereIn('fileable_id', $this->projects($user)->select('projects.id')); + }) + ->orWhere(function (Builder $tasks) use ($user) { + $tasks->where('fileable_type', Task::class) + ->whereIn('fileable_id', $this->tasks($user)->select('tasks.id')); + }) + ->orWhere(function (Builder $meetings) use ($user) { + $meetings->where('fileable_type', Meeting::class) + ->whereIn('fileable_id', $this->meetings($user)->select('meetings.id')); + }) + ->orWhere(function (Builder $comments) use ($user) { + $comments->where('fileable_type', Comment::class) + ->whereIn('fileable_id', $this->comments($user)->select('comments.id')); + }); + }); + } + + public function canAccessProject(User $user, Project $project): bool + { + return $this->projects($user)->whereKey($project->getKey())->exists(); + } + + public function canAccessTask(User $user, Task $task): bool + { + return $this->tasks($user)->whereKey($task->getKey())->exists(); + } + + public function canAccessSprint(User $user, Sprint $sprint): bool + { + return $this->sprints($user)->whereKey($sprint->getKey())->exists(); + } + + public function canAccessMeeting(User $user, Meeting $meeting): bool + { + return $this->meetings($user)->whereKey($meeting->getKey())->exists(); + } + + public function canAccessComment(User $user, Comment $comment): bool + { + return $this->comments($user)->whereKey($comment->getKey())->exists(); + } + + public function canAccessFile(User $user, File $file): bool + { + return $this->files($user)->whereKey($file->getKey())->exists(); + } +} diff --git a/backend/app/Services/WorkloadService.php b/backend/app/Services/WorkloadService.php index 4ae10cd..bd3f241 100644 --- a/backend/app/Services/WorkloadService.php +++ b/backend/app/Services/WorkloadService.php @@ -2,8 +2,8 @@ namespace App\Services; -use App\Models\User; use App\Models\Task; +use App\Models\User; use Carbon\Carbon; class WorkloadService diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index 4a38840..b9333d5 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -1,8 +1,9 @@ append(SecurityHeaders::class); $middleware->alias([ 'permission' => EnsureUserHasPermission::class, + 'resource.access' => EnsureResourceAccess::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/backend/config/database.php b/backend/config/database.php index 64709ce..2ffdb97 100644 --- a/backend/config/database.php +++ b/backend/config/database.php @@ -64,6 +64,28 @@ return [ ]) : [], ], + 'migration_sqlite' => [ + 'driver' => 'sqlite', + 'database' => env('MIGRATION_SQLITE_PATH', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => true, + ], + + 'migration_mysql' => [ + 'driver' => 'mysql', + 'host' => env('MIGRATION_MYSQL_HOST', '127.0.0.1'), + 'port' => env('MIGRATION_MYSQL_PORT', '3306'), + 'database' => env('MIGRATION_MYSQL_DATABASE', 'pm_migration'), + 'username' => env('MIGRATION_MYSQL_USERNAME', 'root'), + 'password' => env('MIGRATION_MYSQL_PASSWORD', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => 'InnoDB', + ], + 'mariadb' => [ 'driver' => 'mariadb', 'url' => env('DB_URL'), diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php index 379cf8c..56391c6 100644 --- a/backend/config/sanctum.php +++ b/backend/config/sanctum.php @@ -15,7 +15,7 @@ return [ 'guard' => ['web'], - 'expiration' => null, + 'expiration' => (int) env('SANCTUM_TOKEN_EXPIRATION', 480), 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), diff --git a/backend/database/migrations/2026_06_28_000000_sync_system_roles.php b/backend/database/migrations/2026_06_28_000000_sync_system_roles.php index c8c3456..45eb382 100644 --- a/backend/database/migrations/2026_06_28_000000_sync_system_roles.php +++ b/backend/database/migrations/2026_06_28_000000_sync_system_roles.php @@ -35,13 +35,13 @@ return new class extends Migration $roleIds = DB::table('roles')->pluck('id', 'name'); $permissionIds = DB::table('permissions')->pluck('id', 'name'); $syncRole = function (string $roleName, array $modules, array $actions) use ($roleIds, $permissionIds, $now) { - if (!$roleIds->has($roleName)) { + if (! $roleIds->has($roleName)) { return; } foreach ($permissionIds as $permissionName => $permissionId) { [$module, $action] = explode('.', $permissionName); - if (!in_array($module, $modules, true) || !in_array($action, $actions, true)) { + if (! in_array($module, $modules, true) || ! in_array($action, $actions, true)) { continue; } diff --git a/backend/database/migrations/2026_06_28_000001_remove_legacy_team_member_role.php b/backend/database/migrations/2026_06_28_000001_remove_legacy_team_member_role.php index b23a6d9..da5a972 100644 --- a/backend/database/migrations/2026_06_28_000001_remove_legacy_team_member_role.php +++ b/backend/database/migrations/2026_06_28_000001_remove_legacy_team_member_role.php @@ -10,7 +10,7 @@ return new class extends Migration $legacyId = DB::table('roles')->where('name', 'team_member')->value('id'); $specialistId = DB::table('roles')->where('name', 'specialist')->value('id'); - if (!$legacyId || !$specialistId) { + if (! $legacyId || ! $specialistId) { return; } diff --git a/backend/database/migrations/2026_06_28_000002_backfill_user_status_and_roles.php b/backend/database/migrations/2026_06_28_000002_backfill_user_status_and_roles.php index d3748a1..ff78b7a 100644 --- a/backend/database/migrations/2026_06_28_000002_backfill_user_status_and_roles.php +++ b/backend/database/migrations/2026_06_28_000002_backfill_user_status_and_roles.php @@ -10,7 +10,7 @@ return new class extends Migration DB::table('users')->whereNull('status')->update(['status' => 'active']); $specialistId = DB::table('roles')->where('name', 'specialist')->value('id'); - if (!$specialistId) { + if (! $specialistId) { return; } diff --git a/backend/database/migrations/2026_06_28_000003_enhance_sprints.php b/backend/database/migrations/2026_06_28_000003_enhance_sprints.php index 59f1917..799a8a7 100644 --- a/backend/database/migrations/2026_06_28_000003_enhance_sprints.php +++ b/backend/database/migrations/2026_06_28_000003_enhance_sprints.php @@ -10,13 +10,13 @@ return new class extends Migration public function up(): void { Schema::table('sprints', function (Blueprint $table) { - if (!Schema::hasColumn('sprints', 'capacity_hours')) { + if (! Schema::hasColumn('sprints', 'capacity_hours')) { $table->decimal('capacity_hours', 8, 2)->nullable()->after('goal'); } - if (!Schema::hasColumn('sprints', 'completed_at')) { + if (! Schema::hasColumn('sprints', 'completed_at')) { $table->timestamp('completed_at')->nullable()->after('status'); } - if (!Schema::hasColumn('sprints', 'completion_summary')) { + if (! Schema::hasColumn('sprints', 'completion_summary')) { $table->json('completion_summary')->nullable()->after('completed_at'); } }); diff --git a/backend/database/migrations/2026_06_28_000004_add_organization_metadata_to_departments_table.php b/backend/database/migrations/2026_06_28_000004_add_organization_metadata_to_departments_table.php index 0de4637..77e4071 100644 --- a/backend/database/migrations/2026_06_28_000004_add_organization_metadata_to_departments_table.php +++ b/backend/database/migrations/2026_06_28_000004_add_organization_metadata_to_departments_table.php @@ -9,10 +9,10 @@ return new class extends Migration public function up(): void { Schema::table('departments', function (Blueprint $table) { - if (!Schema::hasColumn('departments', 'type')) { + if (! Schema::hasColumn('departments', 'type')) { $table->string('type')->default('department')->after('description'); } - if (!Schema::hasColumn('departments', 'manager_changed_at')) { + if (! Schema::hasColumn('departments', 'manager_changed_at')) { $table->timestamp('manager_changed_at')->nullable()->after('manager_id'); } }); diff --git a/backend/database/migrations/2026_06_28_000006_add_department_id_to_projects_table.php b/backend/database/migrations/2026_06_28_000006_add_department_id_to_projects_table.php index 90ab96b..abf9e89 100644 --- a/backend/database/migrations/2026_06_28_000006_add_department_id_to_projects_table.php +++ b/backend/database/migrations/2026_06_28_000006_add_department_id_to_projects_table.php @@ -9,7 +9,7 @@ return new class extends Migration public function up(): void { Schema::table('projects', function (Blueprint $table) { - if (!Schema::hasColumn('projects', 'department_id')) { + if (! Schema::hasColumn('projects', 'department_id')) { $table->foreignId('department_id')->nullable()->after('project_manager_id')->constrained('departments')->onDelete('set null'); } }); diff --git a/backend/database/migrations/2026_06_28_000007_add_blocker_fields_to_tasks_table.php b/backend/database/migrations/2026_06_28_000007_add_blocker_fields_to_tasks_table.php index bd649ca..ee7cb35 100644 --- a/backend/database/migrations/2026_06_28_000007_add_blocker_fields_to_tasks_table.php +++ b/backend/database/migrations/2026_06_28_000007_add_blocker_fields_to_tasks_table.php @@ -9,10 +9,10 @@ return new class extends Migration public function up(): void { Schema::table('tasks', function (Blueprint $table) { - if (!Schema::hasColumn('tasks', 'blocker_type')) { + if (! Schema::hasColumn('tasks', 'blocker_type')) { $table->string('blocker_type')->nullable()->after('status'); } - if (!Schema::hasColumn('tasks', 'blocker_note')) { + if (! Schema::hasColumn('tasks', 'blocker_note')) { $table->text('blocker_note')->nullable()->after('blocker_type'); } }); diff --git a/backend/database/migrations/2026_06_28_000008_add_notifiable_fields_to_notifications_table.php b/backend/database/migrations/2026_06_28_000008_add_notifiable_fields_to_notifications_table.php index d6774de..c834405 100644 --- a/backend/database/migrations/2026_06_28_000008_add_notifiable_fields_to_notifications_table.php +++ b/backend/database/migrations/2026_06_28_000008_add_notifiable_fields_to_notifications_table.php @@ -9,10 +9,10 @@ return new class extends Migration public function up(): void { Schema::table('notifications', function (Blueprint $table) { - if (!Schema::hasColumn('notifications', 'notifiable_type')) { + if (! Schema::hasColumn('notifications', 'notifiable_type')) { $table->string('notifiable_type')->nullable()->after('type'); } - if (!Schema::hasColumn('notifications', 'notifiable_id')) { + if (! Schema::hasColumn('notifications', 'notifiable_id')) { $table->unsignedBigInteger('notifiable_id')->nullable()->after('notifiable_type'); } }); diff --git a/backend/database/migrations/2026_06_30_000001_add_type_to_settings_table.php b/backend/database/migrations/2026_06_30_000001_add_type_to_settings_table.php index 2332f83..adb9a23 100644 --- a/backend/database/migrations/2026_06_30_000001_add_type_to_settings_table.php +++ b/backend/database/migrations/2026_06_30_000001_add_type_to_settings_table.php @@ -9,7 +9,7 @@ return new class extends Migration public function up(): void { Schema::table('settings', function (Blueprint $table) { - if (!Schema::hasColumn('settings', 'type')) { + if (! Schema::hasColumn('settings', 'type')) { $table->string('type', 50)->nullable()->after('group'); } }); diff --git a/backend/database/migrations/2026_07_29_000001_add_workspaces_calendar_and_notification_lifecycle.php b/backend/database/migrations/2026_07_29_000001_add_workspaces_calendar_and_notification_lifecycle.php new file mode 100644 index 0000000..ee9cf20 --- /dev/null +++ b/backend/database/migrations/2026_07_29_000001_add_workspaces_calendar_and_notification_lifecycle.php @@ -0,0 +1,186 @@ +id(); + $table->string('key')->unique(); + $table->string('name'); + $table->string('default_title')->nullable(); + $table->unsignedSmallInteger('suggested_duration')->nullable(); + $table->text('agenda_template')->nullable(); + $table->text('minutes_template')->nullable(); + $table->json('participant_roles')->nullable(); + $table->json('recurrence_rule')->nullable(); + $table->json('settings')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + + $now = now(); + DB::table('meeting_types')->insert([ + ['key' => 'sprint_planning', 'name' => 'برنامهریزی اسپرینت', 'default_title' => 'جلسه برنامهریزی اسپرینت', 'suggested_duration' => 90, 'agenda_template' => "هدف اسپرینت\nظرفیت تیم\nانتخاب اقلام بکلاگ\nریسکها و وابستگیها", 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now], + ['key' => 'daily_standup', 'name' => 'جلسه روزانه', 'default_title' => 'Daily Stand-up', 'suggested_duration' => 15, 'agenda_template' => "کارهای انجامشده\nبرنامه امروز\nموانع و نیاز به کمک", 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => json_encode(['frequency' => 'weekdays', 'until' => 'sprint_end']), 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now], + ['key' => 'backlog_refinement', 'name' => 'پالایش بکلاگ', 'default_title' => 'پالایش بکلاگ', 'suggested_duration' => 60, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now], + ['key' => 'sprint_review', 'name' => 'بازبینی اسپرینت', 'default_title' => 'Sprint Review', 'suggested_duration' => 60, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now], + ['key' => 'sprint_retrospective', 'name' => 'بازنگری اسپرینت', 'default_title' => 'Sprint Retrospective', 'suggested_duration' => 60, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now], + ['key' => 'adhoc', 'name' => 'جلسه موردی اسپرینت', 'default_title' => 'جلسه موردی', 'suggested_duration' => 30, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now], + ['key' => 'blocker_resolution', 'name' => 'رفع مانع', 'default_title' => 'جلسه رفع مانع', 'suggested_duration' => 30, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now], + ['key' => 'scope_change', 'name' => 'تغییر محدوده', 'default_title' => 'بررسی تغییر محدوده', 'suggested_duration' => 45, 'agenda_template' => null, 'minutes_template' => null, 'participant_roles' => null, 'recurrence_rule' => null, 'settings' => null, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now], + ]); + + Schema::table('meetings', function (Blueprint $table) { + $table->foreignId('sprint_id')->nullable()->after('project_id')->constrained()->nullOnDelete(); + $table->foreignId('meeting_type_id')->nullable()->after('sprint_id')->constrained()->nullOnDelete(); + $table->foreignId('owner_id')->nullable()->after('created_by')->constrained('users')->nullOnDelete(); + $table->foreignId('facilitator_id')->nullable()->after('owner_id')->constrained('users')->nullOnDelete(); + $table->string('status')->default('scheduled')->after('meeting_type'); + $table->text('objective')->nullable()->after('status'); + $table->text('summary')->nullable()->after('notes'); + $table->unsignedSmallInteger('reminder_minutes')->nullable()->after('summary'); + $table->json('recurrence_rule')->nullable()->after('reminder_minutes'); + $table->timestamp('started_at')->nullable()->after('recurrence_rule'); + $table->timestamp('completed_at')->nullable()->after('started_at'); + $table->index(['sprint_id', 'date']); + $table->index(['status', 'date']); + }); + + Schema::create('meeting_relations', function (Blueprint $table) { + $table->id(); + $table->foreignId('meeting_id')->constrained()->cascadeOnDelete(); + $table->string('related_type'); + $table->unsignedBigInteger('related_id'); + $table->string('relation_type')->default('related'); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->unique(['meeting_id', 'related_type', 'related_id', 'relation_type'], 'meeting_relation_unique'); + $table->index(['related_type', 'related_id']); + }); + + Schema::create('decisions', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('status')->default('proposed'); + $table->string('decision_type')->nullable(); + $table->foreignId('project_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('sprint_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('decision_maker_id')->nullable()->constrained('users')->nullOnDelete(); + $table->text('rationale')->nullable(); + $table->text('impact')->nullable(); + $table->date('effective_date')->nullable(); + $table->date('review_date')->nullable(); + $table->foreignId('created_by')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('action_items', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('status')->default('open'); + $table->string('priority')->default('medium'); + $table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('project_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('sprint_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('decision_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('converted_to_task_id')->nullable()->constrained('tasks')->nullOnDelete(); + $table->date('due_date')->nullable(); + $table->text('completion_notes')->nullable(); + $table->foreignId('created_by')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + $table->softDeletes(); + $table->index(['status', 'due_date']); + }); + + Schema::create('blockers', function (Blueprint $table) { + $table->id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->string('severity')->default('medium'); + $table->string('status')->default('open'); + $table->foreignId('owner_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('raised_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('project_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('sprint_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('task_id')->nullable()->constrained()->nullOnDelete(); + $table->date('due_date')->nullable(); + $table->text('resolution')->nullable(); + $table->timestamp('resolved_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->index(['status', 'severity']); + }); + + Schema::create('sprint_scope_changes', function (Blueprint $table) { + $table->id(); + $table->foreignId('sprint_id')->constrained()->cascadeOnDelete(); + $table->foreignId('meeting_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('task_id')->nullable()->constrained()->nullOnDelete(); + $table->string('change_type'); + $table->json('previous_state')->nullable(); + $table->json('new_state')->nullable(); + $table->text('reason')->nullable(); + $table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->decimal('capacity_impact', 8, 2)->nullable(); + $table->text('goal_impact')->nullable(); + $table->timestamps(); + }); + + Schema::table('notifications', function (Blueprint $table) { + $table->timestamp('archived_at')->nullable()->after('dismissed_at')->index(); + $table->softDeletes(); + }); + + if (Schema::hasTable('settings')) { + DB::table('settings')->insertOrIgnore([ + ['key' => 'working_days', 'value' => json_encode(['saturday', 'sunday', 'monday', 'tuesday', 'wednesday']), 'group' => 'calendar', 'type' => 'list', 'created_at' => $now, 'updated_at' => $now], + ['key' => 'organization_timezone', 'value' => 'Asia/Tehran', 'group' => 'calendar', 'type' => 'string', 'created_at' => $now, 'updated_at' => $now], + ['key' => 'default_meeting_reminder_minutes', 'value' => '30', 'group' => 'meeting', 'type' => 'number', 'created_at' => $now, 'updated_at' => $now], + ['key' => 'meeting_effectiveness_enabled', 'value' => 'true', 'group' => 'meeting', 'type' => 'boolean', 'created_at' => $now, 'updated_at' => $now], + ['key' => 'sprint_health_override_requires_reason', 'value' => 'true', 'group' => 'sprint', 'type' => 'boolean', 'created_at' => $now, 'updated_at' => $now], + ]); + } + } + + public function down(): void + { + Schema::table('notifications', function (Blueprint $table) { + $table->dropSoftDeletes(); + $table->dropColumn('archived_at'); + }); + + Schema::dropIfExists('sprint_scope_changes'); + Schema::dropIfExists('blockers'); + Schema::dropIfExists('action_items'); + Schema::dropIfExists('decisions'); + Schema::dropIfExists('meeting_relations'); + + Schema::table('meetings', function (Blueprint $table) { + $table->dropForeign(['sprint_id']); + $table->dropForeign(['meeting_type_id']); + $table->dropForeign(['owner_id']); + $table->dropForeign(['facilitator_id']); + $table->dropColumn([ + 'sprint_id', 'meeting_type_id', 'owner_id', 'facilitator_id', 'status', + 'objective', 'summary', 'reminder_minutes', 'recurrence_rule', + 'started_at', 'completed_at', + ]); + }); + + Schema::dropIfExists('meeting_types'); + } +}; diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index ae5d577..d8d025e 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -4,7 +4,7 @@ namespace Database\Seeders; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; class DatabaseSeeder extends Seeder { @@ -12,22 +12,25 @@ class DatabaseSeeder extends Seeder public function run(): void { - DB::statement('PRAGMA foreign_keys = OFF'); + Schema::disableForeignKeyConstraints(); - $this->call([ - RolePermissionSeeder::class, - UserSeeder::class, - ProjectSeeder::class, - TaskSeeder::class, - SprintSeeder::class, - BacklogSeeder::class, - MeetingSeeder::class, - CommentSeeder::class, - ActivityLogSeeder::class, - NotificationSeeder::class, - SettingSeeder::class, - ]); - - DB::statement('PRAGMA foreign_keys = ON'); + try { + $this->call([ + RolePermissionSeeder::class, + UserSeeder::class, + ProjectSeeder::class, + TaskSeeder::class, + SprintSeeder::class, + BacklogSeeder::class, + MeetingTypeSeeder::class, + MeetingSeeder::class, + CommentSeeder::class, + ActivityLogSeeder::class, + NotificationSeeder::class, + SettingSeeder::class, + ]); + } finally { + Schema::enableForeignKeyConstraints(); + } } } diff --git a/backend/database/seeders/MeetingTypeSeeder.php b/backend/database/seeders/MeetingTypeSeeder.php new file mode 100644 index 0000000..ac761b8 --- /dev/null +++ b/backend/database/seeders/MeetingTypeSeeder.php @@ -0,0 +1,27 @@ + 'sprint_planning', 'name' => 'برنامهریزی اسپرینت', 'suggested_duration' => 90, 'default_title' => 'جلسه برنامهریزی اسپرینت', 'agenda_template' => "هدف اسپرینت\nظرفیت تیم\nانتخاب اقلام بکلاگ\nریسکها و وابستگیها"], + ['key' => 'daily_standup', 'name' => 'جلسه روزانه', 'suggested_duration' => 15, 'default_title' => 'Daily Stand-up', 'agenda_template' => "کارهای انجامشده\nبرنامه امروز\nموانع و نیاز به کمک", 'recurrence_rule' => ['frequency' => 'weekdays', 'until' => 'sprint_end']], + ['key' => 'backlog_refinement', 'name' => 'پالایش بکلاگ', 'suggested_duration' => 60, 'default_title' => 'پالایش بکلاگ'], + ['key' => 'sprint_review', 'name' => 'بازبینی اسپرینت', 'suggested_duration' => 60, 'default_title' => 'Sprint Review'], + ['key' => 'sprint_retrospective', 'name' => 'بازنگری اسپرینت', 'suggested_duration' => 60, 'default_title' => 'Sprint Retrospective'], + ['key' => 'adhoc', 'name' => 'جلسه موردی اسپرینت', 'suggested_duration' => 30, 'default_title' => 'جلسه موردی'], + ['key' => 'blocker_resolution', 'name' => 'رفع مانع', 'suggested_duration' => 30, 'default_title' => 'جلسه رفع مانع'], + ['key' => 'scope_change', 'name' => 'تغییر محدوده', 'suggested_duration' => 45, 'default_title' => 'بررسی تغییر محدوده'], + ]; + + foreach ($types as $type) { + MeetingType::updateOrCreate(['key' => $type['key']], $type + ['is_active' => true]); + } + } +} diff --git a/backend/database/seeders/RolePermissionSeeder.php b/backend/database/seeders/RolePermissionSeeder.php index adc19e3..34f9518 100644 --- a/backend/database/seeders/RolePermissionSeeder.php +++ b/backend/database/seeders/RolePermissionSeeder.php @@ -68,7 +68,7 @@ class RolePermissionSeeder extends Seeder ]; $permissions[] = [ 'name' => $name, - 'display_name' => $displayName . ' ' . $moduleDisplay[$module], + 'display_name' => $displayName.' '.$moduleDisplay[$module], 'guard_name' => 'web', 'module' => $module, 'created_at' => now(), diff --git a/backend/database/seeders/SettingSeeder.php b/backend/database/seeders/SettingSeeder.php index cf644b1..5612b8c 100644 --- a/backend/database/seeders/SettingSeeder.php +++ b/backend/database/seeders/SettingSeeder.php @@ -108,10 +108,50 @@ class SettingSeeder extends Seeder 'created_at' => $now, 'updated_at' => $now, ], + [ + 'key' => 'working_days', + 'value' => json_encode(['saturday', 'sunday', 'monday', 'tuesday', 'wednesday'], JSON_UNESCAPED_UNICODE), + 'group' => 'calendar', + 'type' => 'list', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'key' => 'organization_timezone', + 'value' => 'Asia/Tehran', + 'group' => 'calendar', + 'type' => 'string', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'key' => 'default_meeting_reminder_minutes', + 'value' => '30', + 'group' => 'meeting', + 'type' => 'number', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'key' => 'meeting_effectiveness_enabled', + 'value' => 'true', + 'group' => 'meeting', + 'type' => 'boolean', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'key' => 'sprint_health_override_requires_reason', + 'value' => 'true', + 'group' => 'sprint', + 'type' => 'boolean', + 'created_at' => $now, + 'updated_at' => $now, + ], ]; foreach ($settings as $setting) { - DB::table('settings')->insert($setting); + DB::table('settings')->updateOrInsert(['key' => $setting['key']], $setting); } } } diff --git a/backend/routes/api.php b/backend/routes/api.php index c54b118..1042a2e 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,32 +1,34 @@ middleware('throttle:login'); Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])->middleware('throttle:login'); +Route::post('/reset-password', [AuthController::class, 'resetPassword'])->middleware('throttle:sensitive'); -Route::middleware('auth:sanctum')->group(function () { +Route::middleware(['auth:sanctum', 'resource.access'])->group(function () { Route::post('/logout', [AuthController::class, 'logout']); Route::get('/user', [AuthController::class, 'user']); Route::put('/user/profile', [AuthController::class, 'updateProfile']); @@ -55,6 +57,8 @@ Route::middleware('auth:sanctum')->group(function () { Route::get('/dashboard/summary', [DashboardController::class, 'summary'])->middleware('permission:reports.view'); Route::get('/dashboard/charts', [DashboardController::class, 'charts'])->middleware('permission:reports.view'); + Route::get('/dashboard/monitoring', [DashboardController::class, 'monitoring'])->middleware('permission:reports.view'); + Route::get('/calendar/events', [CalendarController::class, 'events']); Route::get('/users', [UserController::class, 'index'])->middleware(['permission:team.view', 'throttle:sensitive']); Route::post('/users', [UserController::class, 'store'])->middleware(['permission:team.create', 'throttle:sensitive']); @@ -62,11 +66,11 @@ Route::middleware('auth:sanctum')->group(function () { Route::put('/users/{user}', [UserController::class, 'update'])->middleware(['permission:team.edit', 'throttle:sensitive']); Route::patch('/users/{user}', [UserController::class, 'update'])->middleware(['permission:team.edit', 'throttle:sensitive']); Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware(['permission:team.delete', 'throttle:sensitive']); - Route::patch('/users/{user}/status', [UserController::class, 'updateStatus']); - Route::put('/users/{user}/status', [UserController::class, 'updateStatus']); + Route::patch('/users/{user}/status', [UserController::class, 'updateStatus'])->middleware(['permission:team.edit', 'throttle:sensitive']); + Route::put('/users/{user}/status', [UserController::class, 'updateStatus'])->middleware(['permission:team.edit', 'throttle:sensitive']); Route::put('/users/{user}/roles', [UserController::class, 'syncRoles'])->middleware(['permission:roles.edit', 'throttle:sensitive']); - Route::get('/users/{user}/workload', [UserController::class, 'updateWorkload']); - Route::get('/users/{user}/tasks', [UserController::class, 'taskStats']); + Route::get('/users/{user}/workload', [UserController::class, 'updateWorkload'])->middleware('permission:team.view'); + Route::get('/users/{user}/tasks', [UserController::class, 'taskStats'])->middleware('permission:team.view'); Route::get('/projects', [ProjectController::class, 'index'])->middleware('permission:projects.view'); Route::post('/projects', [ProjectController::class, 'store'])->middleware('permission:projects.create'); @@ -92,15 +96,16 @@ Route::middleware('auth:sanctum')->group(function () { Route::get('/my-tasks', [TaskController::class, 'myTasks']); Route::get('/delayed-tasks', [TaskController::class, 'delayedTasks']); - Route::apiResource('tasks.checklists', ChecklistController::class)->shallow(); - Route::put('/checklists/{checklist}/toggle', [ChecklistController::class, 'toggleComplete']); + Route::apiResource('tasks.checklists', ChecklistController::class)->shallow()->middleware('permission:tasks.edit'); + Route::put('/checklists/{checklist}/toggle', [ChecklistController::class, 'toggleComplete'])->middleware('permission:tasks.edit'); - Route::apiResource('tasks.subtasks', SubtaskController::class)->shallow(); - Route::put('/subtasks/{subtask}/status', [SubtaskController::class, 'updateStatus']); + Route::apiResource('tasks.subtasks', SubtaskController::class)->shallow()->middleware('permission:tasks.edit'); + Route::put('/subtasks/{subtask}/status', [SubtaskController::class, 'updateStatus'])->middleware('permission:tasks.edit'); Route::get('/sprints', [SprintController::class, 'index'])->middleware('permission:sprints.view'); Route::post('/sprints', [SprintController::class, 'store'])->middleware('permission:sprints.create'); Route::get('/sprints/{sprint}', [SprintController::class, 'show'])->middleware('permission:sprints.view'); + Route::get('/sprints/{sprint}/workspace', [SprintController::class, 'workspace'])->middleware('permission:sprints.view'); Route::put('/sprints/{sprint}', [SprintController::class, 'update'])->middleware('permission:sprints.edit'); Route::patch('/sprints/{sprint}', [SprintController::class, 'update'])->middleware('permission:sprints.edit'); Route::delete('/sprints/{sprint}', [SprintController::class, 'destroy'])->middleware('permission:sprints.delete'); @@ -122,12 +127,19 @@ Route::middleware('auth:sanctum')->group(function () { Route::delete('/backlog-items/{backlogItem}', [BacklogController::class, 'destroy'])->middleware('permission:backlog.delete'); Route::post('/backlog-items/{backlogItem}/convert-to-task', [BacklogController::class, 'convertToTask'])->middleware('permission:backlog.edit'); + Route::get('/meeting-types', [MeetingController::class, 'types'])->middleware('permission:meetings.view'); Route::get('/meetings', [MeetingController::class, 'index'])->middleware('permission:meetings.view'); Route::post('/meetings', [MeetingController::class, 'store'])->middleware('permission:meetings.create'); Route::get('/meetings/{meeting}', [MeetingController::class, 'show'])->middleware('permission:meetings.view'); + Route::get('/meetings/{meeting}/workspace', [MeetingController::class, 'workspace'])->middleware('permission:meetings.view'); Route::put('/meetings/{meeting}', [MeetingController::class, 'update'])->middleware('permission:meetings.edit'); Route::patch('/meetings/{meeting}', [MeetingController::class, 'update'])->middleware('permission:meetings.edit'); Route::delete('/meetings/{meeting}', [MeetingController::class, 'destroy'])->middleware('permission:meetings.delete'); + Route::post('/meetings/{meeting}/start', [MeetingController::class, 'start'])->middleware('permission:meetings.edit'); + Route::post('/meetings/{meeting}/complete', [MeetingController::class, 'complete'])->middleware('permission:meetings.edit'); + Route::post('/meetings/{meeting}/decisions', [MeetingController::class, 'addDecision'])->middleware('permission:meetings.edit'); + Route::post('/meetings/{meeting}/structured-action-items', [MeetingController::class, 'addStructuredActionItem'])->middleware('permission:meetings.edit'); + Route::post('/meetings/{meeting}/blockers', [MeetingController::class, 'addBlocker'])->middleware('permission:meetings.edit'); Route::post('/meetings/{meeting}/participants', [MeetingController::class, 'addParticipant'])->middleware('permission:meetings.edit'); Route::delete('/meetings/{meeting}/participants/{user}', [MeetingController::class, 'removeParticipant'])->middleware('permission:meetings.edit'); Route::post('/meetings/{meeting}/action-items', [MeetingController::class, 'addActionItem'])->middleware('permission:meetings.edit'); @@ -149,6 +161,9 @@ Route::middleware('auth:sanctum')->group(function () { Route::get('/notifications', [NotificationController::class, 'index'])->middleware('permission:notifications.view'); Route::post('/notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->middleware('permission:notifications.view'); + Route::get('/notifications/{notification}/preview', [NotificationController::class, 'preview'])->middleware('permission:notifications.view'); + Route::patch('/notifications/{notification}/archive', [NotificationController::class, 'archive'])->middleware('permission:notifications.view'); + Route::patch('/notifications/{notification}/restore', [NotificationController::class, 'restore'])->middleware('permission:notifications.view'); Route::post('/notifications/read-all', [NotificationController::class, 'markAllAsRead'])->middleware('permission:notifications.view'); Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy'])->middleware('permission:notifications.view'); diff --git a/backend/tests/Feature/AuthTest.php b/backend/tests/Feature/AuthTest.php index c89ddb1..4fcd75e 100644 --- a/backend/tests/Feature/AuthTest.php +++ b/backend/tests/Feature/AuthTest.php @@ -3,9 +3,11 @@ namespace Tests\Feature; use App\Models\User; +use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Notification; use Tests\TestCase; class AuthTest extends TestCase @@ -91,4 +93,41 @@ class AuthTest extends TestCase ->getJson('/api/user') ->assertUnauthorized(); } + + public function test_password_change_revokes_all_access_tokens(): void + { + $user = User::factory()->create([ + 'password' => Hash::make('old-password'), + 'status' => 'active', + ]); + $currentToken = $user->createToken('current')->plainTextToken; + $user->createToken('other'); + + $this->withToken($currentToken) + ->putJson('/api/user/password', [ + 'current_password' => 'old-password', + 'new_password' => 'new-secure-password', + 'new_password_confirmation' => 'new-secure-password', + ]) + ->assertOk() + ->assertJsonPath('reauthentication_required', true); + + $this->assertSame(0, $user->tokens()->count()); + $this->assertTrue(Hash::check('new-secure-password', $user->fresh()->password)); + } + + public function test_forgot_password_sends_reset_notification_without_account_disclosure(): void + { + Notification::fake(); + $user = User::factory()->create(['email' => 'reset@example.com', 'status' => 'active']); + + $this->postJson('/api/forgot-password', ['email' => $user->email]) + ->assertOk() + ->assertJsonPath('success', true); + Notification::assertSentTo($user, ResetPassword::class); + + $this->postJson('/api/forgot-password', ['email' => 'missing@example.com']) + ->assertOk() + ->assertJsonPath('success', true); + } } diff --git a/backend/tests/Feature/SecurityHardeningTest.php b/backend/tests/Feature/SecurityHardeningTest.php index c941e5e..1785e4a 100644 --- a/backend/tests/Feature/SecurityHardeningTest.php +++ b/backend/tests/Feature/SecurityHardeningTest.php @@ -3,7 +3,11 @@ namespace Tests\Feature; use App\Models\File; +use App\Models\Meeting; +use App\Models\MeetingActionItem; +use App\Models\Permission; use App\Models\Project; +use App\Models\Role; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; @@ -84,4 +88,118 @@ class SecurityHardeningTest extends TestCase ->assertForbidden() ->assertJsonPath('success', false); } + + public function test_project_permission_does_not_expose_unrelated_projects(): void + { + $owner = User::factory()->create(['status' => 'active']); + $viewer = User::factory()->create(['status' => 'active']); + $permission = Permission::create([ + 'name' => 'projects.view', + 'display_name' => 'View projects', + 'guard_name' => 'web', + 'module' => 'projects', + ]); + $role = Role::create(['name' => 'project-viewer', 'display_name' => 'Project viewer', 'guard_name' => 'web']); + $role->permissions()->attach($permission); + $viewer->roles()->attach($role); + + $foreignProject = Project::create([ + 'title' => 'Private project', + 'project_manager_id' => $owner->id, + 'created_by' => $owner->id, + ]); + + $token = $viewer->createToken('api-token')->plainTextToken; + + $this->withToken($token) + ->getJson('/api/projects') + ->assertOk() + ->assertJsonPath('meta.total', 0); + + $this->withToken($token) + ->getJson("/api/projects/{$foreignProject->id}") + ->assertForbidden(); + } + + public function test_authenticated_user_cannot_change_another_user_status_without_permission(): void + { + $actor = User::factory()->create(['status' => 'active']); + $target = User::factory()->create(['status' => 'active']); + $token = $actor->createToken('api-token')->plainTextToken; + + $this->withToken($token) + ->patchJson("/api/users/{$target->id}/status", ['status' => 'inactive']) + ->assertForbidden(); + + $this->assertSame('active', $target->fresh()->status); + } + + public function test_user_cannot_download_file_from_unrelated_project(): void + { + $owner = User::factory()->create(['status' => 'active']); + $viewer = User::factory()->create(['status' => 'active']); + $permission = Permission::create([ + 'name' => 'files.view', + 'display_name' => 'View files', + 'guard_name' => 'web', + 'module' => 'files', + ]); + $role = Role::create(['name' => 'file-viewer', 'display_name' => 'File viewer', 'guard_name' => 'web']); + $role->permissions()->attach($permission); + $viewer->roles()->attach($role); + $project = Project::create([ + 'title' => 'Private project', + 'project_manager_id' => $owner->id, + 'created_by' => $owner->id, + ]); + $file = File::create([ + 'name' => 'private.pdf', + 'original_name' => 'private.pdf', + 'path' => 'files/private.pdf', + 'mime_type' => 'application/pdf', + 'size' => 10, + 'fileable_type' => Project::class, + 'fileable_id' => $project->id, + 'user_id' => $owner->id, + ]); + + $token = $viewer->createToken('api-token')->plainTextToken; + $this->withToken($token)->getJson("/api/files/{$file->id}")->assertForbidden(); + } + + public function test_meeting_action_item_must_belong_to_meeting_in_route(): void + { + $user = User::factory()->create(['status' => 'active']); + $this->grantAdminRole($user); + $project = Project::create([ + 'title' => 'Meeting security', + 'project_manager_id' => $user->id, + 'created_by' => $user->id, + ]); + $first = Meeting::create([ + 'title' => 'First', + 'project_id' => $project->id, + 'date' => now()->toDateString(), + 'meeting_type' => 'other', + 'created_by' => $user->id, + ]); + $second = Meeting::create([ + 'title' => 'Second', + 'project_id' => $project->id, + 'date' => now()->toDateString(), + 'meeting_type' => 'other', + 'created_by' => $user->id, + ]); + $item = MeetingActionItem::create([ + 'meeting_id' => $second->id, + 'title' => 'Private action', + ]); + + $token = $user->createToken('api-token')->plainTextToken; + $this->withToken($token) + ->putJson("/api/meetings/{$first->id}/action-items/{$item->id}", ['title' => 'Tampered']) + ->assertForbidden(); + + $this->assertSame('Private action', $item->fresh()->title); + } } diff --git a/backend/tests/Feature/SettingTest.php b/backend/tests/Feature/SettingTest.php index fc7b6cc..3415862 100644 --- a/backend/tests/Feature/SettingTest.php +++ b/backend/tests/Feature/SettingTest.php @@ -58,7 +58,10 @@ class SettingTest extends TestCase $this->withToken($token) ->getJson('/api/settings') ->assertOk() - ->assertJsonPath('data.0.value', 'شرکت پیشگام'); + ->assertJsonFragment([ + 'key' => 'company_name', + 'value' => 'شرکت پیشگام', + ]); $this->withToken($token) ->putJson("/api/settings/{$plain->id}", [ diff --git a/backend/tests/Feature/WorkspaceFeaturesTest.php b/backend/tests/Feature/WorkspaceFeaturesTest.php new file mode 100644 index 0000000..d063bae --- /dev/null +++ b/backend/tests/Feature/WorkspaceFeaturesTest.php @@ -0,0 +1,169 @@ +create(['status' => 'active']); + $this->grantAdminRole($user); + + return [$user, $user->createToken('workspace-test')->plainTextToken]; + } + + private function project(User $user): Project + { + return Project::create([ + 'title' => 'پروژه تست Workspace', + 'project_manager_id' => $user->id, + 'created_by' => $user->id, + 'start_date' => now()->startOfMonth()->toDateString(), + 'end_date' => now()->endOfMonth()->toDateString(), + 'status' => 'in_progress', + ]); + } + + public function test_calendar_aggregates_real_project_sprint_task_and_meeting_events(): void + { + [$user, $token] = $this->authenticatedAdmin(); + $project = $this->project($user); + $sprint = Sprint::create([ + 'title' => 'Sprint تقویم', + 'project_id' => $project->id, + 'start_date' => now()->startOfWeek()->toDateString(), + 'end_date' => now()->endOfWeek()->toDateString(), + 'status' => 'active', + 'created_by' => $user->id, + ]); + Task::create([ + 'title' => 'تسک سررسیددار', + 'project_id' => $project->id, + 'assignee_id' => $user->id, + 'reporter_id' => $user->id, + 'created_by' => $user->id, + 'due_date' => now()->toDateString(), + 'status' => 'todo', + ]); + Meeting::create([ + 'title' => 'جلسه تقویم', + 'project_id' => $project->id, + 'sprint_id' => $sprint->id, + 'meeting_type' => 'daily_standup', + 'status' => 'scheduled', + 'date' => now()->toDateString(), + 'start_time' => '09:30', + 'created_by' => $user->id, + ]); + + $response = $this->withToken($token)->getJson('/api/calendar/events?from='.now()->startOfMonth()->toDateString().'&to='.now()->endOfMonth()->toDateString()); + + $response->assertOk()->assertJsonPath('success', true); + $types = collect($response->json('data'))->pluck('event_type'); + $this->assertTrue($types->contains('meeting')); + $this->assertTrue($types->contains('task')); + $this->assertTrue($types->contains('sprint')); + $this->assertTrue($types->contains('project')); + } + + public function test_notification_can_be_previewed_archived_restored_and_soft_deleted(): void + { + [$user, $token] = $this->authenticatedAdmin(); + $notification = Notification::create([ + 'user_id' => $user->id, + 'type' => 'system', + 'title' => 'اعلان تست', + 'body' => 'جزئیات اعلان', + ]); + + $this->withToken($token) + ->getJson("/api/notifications/{$notification->id}/preview") + ->assertOk() + ->assertJsonPath('data.preview.body', 'جزئیات اعلان'); + + $this->withToken($token) + ->patchJson("/api/notifications/{$notification->id}/archive") + ->assertOk() + ->assertJsonPath('data.archived_at', fn ($value) => filled($value)); + + $this->withToken($token) + ->patchJson("/api/notifications/{$notification->id}/restore") + ->assertOk() + ->assertJsonPath('data.archived_at', null); + + $this->withToken($token)->deleteJson("/api/notifications/{$notification->id}")->assertOk(); + $this->assertSoftDeleted('notifications', ['id' => $notification->id]); + } + + public function test_sprint_creation_can_create_configured_meetings_and_workspace(): void + { + [$user, $token] = $this->authenticatedAdmin(); + $project = $this->project($user); + $this->assertGreaterThan(0, MeetingType::count()); + + $response = $this->withToken($token)->postJson('/api/sprints', [ + 'title' => 'Sprint یکپارچه', + 'project_id' => $project->id, + 'start_date' => now()->addDay()->toDateString(), + 'end_date' => now()->addDays(14)->toDateString(), + 'member_ids' => [$user->id], + 'meeting_setup' => [[ + 'type_key' => 'sprint_planning', + 'date' => now()->addDay()->toDateString(), + 'start_time' => '10:00', + ]], + ])->assertCreated(); + + $sprintId = $response->json('data.id'); + $this->assertDatabaseHas('meetings', ['sprint_id' => $sprintId, 'meeting_type' => 'sprint_planning']); + + $this->withToken($token) + ->getJson("/api/sprints/{$sprintId}/workspace") + ->assertOk() + ->assertJsonPath('data.sprint.id', $sprintId) + ->assertJsonCount(1, 'data.meetings'); + } + + public function test_meeting_lifecycle_and_structured_outputs_are_available_in_workspace(): void + { + [$user, $token] = $this->authenticatedAdmin(); + $project = $this->project($user); + $meeting = Meeting::create([ + 'title' => 'جلسه چرخه عمر', + 'project_id' => $project->id, + 'meeting_type' => 'adhoc', + 'status' => 'scheduled', + 'date' => now()->toDateString(), + 'created_by' => $user->id, + ]); + + $this->withToken($token)->postJson("/api/meetings/{$meeting->id}/start")->assertOk()->assertJsonPath('data.status', 'in_progress'); + $this->withToken($token)->postJson("/api/meetings/{$meeting->id}/decisions", ['title' => 'تصمیم قطعی'])->assertCreated(); + $this->withToken($token)->postJson("/api/meetings/{$meeting->id}/structured-action-items", [ + 'title' => 'اقدام بعدی', + 'owner_id' => $user->id, + 'due_date' => now()->addDay()->toDateString(), + ])->assertCreated(); + $this->withToken($token)->postJson("/api/meetings/{$meeting->id}/blockers", ['title' => 'مانع تست'])->assertCreated(); + $this->withToken($token)->postJson("/api/meetings/{$meeting->id}/complete", ['summary' => 'خلاصه نهایی'])->assertOk()->assertJsonPath('data.status', 'completed'); + + $this->withToken($token) + ->getJson("/api/meetings/{$meeting->id}/workspace") + ->assertOk() + ->assertJsonCount(1, 'data.structured_decisions') + ->assertJsonCount(1, 'data.structured_action_items') + ->assertJsonCount(1, 'data.blockers'); + } +} diff --git a/docs/runbooks/production-launch.md b/docs/runbooks/production-launch.md new file mode 100644 index 0000000..ceb020b --- /dev/null +++ b/docs/runbooks/production-launch.md @@ -0,0 +1,29 @@ +# Production launch checklist + +## Required environment + +- `APP_ENV=production`, `APP_DEBUG=false`, HTTPS `APP_URL` +- a secret generated `APP_KEY` +- `LOG_LEVEL=warning` or stricter with centralized retention and alerting +- MySQL 8 with TLS where traffic leaves the host +- `SESSION_SECURE_COOKIE=true` +- explicit trusted proxy, CORS, Sanctum stateful-domain, and cookie-domain values +- real SMTP credentials stored in the deployment secret manager +- dedicated cache/queue stores and supervised queue workers + +## Release gates + +- Clean reviewed commit and green CI. +- Dependency audit has no unresolved high/critical advisory. +- Production build artifact is generated from the reviewed commit. +- Database backup and restore drill are successful. +- Migration, rollback, incident owner, and maintenance window are documented. +- Rate limiting, access-control regression tests, file authorization, and password reset pass. +- Health check `/up` is monitored externally. + +## Post-deploy checks + +- Login/logout/password reset and forced reauthentication after password change. +- Role and record-level authorization with two users from different projects. +- Project, task, sprint, meeting, file, notification, and settings workflows. +- Queue failures, HTTP 5xx, database latency, disk use, and backup freshness. diff --git a/docs/runbooks/sqlite-to-mysql.md b/docs/runbooks/sqlite-to-mysql.md new file mode 100644 index 0000000..7d7ae21 --- /dev/null +++ b/docs/runbooks/sqlite-to-mysql.md @@ -0,0 +1,64 @@ +# SQLite to MySQL controlled migration + +## Preconditions + +- Work from a dedicated branch and record the source commit. +- Put the application in a planned write freeze. +- Keep the source SQLite file outside the web root. +- Create an immutable copy and verify its SHA-256 hash. +- Provision an empty MySQL 8 database using `utf8mb4` and `utf8mb4_unicode_ci`. +- Use a dedicated least-privilege migration account. Do not commit its password. + +## Dry run + +Configure only the `MIGRATION_MYSQL_*` variables. Keep `DB_CONNECTION=sqlite`. + +```sh +php artisan migrate --database=migration_mysql --force +php artisan db:transfer-sqlite-to-mysql +``` + +The transfer command does not write unless `--execute` is supplied. + +## Transfer + +```sh +php artisan db:transfer-sqlite-to-mysql --execute --truncate +php artisan db:transfer-sqlite-to-mysql --execute +``` + +The second execution proves idempotency. Both runs must finish with identical source +and target row counts. Sessions, access tokens, cache, queues, reset tokens, and the +migration repository are intentionally excluded; users must authenticate again. + +## Validation gates + +- All migrations succeed on an empty MySQL database. +- Every transferred table has equal source and target row counts. +- Foreign-key validation reports no orphan rows. +- Unique business keys have no duplicates. +- JSON columns contain valid JSON. +- Dates, times, decimals, booleans, Persian text, and emoji round-trip correctly. +- Backend, frontend build, and browser tests pass with MySQL as the active test database. +- A manual smoke test covers login, project/task CRUD, sprint, meeting, files, notifications, settings, and reports. + +## Cutover + +1. Enable maintenance/write-freeze mode. +2. Take and hash a final SQLite backup. +3. Re-run the idempotent transfer without `--truncate`. +4. Run validation gates. +5. Change production secrets to `DB_CONNECTION=mysql`. +6. Clear configuration cache, restart application and queue workers, then run health and smoke checks. +7. Retain the SQLite source read-only for the defined rollback period. + +## Rollback + +If any validation or smoke check fails: + +1. Stop writes immediately. +2. Restore the previous application configuration and `DB_CONNECTION=sqlite`. +3. Restore the verified final SQLite copy if the source was touched. +4. Restart application and workers. +5. Run `/up`, authentication, project, task, and file smoke checks. +6. Preserve MySQL unchanged for incident analysis; never merge partial target data back manually. diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..ade4183 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -10,6 +10,8 @@ lerna-debug.log* node_modules dist dist-ssr +test-results +playwright-report *.local # Editor directories and files diff --git a/frontend/e2e/command-center.spec.js b/frontend/e2e/command-center.spec.js new file mode 100644 index 0000000..091623d --- /dev/null +++ b/frontend/e2e/command-center.spec.js @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; + +test('login password can be shown and hidden', async ({ page }) => { + await page.goto('/login'); + const password = page.locator('#login-password'); + await password.fill('secret-password'); + await expect(password).toHaveAttribute('type', 'password'); + await page.getByRole('button', { name: 'نمایش رمز عبور' }).click(); + await expect(password).toHaveAttribute('type', 'text'); + await page.getByRole('button', { name: 'مخفی کردن رمز عبور' }).click(); + await expect(password).toHaveAttribute('type', 'password'); +}); + +test('dashboard command surfaces and settings render without runtime errors', async ({ page }, testInfo) => { + const runtimeErrors = []; + page.on('pageerror', (error) => runtimeErrors.push(error.message)); + page.on('console', (message) => { + if (message.type() === 'error') runtimeErrors.push(message.text()); + }); + + const user = { id: 1, name: 'مدیر تست رابط', email: 'ui@example.test', permissions: ['reports.view', 'settings.view', 'notifications.view'] }; + await page.addInitScript(({ testUser }) => { + localStorage.setItem('token', 'visual-smoke-token'); + localStorage.setItem('user', JSON.stringify(testUser)); + }, { testUser: user }); + await page.route('**/api/**', async (route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + if (path === '/api/user') return route.fulfill({ json: { success: true, data: user } }); + if (path === '/api/dashboard/monitoring') return route.fulfill({ json: { success: true, data: { + generated_at: new Date().toISOString(), + summary: { active_projects: 4, projects_at_risk: 1, open_tasks: 18, overdue_tasks: 3, blocked_tasks: 2, active_sprints: 2, meetings_today: 3, overdue_action_items: 1, open_blockers: 2 }, + projects: [{ id: 1, title: 'پروژه نمونه رابط', status: 'in_progress', health: 'at_risk', progress: 62, total_tasks: 14, completed_tasks: 8, overdue_tasks: 2, blocked_tasks: 1 }], + sprints: [{ id: 1, title: 'Sprint رابط', project: 'پروژه نمونه رابط', progress: 58, expected_progress: 65, remaining_days: 5, health: 'on_track', blocked_tasks: 0 }], + workload: [{ id: 1, name: 'کاربر نمونه', open_tasks: 7, overdue_tasks: 1, load: 'high' }], + attention: [], + upcoming_meetings: [], + trends: [{ month: '2026-06', created: 12, completed: 9 }, { month: '2026-07', created: 14, completed: 13 }], + } } }); + if (path === '/api/calendar/events') return route.fulfill({ json: { success: true, data: [] } }); + if (path === '/api/notifications') return route.fulfill({ json: { success: true, data: [], meta: { unread_count: 0, archived_count: 0 } } }); + if (path === '/api/settings') return route.fulfill({ json: { success: true, data: [ + { id: 1, key: 'working_days', value: ['saturday', 'sunday'], group: 'calendar', type: 'list' }, + { id: 2, key: 'default_meeting_reminder_minutes', value: 30, group: 'meeting', type: 'number' }, + ] } }); + return route.fulfill({ json: { success: true, data: [] } }); + }); + + await page.goto('/'); + await expect(page.getByText('مانیتورینگ عملیاتی')).toBeVisible(); + await expect(page.locator('img.sidebar-brand-mark')).toBeVisible(); + + await page.getByRole('button', { name: 'تقویم رویدادها' }).click(); + await expect(page.getByRole('region', { name: 'تقویم رویدادها' })).toBeVisible(); + await expect(page.getByText('برنامه روز')).toBeVisible(); + await page.mouse.click(900, 760); + await expect(page.locator('.calendar-popover')).toBeHidden(); + + await page.getByRole('button', { name: 'اعلانها' }).click(); + await expect(page.getByLabel('مرکز اعلانها')).toBeVisible(); + await expect(page.getByText(/برای آرشیو به راست/)).toBeVisible(); + await page.mouse.click(900, 760); + await expect(page.locator('.notification-center')).toBeHidden(); + + await page.getByRole('button', { name: 'باز کردن پروفایل کاربری' }).click(); + await expect(page.getByRole('dialog', { name: 'پروفایل کاربری' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'ذخیره هویت' })).toBeVisible(); + await page.getByRole('tab', { name: 'اطلاعات کاری' }).click(); + await expect(page.getByRole('button', { name: 'ذخیره اطلاعات کاری' })).toBeVisible(); + const desktopModalMetrics = await page.getByRole('dialog', { name: 'پروفایل کاربری' }).evaluate((element) => { + const body = element.querySelector('.modal-body'); + const styles = getComputedStyle(element); + return { + overflow: styles.overflowY, + bodyOverflow: getComputedStyle(body).overflowY, + radius: [styles.borderTopLeftRadius, styles.borderTopRightRadius, styles.borderBottomRightRadius, styles.borderBottomLeftRadius], + }; + }); + expect(desktopModalMetrics.overflow).toBe('hidden'); + expect(desktopModalMetrics.bodyOverflow).toBe('hidden'); + expect(desktopModalMetrics.radius.every((value) => parseFloat(value) > 0)).toBe(true); + await page.getByRole('button', { name: 'بستن', exact: true }).click(); + await expect(page.getByRole('dialog', { name: 'پروفایل کاربری' })).toBeHidden(); + + await page.goto('/settings'); + await expect(page.getByRole('heading', { name: 'مرکز تنظیمات' })).toBeVisible(); + await expect(page.getByText('شخصیسازی')).toBeVisible(); + + await page.screenshot({ path: testInfo.outputPath('settings-command-center.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => localStorage.setItem('preferredAppMode', 'desktop')); + await page.goto('/'); + await expect(page.getByText('مانیتورینگ عملیاتی')).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await page.getByRole('button', { name: 'باز کردن منوی اصلی' }).click(); + await expect(page.locator('.app-sidebar.mobile-open')).toBeVisible(); + await expect(page.locator('.app-sidebar.mobile-open').getByText('پروژهها')).toBeVisible(); + await page.locator('.app-sidebar.mobile-open').getByRole('button', { name: 'بستن منوی اصلی' }).click(); + await expect(page.locator('.app-sidebar.mobile-open')).toHaveCount(0); + await page.getByRole('button', { name: 'باز کردن پروفایل کاربری' }).click(); + await expect(page.getByRole('dialog', { name: 'پروفایل کاربری' })).toBeVisible(); + expect(await page.getByRole('dialog', { name: 'پروفایل کاربری' }).evaluate((element) => element.getBoundingClientRect().height <= window.innerHeight)).toBe(true); + await page.getByRole('button', { name: 'بستن', exact: true }).click(); + await page.screenshot({ path: testInfo.outputPath('mobile-dashboard.png'), fullPage: true }); + + expect(runtimeErrors).toEqual([]); +}); diff --git a/frontend/index.html b/frontend/index.html index 78b6242..e49f52c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,11 @@
- + + + - +{body}
} +