harden access controls and prepare mysql launch

مخزن کامیت contained در
مسعود نیک زاد 2026-07-29 16:07:37 +03:30
والد 53ab7244e4
کامیت d22b776572
123فایلهای تغییر یافته به همراه5057 افزوده شده و 777 حذف شده

60
.github/workflows/ci.yml فروخته شده normal فایل
مشاهده پرونده

@ -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

مشاهده پرونده

@ -27,11 +27,23 @@ DB_CONNECTION=sqlite
# DB_USERNAME=root # DB_USERNAME=root
# DB_PASSWORD= # 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_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null 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 BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local

مشاهده پرونده

@ -0,0 +1,268 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
use Throwable;
class TransferSqliteToMysql extends Command
{
protected $signature = 'db:transfer-sqlite-to-mysql
{--execute : Perform writes; without this flag the command is a dry run}
{--truncate : Empty transferable target tables before importing}
{--chunk=250 : Rows processed per batch}';
protected $description = 'Idempotently transfer application data from SQLite to a separately configured MySQL database';
private const EXCLUDED_TABLES = [
'migrations',
'cache',
'cache_locks',
'jobs',
'job_batches',
'failed_jobs',
'password_reset_tokens',
'sessions',
'personal_access_tokens',
];
private const NATURAL_KEYS = [
'role_user' => ['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);
}
}

مشاهده پرونده

@ -3,18 +3,21 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\LoginRequest;
use App\Http\Requests\ChangePasswordRequest; use App\Http\Requests\ChangePasswordRequest;
use App\Http\Requests\LoginRequest;
use App\Http\Requests\UpdateProfileRequest; use App\Http\Requests\UpdateProfileRequest;
use App\Http\Resources\UserResource; use App\Http\Resources\UserResource;
use App\Models\User; use App\Models\User;
use App\Services\ActivityLogService; use App\Services\ActivityLogService;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class AuthController extends Controller class AuthController extends Controller
{ {
@ -28,7 +31,7 @@ class AuthController extends Controller
$user = $this->findUserForLogin($identifier); $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); $this->logAuthEvent(null, 'failed_login', 'تلاش ناموفق ورود', $request, $identifier);
return response()->json([ return response()->json([
@ -187,7 +190,7 @@ class AuthController extends Controller
try { try {
$user = $request->user(); $user = $request->user();
if (!Hash::check($request->current_password, $user->password)) { if (! Hash::check($request->current_password, $user->password)) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'رمز عبور فعلی اشتباه است', 'message' => 'رمز عبور فعلی اشتباه است',
@ -195,10 +198,12 @@ class AuthController extends Controller
} }
$user->update(['password' => Hash::make($request->new_password)]); $user->update(['password' => Hash::make($request->new_password)]);
$user->tokens()->delete();
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => 'رمز عبور با موفقیت تغییر یافت', 'message' => 'رمز عبور با موفقیت تغییر یافت',
'reauthentication_required' => true,
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
@ -210,9 +215,45 @@ class AuthController extends Controller
public function forgotPassword(Request $request): JsonResponse public function forgotPassword(Request $request): JsonResponse
{ {
$data = $request->validate(['email' => 'required|email']);
Password::sendResetLink(['email' => strtolower($data['email'])]);
return response()->json([ return response()->json([
'success' => true, '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' => 'رمز عبور تغییر کرد. دوباره وارد شوید.',
]); ]);
} }

مشاهده پرونده

@ -8,15 +8,24 @@ use App\Http\Resources\BacklogItemResource;
use App\Http\Resources\TaskResource; use App\Http\Resources\TaskResource;
use App\Models\BacklogItem; use App\Models\BacklogItem;
use App\Models\Task; use App\Models\Task;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class BacklogController extends Controller class BacklogController extends Controller
{ {
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
try { try {
$query = BacklogItem::with(['project', 'assignedSprint', 'creator']); $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')) { if ($request->filled('project_id')) {
$query->where('project_id', $request->project_id); $query->where('project_id', $request->project_id);
@ -31,8 +40,10 @@ class BacklogController extends Controller
$query->where('priority', $request->priority); $query->where('priority', $request->priority);
} }
$perPage = $request->input('per_page', 15); $perPage = min(max((int) $request->input('per_page', 15), 1), 100);
$sortBy = $request->input('sort_by', 'created_at'); $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'); $sortDir = $request->input('sort_dir', 'desc');
$items = $query->orderBy($sortBy, $sortDir)->paginate($perPage); $items = $query->orderBy($sortBy, $sortDir)->paginate($perPage);
@ -77,6 +88,13 @@ class BacklogController extends Controller
{ {
try { try {
$data = $request->validated(); $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['created_by'] = $request->user()->id;
$item = BacklogItem::create($data); $item = BacklogItem::create($data);
$item->load(['project', 'assignedSprint', 'creator']); $item->load(['project', 'assignedSprint', 'creator']);
@ -109,7 +127,7 @@ class BacklogController extends Controller
$backlogItem->update($request->only([ $backlogItem->update($request->only([
'title', 'description', 'type', 'priority', 'title', 'description', 'type', 'priority',
'estimated_effort', 'status', 'assigned_sprint_id' 'estimated_effort', 'status', 'assigned_sprint_id',
])); ]));
$backlogItem->load(['project', 'assignedSprint', 'creator']); $backlogItem->load(['project', 'assignedSprint', 'creator']);

مشاهده پرونده

@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\CalendarService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CalendarController extends Controller
{
public function __construct(private readonly CalendarService $calendarService) {}
public function events(Request $request): JsonResponse
{
$data = $request->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' => 'رویدادهای تقویم',
]);
}
}

مشاهده پرونده

@ -98,7 +98,7 @@ class ChecklistController extends Controller
public function toggleComplete(Checklist $checklist): JsonResponse public function toggleComplete(Checklist $checklist): JsonResponse
{ {
try { try {
$checklist->update(['is_completed' => !$checklist->is_completed]); $checklist->update(['is_completed' => ! $checklist->is_completed]);
return response()->json([ return response()->json([
'success' => true, 'success' => true,

مشاهده پرونده

@ -6,9 +6,13 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\StoreCommentRequest; use App\Http\Requests\StoreCommentRequest;
use App\Http\Resources\CommentResource; use App\Http\Resources\CommentResource;
use App\Models\Comment; use App\Models\Comment;
use App\Models\Meeting;
use App\Models\Project;
use App\Models\Task;
use App\Models\User; use App\Models\User;
use App\Services\ActivityLogService; use App\Services\ActivityLogService;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -17,12 +21,13 @@ class CommentController extends Controller
public function __construct( public function __construct(
protected ActivityLogService $activityLogService, protected ActivityLogService $activityLogService,
protected NotificationService $notificationService, protected NotificationService $notificationService,
protected ResourceAccessService $resourceAccess,
) {} ) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
try { try {
$query = Comment::with(['user', 'files']); $query = $this->resourceAccess->comments($request->user())->with(['user', 'files']);
if ($request->filled('commentable_type')) { if ($request->filled('commentable_type')) {
$query->where('commentable_type', $request->commentable_type); $query->where('commentable_type', $request->commentable_type);
@ -57,11 +62,18 @@ class CommentController extends Controller
{ {
try { try {
$data = $request->validated(); $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; $data['user_id'] = $request->user()->id;
$comment = Comment::create($data); $comment = Comment::create($data);
$comment->load('user'); $comment->load('user');
if (!empty($data['mentioned_user_id'])) { if (! empty($data['mentioned_user_id'])) {
$mentionedUser = User::find($data['mentioned_user_id']); $mentionedUser = User::find($data['mentioned_user_id']);
if ($mentionedUser && $mentionedUser->id !== $request->user()->id) { if ($mentionedUser && $mentionedUser->id !== $request->user()->id) {
$this->notificationService->create( $this->notificationService->create(
@ -80,7 +92,7 @@ class CommentController extends Controller
} }
preg_match_all('/@(\w+)/', $comment->body, $matches); preg_match_all('/@(\w+)/', $comment->body, $matches);
if (!empty($matches[1])) { if (! empty($matches[1])) {
$mentionedUsers = User::whereIn('name', $matches[1])->get(); $mentionedUsers = User::whereIn('name', $matches[1])->get();
foreach ($mentionedUsers as $mentionedUser) { foreach ($mentionedUsers as $mentionedUser) {
$mentionedUser->notifications()->create([ $mentionedUser->notifications()->create([

مشاهده پرونده

@ -46,4 +46,22 @@ class DashboardController extends Controller
], 500); ], 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);
}
}
} }

مشاهده پرونده

@ -22,14 +22,14 @@ class DepartmentController extends Controller
$departmentIds = $departments->pluck('id'); $departmentIds = $departments->pluck('id');
$tree = $departments $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() ->values()
->map(fn($d) => $this->formatNode($d, $departments)); ->map(fn ($d) => $this->formatNode($d, $departments));
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => $tree, 'data' => $tree,
'flat' => $departments->values()->map(fn($d) => [ 'flat' => $departments->values()->map(fn ($d) => [
'id' => $d->id, 'id' => $d->id,
'name' => $d->name, 'name' => $d->name,
'type' => $d->type ?? 'department', 'type' => $d->type ?? 'department',
@ -50,6 +50,7 @@ class DepartmentController extends Controller
private function formatNode($dept, $all): array private function formatNode($dept, $all): array
{ {
$children = $all->where('parent_id', $dept->id)->values(); $children = $all->where('parent_id', $dept->id)->values();
return [ return [
'id' => $dept->id, 'id' => $dept->id,
'name' => $dept->name, 'name' => $dept->name,
@ -62,7 +63,7 @@ class DepartmentController extends Controller
'is_active' => $dept->is_active, 'is_active' => $dept->is_active,
'sort_order' => $dept->sort_order, 'sort_order' => $dept->sort_order,
'users_count' => $dept->members()->count() ?: $dept->users()->count(), '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 = $request->only(['name', 'description', 'type', 'parent_id', 'manager_id', 'is_active', 'sort_order']);
$data['type'] = $data['type'] ?? 'department'; $data['type'] = $data['type'] ?? 'department';
$data['is_active'] = $data['is_active'] ?? true; $data['is_active'] = $data['is_active'] ?? true;
if (!empty($data['manager_id'])) { if (! empty($data['manager_id'])) {
$data['manager_changed_at'] = now(); $data['manager_changed_at'] = now();
} }
$dept = Department::create($data); $dept = Department::create($data);
@ -117,6 +118,7 @@ class DepartmentController extends Controller
{ {
try { try {
$department->load(['manager', 'parent', 'children']); $department->load(['manager', 'parent', 'children']);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => $department, 'data' => $department,
@ -152,11 +154,11 @@ class DepartmentController extends Controller
$changed = collect($payload) $changed = collect($payload)
->except('manager_changed_at') ->except('manager_changed_at')
->filter(fn($value, $key) => array_key_exists($key, $old) && $old[$key] != $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}]) ->map(fn ($value, $key) => ['old' => $old[$key], 'new' => $department->{$key}])
->all(); ->all();
if (!empty($changed)) { if (! empty($changed)) {
$this->activityLogService->log( $this->activityLogService->log(
$request->user()?->id, $request->user()?->id,
array_key_exists('manager_id', $changed) ? 'change_department_manager' : 'update_department', array_key_exists('manager_id', $changed) ? 'change_department_manager' : 'update_department',

مشاهده پرونده

@ -4,8 +4,12 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\FileResource; use App\Http\Resources\FileResource;
use App\Models\Comment;
use App\Models\File; use App\Models\File;
use App\Models\Meeting;
use App\Models\Project; use App\Models\Project;
use App\Models\Task;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -14,10 +18,12 @@ use Illuminate\Validation\ValidationException;
class FileController extends Controller class FileController extends Controller
{ {
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
try { try {
$query = File::with('user'); $query = $this->resourceAccess->files($request->user())->with('user');
if ($request->filled('fileable_type')) { if ($request->filled('fileable_type')) {
$query->where('fileable_type', $request->fileable_type); $query->where('fileable_type', $request->fileable_type);
@ -66,17 +72,26 @@ class FileController extends Controller
$fileableId = $request->project_id; $fileableId = $request->project_id;
} }
if (!$fileableType || !$fileableId) { if (! $fileableType || ! $fileableId) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'انتخاب محل آپلود فایل الزامی است', 'message' => 'انتخاب محل آپلود فایل الزامی است',
], 422); ], 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'); $uploadedFile = $request->file('file');
$originalName = $uploadedFile->getClientOriginalName(); $originalName = $uploadedFile->getClientOriginalName();
$extension = strtolower($uploadedFile->getClientOriginalExtension()); $extension = strtolower($uploadedFile->getClientOriginalExtension());
$name = Str::uuid()->toString() . ($extension ? ".{$extension}" : ''); $name = Str::uuid()->toString().($extension ? ".{$extension}" : '');
$path = $uploadedFile->storeAs('files', $name, 'local'); $path = $uploadedFile->storeAs('files', $name, 'local');
$file = File::create([ $file = File::create([
@ -111,7 +126,7 @@ class FileController extends Controller
try { try {
$disk = Storage::disk('local')->exists($file->path) ? 'local' : 'public'; $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([ return response()->json([
'success' => false, 'success' => false,
'message' => 'فایل یافت نشد', 'message' => 'فایل یافت نشد',

مشاهده پرونده

@ -4,25 +4,34 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\StoreMeetingRequest; use App\Http\Requests\StoreMeetingRequest;
use App\Http\Resources\MeetingResource;
use App\Http\Resources\MeetingActionItemResource; use App\Http\Resources\MeetingActionItemResource;
use App\Http\Resources\MeetingResource;
use App\Http\Resources\TaskResource; use App\Http\Resources\TaskResource;
use App\Models\ActionItem;
use App\Models\Blocker;
use App\Models\Decision;
use App\Models\Meeting; use App\Models\Meeting;
use App\Models\MeetingActionItem; use App\Models\MeetingActionItem;
use App\Models\MeetingType;
use App\Models\Task; use App\Models\Task;
use App\Models\User; use App\Models\User;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class MeetingController extends Controller 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 public function index(Request $request): JsonResponse
{ {
try { try {
$query = Meeting::with(['project', 'creator', 'participants']); $query = $this->resourceAccess->meetings($request->user())
->with(['project', 'sprint', 'type', 'creator', 'participants']);
if ($request->filled('project_id')) { if ($request->filled('project_id')) {
$query->where('project_id', $request->project_id); $query->where('project_id', $request->project_id);
@ -36,6 +45,12 @@ class MeetingController extends Controller
if ($request->filled('meeting_type')) { if ($request->filled('meeting_type')) {
$query->where('meeting_type', $request->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); $perPage = $request->input('per_page', 15);
$sortBy = $request->input('sort_by', 'date'); $sortBy = $request->input('sort_by', 'date');
@ -64,7 +79,11 @@ class MeetingController extends Controller
public function show(Meeting $meeting): JsonResponse public function show(Meeting $meeting): JsonResponse
{ {
try { 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([ return response()->json([
'success' => true, 'success' => true,
@ -83,9 +102,17 @@ class MeetingController extends Controller
{ {
try { try {
$data = $request->validated(); $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['created_by'] = $request->user()->id;
$data['owner_id'] ??= $request->user()->id;
$meeting = Meeting::create($data); $meeting = Meeting::create($data);
$meeting->load(['project', 'creator']); $meeting->load(['project', 'sprint', 'type', 'creator', 'owner']);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@ -103,24 +130,43 @@ class MeetingController extends Controller
public function update(Request $request, Meeting $meeting): JsonResponse public function update(Request $request, Meeting $meeting): JsonResponse
{ {
try { try {
$request->validate([ $data = $request->validate([
'title' => 'sometimes|required|string|max:255', 'title' => 'sometimes|required|string|max:255',
'date' => 'sometimes|required|date', 'date' => 'sometimes|required|date',
'start_time' => 'nullable', 'start_time' => 'nullable',
'end_time' => 'nullable', 'end_time' => 'nullable',
'meeting_type' => 'sometimes|required|string|max:50', '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', 'location' => 'nullable|string',
'meeting_link' => 'nullable|string', 'meeting_link' => 'nullable|url',
'objective' => 'nullable|string',
'agenda' => 'nullable|string', 'agenda' => 'nullable|string',
'notes' => 'nullable|string', 'notes' => 'nullable|string',
'summary' => 'nullable|string',
'decisions' => '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([ $meeting->update(collect($data)->only([
'title', 'date', 'start_time', 'end_time', 'meeting_type', 'title', 'project_id', 'sprint_id', 'date', 'start_time', 'end_time',
'location', 'meeting_link', 'agenda', 'notes', 'decisions' 'meeting_type', 'meeting_type_id', 'status', 'location', 'meeting_link',
])); 'objective', 'agenda', 'notes', 'summary', 'decisions', 'owner_id',
$meeting->load(['project', 'creator']); 'facilitator_id', 'reminder_minutes', 'recurrence_rule',
])->all());
$meeting->load(['project', 'sprint', 'type', 'creator', 'owner', 'facilitator']);
return response()->json([ return response()->json([
'success' => true, 'success' => true,
@ -277,6 +323,11 @@ class MeetingController extends Controller
$request->validate([ $request->validate([
'project_id' => 'required|exists:projects,id', 'project_id' => 'required|exists:projects,id',
]); ]);
abort_unless(
$this->resourceAccess->projects($request->user())->whereKey($request->project_id)->exists(),
403,
'شما به پروژه انتخاب‌شده دسترسی ندارید.'
);
$task = Task::create([ $task = Task::create([
'title' => $actionItem->title, 'title' => $actionItem->title,
@ -303,4 +354,131 @@ class MeetingController extends Controller
], 500); ], 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' => 'انواع جلسه',
]);
}
} }

مشاهده پرونده

@ -15,6 +15,12 @@ class NotificationController extends Controller
{ {
try { try {
$query = $request->user()->notifications(); $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); $perPage = $request->input('per_page', 15);
$notifications = $query->orderBy('created_at', 'desc')->paginate($perPage); $notifications = $query->orderBy('created_at', 'desc')->paginate($perPage);
@ -27,7 +33,8 @@ class NotificationController extends Controller
'last_page' => $notifications->lastPage(), 'last_page' => $notifications->lastPage(),
'per_page' => $notifications->perPage(), 'per_page' => $notifications->perPage(),
'total' => $notifications->total(), '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' => 'لیست اعلان‌ها', 'message' => 'لیست اعلان‌ها',
]); ]);
@ -107,4 +114,55 @@ class NotificationController extends Controller
], 500); ], 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' => 'جزئیات اعلان',
]);
}
} }

مشاهده پرونده

@ -11,6 +11,7 @@ use App\Models\User;
use App\Services\ActivityLogService; use App\Services\ActivityLogService;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\ProjectProgressService; use App\Services\ProjectProgressService;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -20,12 +21,14 @@ class ProjectController extends Controller
protected ActivityLogService $activityLogService, protected ActivityLogService $activityLogService,
protected NotificationService $notificationService, protected NotificationService $notificationService,
protected ProjectProgressService $projectProgressService, protected ProjectProgressService $projectProgressService,
protected ResourceAccessService $resourceAccess,
) {} ) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
try { try {
$query = Project::with(['projectManager', 'creator', 'department']); $query = $this->resourceAccess->projects($request->user())
->with(['projectManager', 'creator', 'department']);
if ($request->filled('status')) { if ($request->filled('status')) {
$query->where('status', $request->status); $query->where('status', $request->status);
@ -225,7 +228,7 @@ class ProjectController extends Controller
$project->members()->syncWithoutDetaching([ $project->members()->syncWithoutDetaching([
$request->user_id => [ $request->user_id => [
'role_in_project' => $request->role_in_project ?? 'member', 'role_in_project' => $request->role_in_project ?? 'member',
], ],
]); ]);

مشاهده پرونده

@ -5,15 +5,16 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\CommentResource; use App\Http\Resources\CommentResource;
use App\Http\Resources\FileResource; use App\Http\Resources\FileResource;
use App\Models\Notification;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Comment; use App\Models\Comment;
use App\Models\File; use App\Models\File;
use App\Models\Notification;
use App\Models\Project; use App\Models\Project;
use App\Models\Sprint; use App\Models\Sprint;
use App\Models\Task; use App\Models\Task;
use App\Models\User; use App\Models\User;
use App\Services\ProjectProgressService; use App\Services\ProjectProgressService;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -22,13 +23,16 @@ use Illuminate\Validation\Rule;
class PwaController extends Controller 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 public function profile(Request $request): JsonResponse
{ {
$user = $request->user()->loadMissing(['roles.permissions', 'dept', 'departments']); $user = $request->user()->loadMissing(['roles.permissions', 'dept', 'departments']);
$permissions = $user->roles $permissions = $user->roles
->flatMap(fn($role) => $role->permissions->pluck('name')) ->flatMap(fn ($role) => $role->permissions->pluck('name'))
->unique() ->unique()
->values(); ->values();
@ -44,14 +48,14 @@ class PwaController extends Controller
'department' => $user->dept?->name ?: $user->department, 'department' => $user->dept?->name ?: $user->department,
'status' => $user->status ?: 'active', 'status' => $user->status ?: 'active',
'avatar' => $user->avatar, 'avatar' => $user->avatar,
'avatar_url' => $user->avatar ? asset('storage/' . $user->avatar) : null, 'avatar_url' => $user->avatar ? asset('storage/'.$user->avatar) : null,
'roles' => $user->roles->map(fn($role) => [ 'roles' => $user->roles->map(fn ($role) => [
'id' => $role->id, 'id' => $role->id,
'name' => $role->name, 'name' => $role->name,
'display_name' => $role->display_name, 'display_name' => $role->display_name,
])->values(), ])->values(),
'primary_role' => $user->roles->first()?->display_name ?: $user->roles->first()?->name, '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, 'id' => $department->id,
'name' => $department->name, 'name' => $department->name,
'role_in_team' => $department->pivot?->role_in_team, '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']) ->with(['projectManager:id,name,job_title,avatar', 'members:id,name,job_title,avatar'])
->withCount([ ->withCount([
'tasks', 'tasks',
'tasks as done_tasks_count' => fn($taskQuery) => $taskQuery->where('status', 'done'), 'tasks as done_tasks_count' => fn ($taskQuery) => $taskQuery->where('status', 'done'),
'tasks as overdue_tasks_count' => fn($taskQuery) => $taskQuery 'tasks as overdue_tasks_count' => fn ($taskQuery) => $taskQuery
->whereDate('due_date', '<', now()->toDateString()) ->whereDate('due_date', '<', now()->toDateString())
->whereNotIn('status', ['done', 'canceled', 'cancelled']), ->whereNotIn('status', ['done', 'canceled', 'cancelled']),
]); ]);
@ -88,12 +92,12 @@ class PwaController extends Controller
'active' => $query->where('status', 'active'), 'active' => $query->where('status', 'active'),
'waiting' => $query->whereIn('status', ['waiting', 'pending']), 'waiting' => $query->whereIn('status', ['waiting', 'pending']),
'completed' => $query->whereIn('status', ['completed', 'done']), '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()) ->whereDate('due_date', '<', now()->toDateString())
->whereNotIn('status', ['done', 'canceled', 'cancelled'])), ->whereNotIn('status', ['done', 'canceled', 'cancelled'])),
'mine' => $query->where(function ($scopeQuery) use ($user) { 'mine' => $query->where(function ($scopeQuery) use ($user) {
$scopeQuery->where('project_manager_id', $user->id) $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, default => null,
}; };
@ -114,7 +118,7 @@ class PwaController extends Controller
return response()->json([ return response()->json([
'success' => true, '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' => [ 'meta' => [
'current_page' => $projects->currentPage(), 'current_page' => $projects->currentPage(),
'last_page' => $projects->lastPage(), 'last_page' => $projects->lastPage(),
@ -133,7 +137,7 @@ class PwaController extends Controller
$project->load([ $project->load([
'projectManager:id,name,job_title,avatar', 'projectManager:id,name,job_title,avatar',
'members: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']) ->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 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') ->orderByRaw('case when due_date is null then 1 else 0 end')
@ -141,11 +145,11 @@ class PwaController extends Controller
->limit(8), ->limit(8),
])->loadCount([ ])->loadCount([
'tasks', 'tasks',
'tasks as done_tasks_count' => fn($taskQuery) => $taskQuery->where('status', 'done'), 'tasks as done_tasks_count' => fn ($taskQuery) => $taskQuery->where('status', 'done'),
'tasks as overdue_tasks_count' => fn($taskQuery) => $taskQuery 'tasks as overdue_tasks_count' => fn ($taskQuery) => $taskQuery
->whereDate('due_date', '<', $today) ->whereDate('due_date', '<', $today)
->whereNotIn('status', ['done', 'canceled', 'cancelled']), ->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', 'members',
]); ]);
@ -154,7 +158,7 @@ class PwaController extends Controller
->latest() ->latest()
->limit(6) ->limit(6)
->get(['id', 'action', 'description', 'created_at']) ->get(['id', 'action', 'description', 'created_at'])
->map(fn(ActivityLog $activity) => [ ->map(fn (ActivityLog $activity) => [
'id' => $activity->id, 'id' => $activity->id,
'action' => $activity->action, 'action' => $activity->action,
'description' => $activity->description, 'description' => $activity->description,
@ -167,12 +171,12 @@ class PwaController extends Controller
'description' => $project->description, 'description' => $project->description,
'members_count' => $project->members_count, 'members_count' => $project->members_count,
'today_tasks_count' => $project->today_tasks_count, 'today_tasks_count' => $project->today_tasks_count,
'tasks' => $project->tasks->map(fn(Task $task) => $this->taskCard($task))->values(), 'tasks' => $project->tasks->map(fn (Task $task) => $this->taskCard($task))->values(),
'members' => $project->members->map(fn(User $member) => [ 'members' => $project->members->map(fn (User $member) => [
'id' => $member->id, 'id' => $member->id,
'name' => $member->name, 'name' => $member->name,
'job_title' => $member->job_title, '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, 'role_in_project' => $member->pivot?->role_in_project,
])->values(), ])->values(),
'activities' => $activities, 'activities' => $activities,
@ -199,8 +203,8 @@ class PwaController extends Controller
$project->load(['projectManager:id,name,job_title,avatar', 'members:id,name,job_title,avatar']) $project->load(['projectManager:id,name,job_title,avatar', 'members:id,name,job_title,avatar'])
->loadCount([ ->loadCount([
'tasks', 'tasks',
'tasks as done_tasks_count' => fn($taskQuery) => $taskQuery->where('status', 'done'), 'tasks as done_tasks_count' => fn ($taskQuery) => $taskQuery->where('status', 'done'),
'tasks as overdue_tasks_count' => fn($taskQuery) => $taskQuery 'tasks as overdue_tasks_count' => fn ($taskQuery) => $taskQuery
->whereDate('due_date', '<', now()->toDateString()) ->whereDate('due_date', '<', now()->toDateString())
->whereNotIn('status', ['done', 'canceled', 'cancelled']), ->whereNotIn('status', ['done', 'canceled', 'cancelled']),
]); ]);
@ -239,7 +243,7 @@ class PwaController extends Controller
return response()->json([ return response()->json([
'success' => true, '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' => [ 'meta' => [
'current_page' => $notifications->currentPage(), 'current_page' => $notifications->currentPage(),
'last_page' => $notifications->lastPage(), 'last_page' => $notifications->lastPage(),
@ -360,7 +364,7 @@ class PwaController extends Controller
$search = trim($request->input('search')); $search = trim($request->input('search'));
$query->where(function ($innerQuery) use ($search) { $query->where(function ($innerQuery) use ($search) {
$innerQuery->where('title', 'like', "%{$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([ return response()->json([
'success' => true, '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' => [ 'meta' => [
'current_page' => $tasks->currentPage(), 'current_page' => $tasks->currentPage(),
'last_page' => $tasks->lastPage(), 'last_page' => $tasks->lastPage(),
@ -467,7 +471,7 @@ class PwaController extends Controller
$uploadedFile = $request->file('file'); $uploadedFile = $request->file('file');
$extension = strtolower($uploadedFile->getClientOriginalExtension()); $extension = strtolower($uploadedFile->getClientOriginalExtension());
$name = Str::uuid()->toString() . ($extension ? ".{$extension}" : ''); $name = Str::uuid()->toString().($extension ? ".{$extension}" : '');
$path = $uploadedFile->storeAs('files', $name, 'local'); $path = $uploadedFile->storeAs('files', $name, 'local');
$file = File::create([ $file = File::create([
@ -506,7 +510,7 @@ class PwaController extends Controller
{ {
$user = $request->user()->loadMissing(['roles.permissions']); $user = $request->user()->loadMissing(['roles.permissions']);
if (!$user->hasPermission('tasks.create')) { if (! $user->hasPermission('tasks.create')) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'شما مجوز ایجاد تسک را ندارید.', 'message' => 'شما مجوز ایجاد تسک را ندارید.',
@ -515,11 +519,11 @@ class PwaController extends Controller
$projectQuery = Project::query() $projectQuery = Project::query()
->with(['members:id,name,job_title,avatar', 'projectManager:id,name,job_title,avatar']) ->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) { $query->where(function ($scopeQuery) use ($user) {
$scopeQuery->where('project_manager_id', $user->id) $scopeQuery->where('project_manager_id', $user->id)
->orWhere('created_by', $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) { ->where(function ($query) {
@ -529,15 +533,15 @@ class PwaController extends Controller
$projects = $projectQuery->limit(100)->get(); $projects = $projectQuery->limit(100)->get();
$projectUserIds = $projects $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() ->filter()
->unique() ->unique()
->values(); ->values();
$users = User::query() $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) $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) { ->where(function ($query) {
$query->where('status', 'active')->orWhereNull('status'); $query->where('status', 'active')->orWhereNull('status');
@ -549,7 +553,7 @@ class PwaController extends Controller
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'projects' => $projects->map(fn(Project $project) => [ 'projects' => $projects->map(fn (Project $project) => [
'id' => $project->id, 'id' => $project->id,
'title' => $project->title, 'title' => $project->title,
'members' => $project->members 'members' => $project->members
@ -557,13 +561,13 @@ class PwaController extends Controller
->filter() ->filter()
->unique('id') ->unique('id')
->values() ->values()
->map(fn(User $member) => [ ->map(fn (User $member) => [
'id' => $member->id, 'id' => $member->id,
'name' => $member->name, 'name' => $member->name,
'job_title' => $member->job_title, 'job_title' => $member->job_title,
]), ]),
])->values(), ])->values(),
'users' => $users->map(fn(User $optionUser) => [ 'users' => $users->map(fn (User $optionUser) => [
'id' => $optionUser->id, 'id' => $optionUser->id,
'name' => $optionUser->name, 'name' => $optionUser->name,
'job_title' => $optionUser->job_title, 'job_title' => $optionUser->job_title,
@ -585,13 +589,13 @@ class PwaController extends Controller
$query = Sprint::query()->with('project:id,title,status'); $query = Sprint::query()->with('project:id,title,status');
if (!$user->hasPermission('reports.view')) { if (! $user->hasPermission('reports.view')) {
$visibleProjectIds = $this->visibleProjectsQuery($user)->pluck('id'); $visibleProjectIds = $this->visibleProjectsQuery($user)->pluck('id');
$query->where(function ($scopeQuery) use ($user, $visibleProjectIds) { $query->where(function ($scopeQuery) use ($user, $visibleProjectIds) {
$scopeQuery $scopeQuery
->when($visibleProjectIds->isNotEmpty(), fn($innerQuery) => $innerQuery->whereIn('project_id', $visibleProjectIds)) ->when($visibleProjectIds->isNotEmpty(), fn ($innerQuery) => $innerQuery->whereIn('project_id', $visibleProjectIds))
->orWhereHas('members', fn($memberQuery) => $memberQuery->where('users.id', $user->id)) ->orWhereHas('members', fn ($memberQuery) => $memberQuery->where('users.id', $user->id))
->orWhereHas('tasks', function ($taskQuery) use ($user) { ->orWhereHas('tasks', function ($taskQuery) use ($user) {
$taskQuery $taskQuery
->where('assignee_id', $user->id) ->where('assignee_id', $user->id)
@ -611,7 +615,7 @@ class PwaController extends Controller
return response()->json([ return response()->json([
'success' => true, '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' => 'اسپرینت‌های موبایل', 'message' => 'اسپرینت‌های موبایل',
]); ]);
} }
@ -644,7 +648,7 @@ class PwaController extends Controller
->orderBy('due_date') ->orderBy('due_date')
->limit(5) ->limit(5)
->get() ->get()
->map(fn(Task $task) => $this->taskPreview($task)) ->map(fn (Task $task) => $this->taskPreview($task))
->values(); ->values();
$notifications = Notification::query() $notifications = Notification::query()
@ -652,7 +656,7 @@ class PwaController extends Controller
->latest() ->latest()
->limit(5) ->limit(5)
->get() ->get()
->map(fn(Notification $notification) => [ ->map(fn (Notification $notification) => [
'id' => $notification->id, 'id' => $notification->id,
'type' => $notification->type, 'type' => $notification->type,
'title' => $notification->title, 'title' => $notification->title,
@ -666,7 +670,7 @@ class PwaController extends Controller
->values(); ->values();
$permissions = $user->roles $permissions = $user->roles
->flatMap(fn($role) => $role->permissions->pluck('name')) ->flatMap(fn ($role) => $role->permissions->pluck('name'))
->unique() ->unique()
->values(); ->values();
@ -677,8 +681,8 @@ class PwaController extends Controller
$manageableProjectIds = $this->manageableProjectIds($user); $manageableProjectIds = $this->manageableProjectIds($user);
$teamTaskQuery = Task::query() $teamTaskQuery = Task::query()
->when($manageableProjectIds->isNotEmpty(), fn($query) => $query->whereIn('project_id', $manageableProjectIds)) ->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->isEmpty() && ! $user->hasPermission('reports.view'), fn ($query) => $query->whereRaw('1 = 0'));
$managerSummary = [ $managerSummary = [
'overdue_team_tasks' => (clone $teamTaskQuery) 'overdue_team_tasks' => (clone $teamTaskQuery)
@ -689,7 +693,7 @@ class PwaController extends Controller
'active_sprint' => Sprint::query() 'active_sprint' => Sprint::query()
->with('project:id,title') ->with('project:id,title')
->where('status', 'active') ->where('status', 'active')
->when($manageableProjectIds->isNotEmpty(), fn($query) => $query->whereIn('project_id', $manageableProjectIds)) ->when($manageableProjectIds->isNotEmpty(), fn ($query) => $query->whereIn('project_id', $manageableProjectIds))
->latest() ->latest()
->first()?->only(['id', 'title', 'project_id', 'start_date', 'end_date']), ->first()?->only(['id', 'title', 'project_id', 'start_date', 'end_date']),
'delayed_members' => (clone $teamTaskQuery) 'delayed_members' => (clone $teamTaskQuery)
@ -756,11 +760,11 @@ class PwaController extends Controller
$permissionSet = $permissions->flip(); $permissionSet = $permissions->flip();
$summary = collect($items) $summary = collect($items)
->filter(fn($item) => collect($item['permissions'])->contains(fn($permission) => $permissionSet->has($permission))) ->filter(fn ($item) => collect($item['permissions'])->contains(fn ($permission) => $permissionSet->has($permission)))
->map(fn($item) => ['label' => $item['label']]) ->map(fn ($item) => ['label' => $item['label']])
->values(); ->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('roles.edit')
|| $permissionSet->has('settings.edit'); || $permissionSet->has('settings.edit');
@ -784,18 +788,7 @@ class PwaController extends Controller
private function visibleTasksQuery(User $user) private function visibleTasksQuery(User $user)
{ {
if ($user->hasPermission('reports.view')) { return $this->resourceAccess->tasks($user);
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));
});
} }
private function abortUnlessTaskVisible(Request $request, Task $task): void private function abortUnlessTaskVisible(Request $request, Task $task): void
@ -807,15 +800,7 @@ class PwaController extends Controller
private function visibleProjectsQuery(User $user) private function visibleProjectsQuery(User $user)
{ {
if ($user->hasPermission('reports.view')) { return $this->resourceAccess->projects($user);
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));
});
} }
private function abortUnlessProjectVisible(Request $request, Project $project): void private function abortUnlessProjectVisible(Request $request, Project $project): void
@ -844,10 +829,10 @@ class PwaController extends Controller
'overdue' => $project->overdue_tasks_count ?? 0, 'overdue' => $project->overdue_tasks_count ?? 0,
], ],
'members_preview' => $project->relationLoaded('members') 'members_preview' => $project->relationLoaded('members')
? $project->members->take(4)->map(fn(User $member) => [ ? $project->members->take(4)->map(fn (User $member) => [
'id' => $member->id, 'id' => $member->id,
'name' => $member->name, 'name' => $member->name,
'avatar_url' => $member->avatar ? asset('storage/' . $member->avatar) : null, 'avatar_url' => $member->avatar ? asset('storage/'.$member->avatar) : null,
])->values() ])->values()
: [], : [],
]; ];
@ -928,7 +913,7 @@ class PwaController extends Controller
{ {
$today = now()->toDateString(); $today = now()->toDateString();
$tasks = $this->visibleTasksQuery($user) $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']) ->with(['project:id,title,status', 'assignee:id,name,job_title'])
->withCount(['comments', 'files']) ->withCount(['comments', 'files'])
->orderByRaw("case priority when 'urgent' then 0 when 'high' then 1 when 'medium' then 2 else 3 end") ->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(); $done = $tasks->where('status', 'done')->count();
$inProgress = $tasks->whereIn('status', ['in_progress', 'review'])->count(); $inProgress = $tasks->whereIn('status', ['in_progress', 'review'])->count();
$overdue = $tasks $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(); ->count();
$progress = $total > 0 ? (int) round(($done / $total) * 100) : 0; $progress = $total > 0 ? (int) round(($done / $total) * 100) : 0;
$remainingDays = $sprint->end_date ? now()->startOfDay()->diffInDays($sprint->end_date->copy()->startOfDay(), false) : null; $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, 'in_progress' => $inProgress,
'overdue' => $overdue, 'overdue' => $overdue,
], ],
'tasks' => $tasks->map(fn(Task $task) => $this->taskCard($task))->values(), 'tasks' => $tasks->map(fn (Task $task) => $this->taskCard($task))->values(),
'capabilities' => [ 'capabilities' => [
'can_create_task' => $user->hasPermission('tasks.create'), 'can_create_task' => $user->hasPermission('tasks.create'),
'can_update_task_status' => $user->hasPermission('tasks.edit'), 'can_update_task_status' => $user->hasPermission('tasks.edit'),

مشاهده پرونده

@ -33,7 +33,7 @@ class ReportController extends Controller
{ {
try { try {
$data = $this->reportService->delayedTasks($request->only([ $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([ return response()->json([
@ -53,7 +53,7 @@ class ReportController extends Controller
{ {
try { try {
$data = $this->reportService->teamPerformance($request->only([ $data = $this->reportService->teamPerformance($request->only([
'date_from', 'date_to', 'user_id' 'date_from', 'date_to', 'user_id',
])); ]));
return response()->json([ return response()->json([
@ -91,7 +91,7 @@ class ReportController extends Controller
{ {
try { try {
$data = $this->reportService->sprintProgress($request->only([ $data = $this->reportService->sprintProgress($request->only([
'project_id', 'status' 'project_id', 'status',
])); ]));
return response()->json([ return response()->json([
@ -111,7 +111,7 @@ class ReportController extends Controller
{ {
try { try {
$data = $this->reportService->timeEstimate($request->only([ $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([ return response()->json([
@ -131,7 +131,7 @@ class ReportController extends Controller
{ {
try { try {
$data = $this->reportService->recentActivities($request->only([ $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([ return response()->json([
@ -151,7 +151,7 @@ class ReportController extends Controller
{ {
try { try {
$data = $this->reportService->riskyProjects($request->only([ $data = $this->reportService->riskyProjects($request->only([
'status', 'project_id' 'status', 'project_id',
])); ]));
return response()->json([ return response()->json([

مشاهده پرونده

@ -5,8 +5,8 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\StoreRoleRequest; use App\Http\Requests\StoreRoleRequest;
use App\Http\Requests\UpdateRoleRequest; use App\Http\Requests\UpdateRoleRequest;
use App\Http\Resources\RoleResource;
use App\Http\Resources\PermissionResource; use App\Http\Resources\PermissionResource;
use App\Http\Resources\RoleResource;
use App\Models\Role; use App\Models\Role;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -95,7 +95,7 @@ class RoleController extends Controller
$permissionIds = $data['permissions'] ?? null; $permissionIds = $data['permissions'] ?? null;
unset($data['permissions']); 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'; $data['guard_name'] = 'web';
} }

مشاهده پرونده

@ -3,21 +3,21 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\BacklogItemResource;
use App\Http\Resources\MeetingResource;
use App\Http\Resources\ProjectResource; use App\Http\Resources\ProjectResource;
use App\Http\Resources\TaskResource; use App\Http\Resources\TaskResource;
use App\Http\Resources\UserResource; 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\BacklogItem;
use App\Models\User;
use App\Services\ResourceAccessService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class SearchController extends Controller class SearchController extends Controller
{ {
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
public function search(Request $request): JsonResponse public function search(Request $request): JsonResponse
{ {
try { try {
@ -25,11 +25,25 @@ class SearchController extends Controller
$keyword = $request->q; $keyword = $request->q;
$limit = $request->input('limit', 5); $limit = $request->input('limit', 5);
$projects = Project::where('title', 'like', "%{$keyword}%")->limit($limit)->get(); $projects = $this->resourceAccess->projects($request->user())
$tasks = Task::where('title', 'like', "%{$keyword}%")->limit($limit)->get(); ->where('title', 'like', "%{$keyword}%")->limit($limit)->get();
$users = User::where('name', 'like', "%{$keyword}%")->orWhere('email', 'like', "%{$keyword}%")->limit($limit)->get(); $tasks = $this->resourceAccess->tasks($request->user())
$meetings = Meeting::where('title', 'like', "%{$keyword}%")->limit($limit)->get(); ->where('title', 'like', "%{$keyword}%")->limit($limit)->get();
$backlogItems = BacklogItem::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([ return response()->json([
'success' => true, 'success' => true,

مشاهده پرونده

@ -5,8 +5,12 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\SprintResource; use App\Http\Resources\SprintResource;
use App\Http\Resources\TaskResource; use App\Http\Resources\TaskResource;
use App\Models\Meeting;
use App\Models\MeetingType;
use App\Models\Sprint; use App\Models\Sprint;
use App\Models\Task; use App\Models\Task;
use App\Services\ResourceAccessService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -14,12 +18,15 @@ use Illuminate\Validation\ValidationException;
class SprintController extends Controller class SprintController extends Controller
{ {
public function __construct(private readonly ResourceAccessService $resourceAccess) {}
private array $statuses = ['planning', 'active', 'completed', 'cancelled']; private array $statuses = ['planning', 'active', 'completed', 'cancelled'];
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
try { try {
$query = Sprint::with(['project', 'tasks', 'members']); $query = $this->resourceAccess->sprints($request->user())
->with(['project', 'tasks', 'members']);
if ($request->filled('project_id')) { if ($request->filled('project_id')) {
$query->where('project_id', $request->project_id); $query->where('project_id', $request->project_id);
@ -28,7 +35,7 @@ class SprintController extends Controller
$query->where('status', $request->status); $query->where('status', $request->status);
} }
if ($request->boolean('mine')) { 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); $perPage = min((int) $request->input('per_page', 15), 100);
@ -81,15 +88,48 @@ class SprintController extends Controller
{ {
try { try {
$data = $this->validatedSprintData($request); $data = $this->validatedSprintData($request);
abort_unless(
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
403,
'شما به پروژه انتخاب‌شده دسترسی ندارید.'
);
$memberIds = $data['member_ids'] ?? []; $memberIds = $data['member_ids'] ?? [];
$meetingSetup = $data['meeting_setup'] ?? [];
unset($data['member_ids']); unset($data['member_ids']);
unset($data['meeting_setup']);
$data['created_by'] = $request->user()->id; $data['created_by'] = $request->user()->id;
$data['status'] = 'planning'; $data['status'] = 'planning';
$sprint = DB::transaction(function () use ($data, $memberIds) { $sprint = DB::transaction(function () use ($data, $memberIds, $meetingSetup, $request) {
$sprint = Sprint::create($data); $sprint = Sprint::create($data);
$sprint->members()->sync($memberIds); $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; return $sprint;
}); });
@ -111,6 +151,13 @@ class SprintController extends Controller
{ {
try { try {
$data = $this->validatedSprintData($request, true); $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; $memberIds = $data['member_ids'] ?? null;
unset($data['member_ids']); unset($data['member_ids']);
@ -206,7 +253,7 @@ class SprintController extends Controller
'status' => 'required|string|in:waiting,todo,in_progress,review,done', '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); return response()->json(['success' => false, 'message' => 'این تسک در Sprint انتخاب‌شده نیست'], 404);
} }
@ -227,7 +274,7 @@ class SprintController extends Controller
public function updateStatus(Request $request, Sprint $sprint): JsonResponse public function updateStatus(Request $request, Sprint $sprint): JsonResponse
{ {
try { try {
$request->validate(['status' => 'required|string|in:' . implode(',', $this->statuses)]); $request->validate(['status' => 'required|string|in:'.implode(',', $this->statuses)]);
$sprint->update(['status' => $request->status]); $sprint->update(['status' => $request->status]);
return response()->json([ return response()->json([
@ -330,7 +377,7 @@ class SprintController extends Controller
public function cancel(Sprint $sprint): JsonResponse public function cancel(Sprint $sprint): JsonResponse
{ {
try { 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); 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, 'completion_percentage' => $totalTasks > 0 ? round(($completedTasks / $totalTasks) * 100, 2) : 0,
'completed_by_member' => $tasks 'completed_by_member' => $tasks
->where('status', 'done') ->where('status', 'done')
->groupBy(fn($task) => $task->assignee?->name ?? 'بدون مسئول') ->groupBy(fn ($task) => $task->assignee?->name ?? 'بدون مسئول')
->map(fn($items, $name) => ['name' => $name, 'completed' => $items->count()]) ->map(fn ($items, $name) => ['name' => $name, 'completed' => $items->count()])
->values(), ->values(),
'unfinished_tasks' => TaskResource::collection($tasks->where('status', '!=', 'done')->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 private function validatedSprintData(Request $request, bool $partial = false): array
{ {
$required = $partial ? 'sometimes|required' : 'required'; $required = $partial ? 'sometimes|required' : 'required';
@ -420,6 +527,18 @@ class SprintController extends Controller
'end_date' => "{$required}|date|after_or_equal:start_date", 'end_date' => "{$required}|date|after_or_equal:start_date",
'member_ids' => 'nullable|array', 'member_ids' => 'nullable|array',
'member_ids.*' => 'exists:users,id', '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',
]); ]);
} }
} }

مشاهده پرونده

@ -10,6 +10,7 @@ use App\Models\Task;
use App\Services\ActivityLogService; use App\Services\ActivityLogService;
use App\Services\NotificationService; use App\Services\NotificationService;
use App\Services\ProjectProgressService; use App\Services\ProjectProgressService;
use App\Services\ResourceAccessService;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -20,12 +21,15 @@ class TaskController extends Controller
protected ActivityLogService $activityLogService, protected ActivityLogService $activityLogService,
protected NotificationService $notificationService, protected NotificationService $notificationService,
protected ProjectProgressService $projectProgressService, protected ProjectProgressService $projectProgressService,
protected ResourceAccessService $resourceAccess,
) {} ) {}
public function index(Request $request): JsonResponse public function index(Request $request): JsonResponse
{ {
try { 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')) { if ($request->filled('project_id')) {
$query->where('project_id', $request->project_id); $query->where('project_id', $request->project_id);
@ -92,6 +96,11 @@ class TaskController extends Controller
{ {
try { try {
$data = $request->validated(); $data = $request->validated();
abort_unless(
$this->resourceAccess->projects($request->user())->whereKey($data['project_id'])->exists(),
403,
'شما به پروژه انتخاب‌شده دسترسی ندارید.'
);
$data['reporter_id'] = $request->user()->id; $data['reporter_id'] = $request->user()->id;
$data['created_by'] = $request->user()->id; $data['created_by'] = $request->user()->id;
$task = Task::create($data); $task = Task::create($data);
@ -142,8 +151,16 @@ class TaskController extends Controller
public function update(UpdateTaskRequest $request, Task $task): JsonResponse public function update(UpdateTaskRequest $request, Task $task): JsonResponse
{ {
try { 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; $oldStatus = $task->status;
$task->update($request->validated()); $task->update($validated);
if ($task->wasChanged('status')) { if ($task->wasChanged('status')) {
$this->activityLogService->log( $this->activityLogService->log(

مشاهده پرونده

@ -6,8 +6,8 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\StoreUserRequest; use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest; use App\Http\Requests\UpdateUserRequest;
use App\Http\Resources\UserResource; use App\Http\Resources\UserResource;
use App\Models\User;
use App\Models\Task; use App\Models\Task;
use App\Models\User;
use App\Services\WorkloadService; use App\Services\WorkloadService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -26,7 +26,7 @@ class UserController extends Controller
$search = $request->search; $search = $request->search;
$query->where(function ($q) use ($search) { $query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%") $q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"); ->orWhere('email', 'like', "%{$search}%");
}); });
} }
@ -103,13 +103,13 @@ class UserController extends Controller
{ {
try { try {
$data = $request->validated(); $data = $request->validated();
if (!empty($data['password'])) { if (! empty($data['password'])) {
$data['password'] = Hash::make($data['password']); $data['password'] = Hash::make($data['password']);
} else { } else {
unset($data['password']); unset($data['password']);
} }
$user->update($data); $user->update($data);
if (!empty($data['role_id'])) { if (! empty($data['role_id'])) {
$user->roles()->sync([$data['role_id']]); $user->roles()->sync([$data['role_id']]);
} }
$user->load(['role', 'roles.permissions']); $user->load(['role', 'roles.permissions']);

مشاهده پرونده

@ -0,0 +1,71 @@
<?php
namespace App\Http\Middleware;
use App\Models\BacklogItem;
use App\Models\Checklist;
use App\Models\Comment;
use App\Models\File;
use App\Models\Meeting;
use App\Models\MeetingActionItem;
use App\Models\Notification;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Subtask;
use App\Models\Task;
use App\Services\ResourceAccessService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureResourceAccess
{
public function __construct(private readonly ResourceAccessService $access) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->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);
}
}

مشاهده پرونده

@ -12,7 +12,7 @@ class EnsureUserHasPermission
{ {
$user = $request->user(); $user = $request->user();
if (!$user) { if (! $user) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'Unauthenticated.', 'message' => 'Unauthenticated.',
@ -23,7 +23,7 @@ class EnsureUserHasPermission
$isAdmin = $user->roles->contains('name', 'admin'); $isAdmin = $user->roles->contains('name', 'admin');
if (!$isAdmin && !$user->hasPermission($permission)) { if (! $isAdmin && ! $user->hasPermission($permission)) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'شما دسترسی لازم برای انجام این عملیات را ندارید', 'message' => 'شما دسترسی لازم برای انجام این عملیات را ندارید',

مشاهده پرونده

@ -18,7 +18,7 @@ class SecurityHeaders
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); $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( $response->headers->set(
'Content-Security-Policy', 'Content-Security-Policy',
"default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'" "default-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'"

مشاهده پرونده

@ -15,7 +15,7 @@ class ChangePasswordRequest extends FormRequest
{ {
return [ return [
'current_password' => 'required', 'current_password' => 'required',
'new_password' => 'required|min:8|confirmed', 'new_password' => 'required|string|min:12|confirmed',
]; ];
} }
} }

مشاهده پرونده

@ -16,10 +16,23 @@ class StoreMeetingRequest extends FormRequest
return [ return [
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'project_id' => 'nullable|exists:projects,id', 'project_id' => 'nullable|exists:projects,id',
'sprint_id' => 'nullable|exists:sprints,id',
'meeting_type_id' => 'nullable|exists:meeting_types,id',
'date' => 'required|date', 'date' => 'required|date',
'start_time' => 'nullable', 'start_time' => 'nullable|date_format:H:i',
'end_time' => 'nullable', 'end_time' => 'nullable|date_format:H:i|after:start_time',
'meeting_type' => 'required|string|max:50', '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',
]; ];
} }
} }

مشاهده پرونده

@ -54,7 +54,7 @@ class StoreTaskRequest extends FormRequest
$user = $this->user()?->loadMissing('roles.permissions'); $user = $this->user()?->loadMissing('roles.permissions');
$project = Project::with('members:id')->find($this->input('project_id')); $project = Project::with('members:id')->find($this->input('project_id'));
if (!$user || !$project) { if (! $user || ! $project) {
return; return;
} }
@ -63,7 +63,7 @@ class StoreTaskRequest extends FormRequest
|| (int) $project->created_by === (int) $user->id || (int) $project->created_by === (int) $user->id
|| $project->members->contains('id', $user->id); || $project->members->contains('id', $user->id);
if (!$canUseProject) { if (! $canUseProject) {
$validator->errors()->add('project_id', 'شما به این پروژه دسترسی ندارید.'); $validator->errors()->add('project_id', 'شما به این پروژه دسترسی ندارید.');
} }
@ -76,7 +76,7 @@ class StoreTaskRequest extends FormRequest
|| (int) $project->project_manager_id === (int) $assignee->id || (int) $project->project_manager_id === (int) $assignee->id
); );
if (!$assigneeAllowed) { if (! $assigneeAllowed) {
$validator->errors()->add('assignee_id', 'مسئول انتخاب‌شده برای این پروژه مجاز نیست.'); $validator->errors()->add('assignee_id', 'مسئول انتخاب‌شده برای این پروژه مجاز نیست.');
} }
} }

مشاهده پرونده

@ -24,7 +24,7 @@ class UpdateProfileRequest extends FormRequest
{ {
return [ return [
'name' => 'required|string|max:255', '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', 'phone' => 'nullable|string|max:50',
'job_title' => 'nullable|string|max:255', 'job_title' => 'nullable|string|max:255',
'department' => 'nullable|string|max:255', 'department' => 'nullable|string|max:255',

مشاهده پرونده

@ -14,8 +14,9 @@ class UpdateRoleRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
$roleId = $this->route('role'); $roleId = $this->route('role');
return [ 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', 'display_name' => 'sometimes|required|string|max:255',
'description' => 'nullable|string|max:1000', 'description' => 'nullable|string|max:1000',
'guard_name' => 'nullable|string|max:50', 'guard_name' => 'nullable|string|max:50',

مشاهده پرونده

@ -30,9 +30,10 @@ class UpdateUserRequest extends FormRequest
{ {
$userId = $this->route('user'); $userId = $this->route('user');
$userId = is_object($userId) ? $userId->id : $userId; $userId = is_object($userId) ? $userId->id : $userId;
return [ return [
'name' => 'sometimes|required|string|max:255', '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', 'phone' => 'nullable|string|max:50',
'job_title' => 'nullable|string|max:255', 'job_title' => 'nullable|string|max:255',
'department' => 'nullable|string|max:255', 'department' => 'nullable|string|max:255',

مشاهده پرونده

@ -14,19 +14,38 @@ class MeetingResource extends JsonResource
'title' => $this->title, 'title' => $this->title,
'project_id' => $this->project_id, 'project_id' => $this->project_id,
'project' => new ProjectResource($this->whenLoaded('project')), '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'), 'date' => $this->date?->format('Y-m-d'),
'start_time' => $this->start_time, 'start_time' => $this->start_time,
'end_time' => $this->end_time, 'end_time' => $this->end_time,
'location' => $this->location, 'location' => $this->location,
'meeting_link' => $this->meeting_link, 'meeting_link' => $this->meeting_link,
'meeting_type' => $this->meeting_type, 'meeting_type' => $this->meeting_type,
'status' => $this->status ?? 'scheduled',
'objective' => $this->objective,
'agenda' => $this->agenda, 'agenda' => $this->agenda,
'notes' => $this->notes, 'notes' => $this->notes,
'summary' => $this->summary,
'decisions' => $this->decisions, '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')), 'participants' => UserResource::collection($this->whenLoaded('participants')),
'action_items' => MeetingActionItemResource::collection($this->whenLoaded('actionItems')), 'action_items' => MeetingActionItemResource::collection($this->whenLoaded('actionItems')),
'created_by' => $this->created_by, 'created_by' => $this->created_by,
'creator' => new UserResource($this->whenLoaded('creator')), '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, 'created_at' => $this->created_at,
'updated_at' => $this->updated_at, 'updated_at' => $this->updated_at,
]; ];

مشاهده پرونده

@ -9,6 +9,20 @@ class NotificationResource extends JsonResource
{ {
public function toArray(Request $request): array 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 [ return [
'id' => $this->id, 'id' => $this->id,
'type' => $this->type, 'type' => $this->type,
@ -20,6 +34,16 @@ class NotificationResource extends JsonResource
'notifiable_id' => $this->notifiable_id, 'notifiable_id' => $this->notifiable_id,
'is_read' => $this->is_read, 'is_read' => $this->is_read,
'read_at' => $this->read_at, '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, 'created_at' => $this->created_at,
'updated_at' => $this->updated_at, 'updated_at' => $this->updated_at,
]; ];

مشاهده پرونده

@ -17,7 +17,7 @@ class ProjectResource extends JsonResource
'project_manager_id' => $this->project_manager_id, 'project_manager_id' => $this->project_manager_id,
'project_manager' => new UserResource($this->whenLoaded('projectManager')), 'project_manager' => new UserResource($this->whenLoaded('projectManager')),
'department_id' => $this->department_id, 'department_id' => $this->department_id,
'department' => $this->whenLoaded('department', fn() => [ 'department' => $this->whenLoaded('department', fn () => [
'id' => $this->department?->id, 'id' => $this->department?->id,
'name' => $this->department?->name, 'name' => $this->department?->name,
'type' => $this->department?->type, 'type' => $this->department?->type,

مشاهده پرونده

@ -9,8 +9,8 @@ class SprintResource extends JsonResource
{ {
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$totalTasks = $this->whenLoaded('tasks', fn() => $this->tasks->count(), 0); $totalTasks = $this->whenLoaded('tasks', fn () => $this->tasks->count(), 0);
$completedTasks = $this->whenLoaded('tasks', fn() => $this->tasks->where('status', 'done')->count(), 0); $completedTasks = $this->whenLoaded('tasks', fn () => $this->tasks->where('status', 'done')->count(), 0);
$remainingTasks = max($totalTasks - $completedTasks, 0); $remainingTasks = max($totalTasks - $completedTasks, 0);
return [ return [
@ -25,7 +25,7 @@ class SprintResource extends JsonResource
'status' => $this->status, 'status' => $this->status,
'tasks' => TaskResource::collection($this->whenLoaded('tasks')), 'tasks' => TaskResource::collection($this->whenLoaded('tasks')),
'members' => UserResource::collection($this->whenLoaded('members')), 'members' => UserResource::collection($this->whenLoaded('members')),
'retrospective' => $this->whenLoaded('retrospective', fn() => [ 'retrospective' => $this->whenLoaded('retrospective', fn () => [
'went_well' => $this->retrospective?->went_well, 'went_well' => $this->retrospective?->went_well,
'problems' => $this->retrospective?->problems, 'problems' => $this->retrospective?->problems,
'improvements' => $this->retrospective?->improvements, 'improvements' => $this->retrospective?->improvements,

مشاهده پرونده

@ -17,8 +17,8 @@ class UserResource extends JsonResource
'job_title' => $this->job_title, 'job_title' => $this->job_title,
'department' => $this->department, 'department' => $this->department,
'department_id' => $this->department_id, 'department_id' => $this->department_id,
'department_name' => $this->when($this->relationLoaded('dept'), fn() => $this->dept?->name), 'department_name' => $this->when($this->relationLoaded('dept'), fn () => $this->dept?->name),
'departments' => $this->when($this->relationLoaded('departments'), fn() => $this->departments->map(fn($department) => [ 'departments' => $this->when($this->relationLoaded('departments'), fn () => $this->departments->map(fn ($department) => [
'id' => $department->id, 'id' => $department->id,
'name' => $department->name, 'name' => $department->name,
'type' => $department->type, 'type' => $department->type,
@ -29,7 +29,7 @@ class UserResource extends JsonResource
'status' => $this->status, 'status' => $this->status,
'skills' => $this->skills, 'skills' => $this->skills,
'avatar' => $this->avatar, '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_id' => $this->role_id,
'role' => new RoleResource($this->whenLoaded('role')), 'role' => new RoleResource($this->whenLoaded('role')),
'roles' => RoleResource::collection($this->whenLoaded('roles')), 'roles' => RoleResource::collection($this->whenLoaded('roles')),

مشاهده پرونده

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class ActionItem extends Model
{
use SoftDeletes;
protected $guarded = [];
protected $casts = ['due_date' => '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);
}
}

مشاهده پرونده

@ -9,7 +9,7 @@ class ActivityLog extends Model
{ {
protected $fillable = [ protected $fillable = [
'user_id', 'action', 'description', 'subject_type', 'subject_id', 'user_id', 'action', 'description', 'subject_type', 'subject_id',
'project_id', 'task_id', 'properties' 'project_id', 'task_id', 'properties',
]; ];
protected $casts = [ protected $casts = [

مشاهده پرونده

@ -9,7 +9,7 @@ class BacklogItem extends Model
{ {
protected $fillable = [ protected $fillable = [
'title', 'description', 'type', 'project_id', 'priority', '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 public function project(): BelongsTo

18
backend/app/Models/Blocker.php normal فایل
مشاهده پرونده

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Blocker extends Model
{
use SoftDeletes;
protected $guarded = [];
protected $casts = [
'due_date' => 'date',
'resolved_at' => 'datetime',
];
}

18
backend/app/Models/Decision.php normal فایل
مشاهده پرونده

@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Decision extends Model
{
use SoftDeletes;
protected $guarded = [];
protected $casts = [
'effective_date' => 'date',
'review_date' => 'date',
];
}

مشاهده پرونده

@ -11,12 +11,17 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
class Meeting extends Model class Meeting extends Model
{ {
protected $fillable = [ protected $fillable = [
'title', 'project_id', 'date', 'start_time', 'end_time', 'location', 'title', 'project_id', 'sprint_id', 'meeting_type_id', 'date', 'start_time',
'meeting_link', 'meeting_type', 'agenda', 'notes', 'decisions', 'created_by' '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 = [ protected $casts = [
'date' => 'date', 'date' => 'date',
'recurrence_rule' => 'array',
'started_at' => 'datetime',
'completed_at' => 'datetime',
]; ];
public function project(): BelongsTo public function project(): BelongsTo
@ -29,6 +34,26 @@ class Meeting extends Model
return $this->belongsTo(User::class, 'created_by'); 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 public function participants(): BelongsToMany
{ {
return $this->belongsToMany(User::class, 'meeting_user')->withTimestamps(); return $this->belongsToMany(User::class, 'meeting_user')->withTimestamps();
@ -39,6 +64,21 @@ class Meeting extends Model
return $this->hasMany(MeetingActionItem::class); 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 public function comments(): MorphMany
{ {
return $this->morphMany(Comment::class, 'commentable'); return $this->morphMany(Comment::class, 'commentable');

مشاهده پرونده

@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MeetingActionItem extends Model class MeetingActionItem extends Model
{ {
protected $fillable = [ 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 = [ protected $casts = [

مشاهده پرونده

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class MeetingType extends Model
{
protected $fillable = [
'key', 'name', 'default_title', 'suggested_duration', 'agenda_template',
'minutes_template', 'participant_roles', 'recurrence_rule', 'settings', 'is_active',
];
protected $casts = [
'participant_roles' => 'array',
'recurrence_rule' => 'array',
'settings' => 'array',
'is_active' => 'boolean',
];
public function meetings(): HasMany
{
return $this->hasMany(Meeting::class);
}
}

مشاهده پرونده

@ -3,12 +3,15 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Notification extends Model class Notification extends Model
{ {
use SoftDeletes;
protected $fillable = [ protected $fillable = [
'user_id', 'title', 'body', 'type', 'notifiable_type', 'notifiable_id', '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 = [ protected $casts = [
@ -17,5 +20,6 @@ class Notification extends Model
'read_at' => 'datetime', 'read_at' => 'datetime',
'remind_at' => 'datetime', 'remind_at' => 'datetime',
'dismissed_at' => 'datetime', 'dismissed_at' => 'datetime',
'archived_at' => 'datetime',
]; ];
} }

مشاهده پرونده

@ -12,7 +12,7 @@ class Project extends Model
protected $fillable = [ protected $fillable = [
'title', 'description', 'client', 'project_manager_id', 'department_id', 'start_date', 'end_date', 'title', 'description', 'client', 'project_manager_id', 'department_id', 'start_date', 'end_date',
'priority', 'status', 'progress', 'risk_level', 'budget', 'estimated_hours', '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 = [ protected $casts = [

مشاهده پرونده

@ -2,8 +2,8 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class Setting extends Model class Setting extends Model
{ {

مشاهده پرونده

@ -52,4 +52,29 @@ class Sprint extends Model
{ {
return $this->hasMany(BacklogItem::class, 'assigned_sprint_id'); 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);
}
} }

مشاهده پرونده

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SprintScopeChange extends Model
{
protected $guarded = [];
protected $casts = [
'previous_state' => 'array',
'new_state' => 'array',
'capacity_impact' => 'decimal:2',
];
}

مشاهده پرونده

@ -12,7 +12,7 @@ class Task extends Model
protected $fillable = [ protected $fillable = [
'title', 'description', 'project_id', 'assignee_id', 'reporter_id', 'title', 'description', 'project_id', 'assignee_id', 'reporter_id',
'priority', 'status', 'blocker_type', 'blocker_note', 'start_date', 'due_date', 'estimated_time', '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 = [ protected $casts = [

مشاهده پرونده

@ -63,6 +63,7 @@ class User extends Authenticatable
return true; return true;
} }
} }
return false; return false;
} }
@ -73,6 +74,7 @@ class User extends Authenticatable
return true; return true;
} }
} }
return false; return false;
} }

مشاهده پرونده

@ -0,0 +1,182 @@
<?php
namespace App\Services;
use App\Models\ActionItem;
use App\Models\Meeting;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Task;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class CalendarService
{
public function __construct(private readonly ResourceAccessService $access) {}
public function events(User $user, Carbon $from, Carbon $to, array $types = []): Collection
{
$enabled = fn (string $type): bool => $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();
}
}

مشاهده پرونده

@ -2,11 +2,14 @@
namespace App\Services; namespace App\Services;
use App\Models\Project; use App\Models\ActionItem;
use App\Models\Task;
use App\Models\Meeting;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Blocker;
use App\Models\Meeting;
use App\Models\Project;
use App\Models\Sprint; use App\Models\Sprint;
use App\Models\Task;
use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
class DashboardService class DashboardService
@ -34,7 +37,7 @@ class DashboardService
->orderBy('due_date') ->orderBy('due_date')
->limit(10) ->limit(10)
->get() ->get()
->map(fn($t) => [ ->map(fn ($t) => [
'id' => $t->id, 'id' => $t->id,
'title' => $t->title, 'title' => $t->title,
'project' => $t->project?->title, 'project' => $t->project?->title,
@ -46,7 +49,7 @@ class DashboardService
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->limit(10) ->limit(10)
->get() ->get()
->map(fn($log) => [ ->map(fn ($log) => [
'id' => $log->id, 'id' => $log->id,
'description' => $log->description, 'description' => $log->description,
'user' => $log->user?->name ?? '?', 'user' => $log->user?->name ?? '?',
@ -67,7 +70,7 @@ class DashboardService
if ($user) { if ($user) {
$sprint = Sprint::with(['project', 'tasks.assignee']) $sprint = Sprint::with(['project', 'tasks.assignee'])
->where('status', 'active') ->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') ->orderBy('end_date')
->first(); ->first();
@ -84,7 +87,7 @@ class DashboardService
'my_tasks' => $sprint->tasks 'my_tasks' => $sprint->tasks
->where('assignee_id', $user->id) ->where('assignee_id', $user->id)
->values() ->values()
->map(fn($task) => [ ->map(fn ($task) => [
'id' => $task->id, 'id' => $task->id,
'title' => $task->title, 'title' => $task->title,
'status' => $task->status, 'status' => $task->status,
@ -94,7 +97,7 @@ class DashboardService
} }
$todayMeetings = Meeting::whereDate('date', Carbon::today())->count(); $todayMeetings = Meeting::whereDate('date', Carbon::today())->count();
$teamMembers = \App\Models\User::count(); $teamMembers = User::count();
$projectProgresses = Project::where('is_archived', false) $projectProgresses = Project::where('is_archived', false)
->select(['id', 'title', 'progress', 'status']) ->select(['id', 'title', 'progress', 'status'])
@ -159,4 +162,161 @@ class DashboardService
'monthly_tasks' => $monthlyTasks, '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'],
];
}
} }

مشاهده پرونده

@ -14,6 +14,7 @@ class ProjectProgressService
if ($totalTasks === 0) { if ($totalTasks === 0) {
$project->update(['progress' => 0]); $project->update(['progress' => 0]);
return 0; return 0;
} }

مشاهده پرونده

@ -2,10 +2,10 @@
namespace App\Services; namespace App\Services;
use App\Models\Project;
use App\Models\Task;
use App\Models\Sprint;
use App\Models\ActivityLog; use App\Models\ActivityLog;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Task;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
@ -13,12 +13,13 @@ class ReportService
{ {
protected function applyDateFilters($query, $filters, $dateField = 'created_at') protected function applyDateFilters($query, $filters, $dateField = 'created_at')
{ {
if (!empty($filters['date_from'])) { if (! empty($filters['date_from'])) {
$query->where($dateField, '>=', $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']); $query->where($dateField, '<=', $filters['date_to']);
} }
return $query; return $query;
} }
@ -26,17 +27,17 @@ class ReportService
{ {
$query = Project::query(); $query = Project::query();
if (!empty($filters['status'])) { if (! empty($filters['status'])) {
$query->where('status', $filters['status']); $query->where('status', $filters['status']);
} }
if (!empty($filters['project_id'])) { if (! empty($filters['project_id'])) {
$query->where('id', $filters['project_id']); $query->where('id', $filters['project_id']);
} }
$projects = $query->get(); $projects = $query->get();
$grouped = $projects->groupBy('status'); $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))), 'status' => $status === 'planning' ? 'برنامه‌ریزی' : ($status === 'in_progress' ? 'در حال انجام' : ($status === 'done' ? 'تکمیل شده' : ($status === 'on_hold' ? 'متوقف' : $status))),
'count' => $items->count(), 'count' => $items->count(),
'label' => $status, 'label' => $status,
@ -52,17 +53,17 @@ class ReportService
$query = $this->applyDateFilters($query, $filters, 'due_date'); $query = $this->applyDateFilters($query, $filters, 'due_date');
if (!empty($filters['project_id'])) { if (! empty($filters['project_id'])) {
$query->where('project_id', $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']); $query->where('assignee_id', $filters['user_id']);
} }
if (!empty($filters['status'])) { if (! empty($filters['status'])) {
$query->where('status', $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, 'id' => $t->id,
'title' => $t->title, 'title' => $t->title,
'project' => ['title' => $t->project?->title], 'project' => ['title' => $t->project?->title],
@ -79,15 +80,15 @@ class ReportService
public function teamPerformance($filters = []) public function teamPerformance($filters = [])
{ {
$query = User::with(['tasks' => function ($q) use ($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']); $q->where('created_at', '>=', $filters['date_from']);
} }
if (!empty($filters['date_to'])) { if (! empty($filters['date_to'])) {
$q->where('created_at', '<=', $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']); $query->where('id', $filters['user_id']);
} }
@ -98,6 +99,7 @@ class ReportService
$completedTasks = $user->tasks->where('status', 'done')->count(); $completedTasks = $user->tasks->where('status', 'done')->count();
$delayedTasks = $user->tasks->where('due_date', '<', Carbon::now())->where('status', '!=', 'done')->count(); $delayedTasks = $user->tasks->where('due_date', '<', Carbon::now())->where('status', '!=', 'done')->count();
$inProgress = $user->tasks->whereNotIn('status', ['done', 'canceled'])->count(); $inProgress = $user->tasks->whereNotIn('status', ['done', 'canceled'])->count();
return [ return [
'name' => $user->name, 'name' => $user->name,
'completed' => $completedTasks, 'completed' => $completedTasks,
@ -112,7 +114,7 @@ class ReportService
{ {
$query = User::query(); $query = User::query();
if (!empty($filters['user_id'])) { if (! empty($filters['user_id'])) {
$query->where('id', $filters['user_id']); $query->where('id', $filters['user_id']);
} }
@ -140,10 +142,10 @@ class ReportService
{ {
$query = Sprint::with(['project:id,title', 'tasks']); $query = Sprint::with(['project:id,title', 'tasks']);
if (!empty($filters['project_id'])) { if (! empty($filters['project_id'])) {
$query->where('project_id', $filters['project_id']); $query->where('project_id', $filters['project_id']);
} }
if (!empty($filters['status'])) { if (! empty($filters['status'])) {
$query->where('status', $filters['status']); $query->where('status', $filters['status']);
} }
@ -152,6 +154,7 @@ class ReportService
return $sprints->map(function ($sprint) { return $sprints->map(function ($sprint) {
$totalTasks = $sprint->tasks->count(); $totalTasks = $sprint->tasks->count();
$completedTasks = $sprint->tasks->where('status', 'done')->count(); $completedTasks = $sprint->tasks->where('status', 'done')->count();
return [ return [
'id' => $sprint->id, 'id' => $sprint->id,
'title' => $sprint->title, 'title' => $sprint->title,
@ -171,13 +174,13 @@ class ReportService
$query = Task::with('project'); $query = Task::with('project');
$query = $this->applyDateFilters($query, $filters); $query = $this->applyDateFilters($query, $filters);
if (!empty($filters['project_id'])) { if (! empty($filters['project_id'])) {
$query->where('project_id', $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']); $query->where('assignee_id', $filters['user_id']);
} }
if (!empty($filters['status'])) { if (! empty($filters['status'])) {
$query->where('status', $filters['status']); $query->where('status', $filters['status']);
} }
@ -185,6 +188,7 @@ class ReportService
return $tasks->map(function ($task) { return $tasks->map(function ($task) {
$diff = ($task->estimated_time ?? 0) - ($task->actual_time ?? 0); $diff = ($task->estimated_time ?? 0) - ($task->actual_time ?? 0);
return [ return [
'id' => $task->id, 'id' => $task->id,
'title' => $task->title, 'title' => $task->title,
@ -205,17 +209,17 @@ class ReportService
$query = ActivityLog::with('user'); $query = ActivityLog::with('user');
$query = $this->applyDateFilters($query, $filters); $query = $this->applyDateFilters($query, $filters);
if (!empty($filters['project_id'])) { if (! empty($filters['project_id'])) {
$query->where('project_id', $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']); $query->where('user_id', $filters['user_id']);
} }
if (!empty($filters['action'])) { if (! empty($filters['action'])) {
$query->where('action', $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, 'id' => $log->id,
'description' => $log->description, 'description' => $log->description,
'user' => $log->user?->name ?? '?', 'user' => $log->user?->name ?? '?',
@ -229,14 +233,14 @@ class ReportService
{ {
$query = Project::whereIn('risk_level', ['high', 'critical'])->where('is_archived', false); $query = Project::whereIn('risk_level', ['high', 'critical'])->where('is_archived', false);
if (!empty($filters['status'])) { if (! empty($filters['status'])) {
$query->where('status', $filters['status']); $query->where('status', $filters['status']);
} }
if (!empty($filters['project_id'])) { if (! empty($filters['project_id'])) {
$query->where('id', $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, 'id' => $p->id,
'title' => $p->title, 'title' => $p->title,
'risk_level' => $p->risk_level, 'risk_level' => $p->risk_level,

مشاهده پرونده

@ -0,0 +1,163 @@
<?php
namespace App\Services;
use App\Models\Comment;
use App\Models\File;
use App\Models\Meeting;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Task;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class ResourceAccessService
{
public function canSeeAll(User $user): bool
{
$user->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();
}
}

مشاهده پرونده

@ -2,8 +2,8 @@
namespace App\Services; namespace App\Services;
use App\Models\User;
use App\Models\Task; use App\Models\Task;
use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
class WorkloadService class WorkloadService

مشاهده پرونده

@ -1,8 +1,9 @@
<?php <?php
use Illuminate\Auth\AuthenticationException; use App\Http\Middleware\EnsureResourceAccess;
use App\Http\Middleware\EnsureUserHasPermission; use App\Http\Middleware\EnsureUserHasPermission;
use App\Http\Middleware\SecurityHeaders; use App\Http\Middleware\SecurityHeaders;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@ -19,6 +20,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->append(SecurityHeaders::class); $middleware->append(SecurityHeaders::class);
$middleware->alias([ $middleware->alias([
'permission' => EnsureUserHasPermission::class, 'permission' => EnsureUserHasPermission::class,
'resource.access' => EnsureResourceAccess::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

مشاهده پرونده

@ -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' => [ 'mariadb' => [
'driver' => 'mariadb', 'driver' => 'mariadb',
'url' => env('DB_URL'), 'url' => env('DB_URL'),

مشاهده پرونده

@ -15,7 +15,7 @@ return [
'guard' => ['web'], 'guard' => ['web'],
'expiration' => null, 'expiration' => (int) env('SANCTUM_TOKEN_EXPIRATION', 480),
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),

مشاهده پرونده

@ -35,13 +35,13 @@ return new class extends Migration
$roleIds = DB::table('roles')->pluck('id', 'name'); $roleIds = DB::table('roles')->pluck('id', 'name');
$permissionIds = DB::table('permissions')->pluck('id', 'name'); $permissionIds = DB::table('permissions')->pluck('id', 'name');
$syncRole = function (string $roleName, array $modules, array $actions) use ($roleIds, $permissionIds, $now) { $syncRole = function (string $roleName, array $modules, array $actions) use ($roleIds, $permissionIds, $now) {
if (!$roleIds->has($roleName)) { if (! $roleIds->has($roleName)) {
return; return;
} }
foreach ($permissionIds as $permissionName => $permissionId) { foreach ($permissionIds as $permissionName => $permissionId) {
[$module, $action] = explode('.', $permissionName); [$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; continue;
} }

مشاهده پرونده

@ -10,7 +10,7 @@ return new class extends Migration
$legacyId = DB::table('roles')->where('name', 'team_member')->value('id'); $legacyId = DB::table('roles')->where('name', 'team_member')->value('id');
$specialistId = DB::table('roles')->where('name', 'specialist')->value('id'); $specialistId = DB::table('roles')->where('name', 'specialist')->value('id');
if (!$legacyId || !$specialistId) { if (! $legacyId || ! $specialistId) {
return; return;
} }

مشاهده پرونده

@ -10,7 +10,7 @@ return new class extends Migration
DB::table('users')->whereNull('status')->update(['status' => 'active']); DB::table('users')->whereNull('status')->update(['status' => 'active']);
$specialistId = DB::table('roles')->where('name', 'specialist')->value('id'); $specialistId = DB::table('roles')->where('name', 'specialist')->value('id');
if (!$specialistId) { if (! $specialistId) {
return; return;
} }

مشاهده پرونده

@ -10,13 +10,13 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::table('sprints', function (Blueprint $table) { 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'); $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'); $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'); $table->json('completion_summary')->nullable()->after('completed_at');
} }
}); });

مشاهده پرونده

@ -9,10 +9,10 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::table('departments', function (Blueprint $table) { Schema::table('departments', function (Blueprint $table) {
if (!Schema::hasColumn('departments', 'type')) { if (! Schema::hasColumn('departments', 'type')) {
$table->string('type')->default('department')->after('description'); $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'); $table->timestamp('manager_changed_at')->nullable()->after('manager_id');
} }
}); });

مشاهده پرونده

@ -9,7 +9,7 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::table('projects', function (Blueprint $table) { 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'); $table->foreignId('department_id')->nullable()->after('project_manager_id')->constrained('departments')->onDelete('set null');
} }
}); });

مشاهده پرونده

@ -9,10 +9,10 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::table('tasks', function (Blueprint $table) { 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'); $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'); $table->text('blocker_note')->nullable()->after('blocker_type');
} }
}); });

مشاهده پرونده

@ -9,10 +9,10 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::table('notifications', function (Blueprint $table) { 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'); $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'); $table->unsignedBigInteger('notifiable_id')->nullable()->after('notifiable_type');
} }
}); });

مشاهده پرونده

@ -9,7 +9,7 @@ return new class extends Migration
public function up(): void public function up(): void
{ {
Schema::table('settings', function (Blueprint $table) { Schema::table('settings', function (Blueprint $table) {
if (!Schema::hasColumn('settings', 'type')) { if (! Schema::hasColumn('settings', 'type')) {
$table->string('type', 50)->nullable()->after('group'); $table->string('type', 50)->nullable()->after('group');
} }
}); });

مشاهده پرونده

@ -0,0 +1,186 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('meeting_types', function (Blueprint $table) {
$table->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');
}
};

مشاهده پرونده

@ -4,7 +4,7 @@ namespace Database\Seeders;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
@ -12,22 +12,25 @@ class DatabaseSeeder extends Seeder
public function run(): void public function run(): void
{ {
DB::statement('PRAGMA foreign_keys = OFF'); Schema::disableForeignKeyConstraints();
$this->call([ try {
RolePermissionSeeder::class, $this->call([
UserSeeder::class, RolePermissionSeeder::class,
ProjectSeeder::class, UserSeeder::class,
TaskSeeder::class, ProjectSeeder::class,
SprintSeeder::class, TaskSeeder::class,
BacklogSeeder::class, SprintSeeder::class,
MeetingSeeder::class, BacklogSeeder::class,
CommentSeeder::class, MeetingTypeSeeder::class,
ActivityLogSeeder::class, MeetingSeeder::class,
NotificationSeeder::class, CommentSeeder::class,
SettingSeeder::class, ActivityLogSeeder::class,
]); NotificationSeeder::class,
SettingSeeder::class,
DB::statement('PRAGMA foreign_keys = ON'); ]);
} finally {
Schema::enableForeignKeyConstraints();
}
} }
} }

مشاهده پرونده

@ -0,0 +1,27 @@
<?php
namespace Database\Seeders;
use App\Models\MeetingType;
use Illuminate\Database\Seeder;
class MeetingTypeSeeder extends Seeder
{
public function run(): void
{
$types = [
['key' => '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]);
}
}
}

مشاهده پرونده

@ -68,7 +68,7 @@ class RolePermissionSeeder extends Seeder
]; ];
$permissions[] = [ $permissions[] = [
'name' => $name, 'name' => $name,
'display_name' => $displayName . ' ' . $moduleDisplay[$module], 'display_name' => $displayName.' '.$moduleDisplay[$module],
'guard_name' => 'web', 'guard_name' => 'web',
'module' => $module, 'module' => $module,
'created_at' => now(), 'created_at' => now(),

مشاهده پرونده

@ -108,10 +108,50 @@ class SettingSeeder extends Seeder
'created_at' => $now, 'created_at' => $now,
'updated_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) { foreach ($settings as $setting) {
DB::table('settings')->insert($setting); DB::table('settings')->updateOrInsert(['key' => $setting['key']], $setting);
} }
} }
} }

مشاهده پرونده

@ -1,32 +1,34 @@
<?php <?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\DashboardController;
use App\Http\Controllers\Api\UserController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\TaskController;
use App\Http\Controllers\Api\ChecklistController;
use App\Http\Controllers\Api\SubtaskController;
use App\Http\Controllers\Api\SprintController;
use App\Http\Controllers\Api\BacklogController;
use App\Http\Controllers\Api\MeetingController;
use App\Http\Controllers\Api\CommentController;
use App\Http\Controllers\Api\FileController;
use App\Http\Controllers\Api\ActivityLogController; use App\Http\Controllers\Api\ActivityLogController;
use App\Http\Controllers\Api\NotificationController; use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\ReportController; use App\Http\Controllers\Api\BacklogController;
use App\Http\Controllers\Api\SearchController; use App\Http\Controllers\Api\CalendarController;
use App\Http\Controllers\Api\RoleController; use App\Http\Controllers\Api\ChecklistController;
use App\Http\Controllers\Api\CommentController;
use App\Http\Controllers\Api\DashboardController;
use App\Http\Controllers\Api\DepartmentController; use App\Http\Controllers\Api\DepartmentController;
use App\Http\Controllers\Api\FileController;
use App\Http\Controllers\Api\MeetingController;
use App\Http\Controllers\Api\NotificationController;
use App\Http\Controllers\Api\PermissionController; use App\Http\Controllers\Api\PermissionController;
use App\Http\Controllers\Api\SettingController; use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\PwaController; use App\Http\Controllers\Api\PwaController;
use App\Http\Controllers\Api\ReportController;
use App\Http\Controllers\Api\RoleController;
use App\Http\Controllers\Api\SearchController;
use App\Http\Controllers\Api\SettingController;
use App\Http\Controllers\Api\SprintController;
use App\Http\Controllers\Api\SubtaskController;
use App\Http\Controllers\Api\TaskController;
use App\Http\Controllers\Api\UserController;
use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login'); Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:login');
Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])->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::post('/logout', [AuthController::class, 'logout']);
Route::get('/user', [AuthController::class, 'user']); Route::get('/user', [AuthController::class, 'user']);
Route::put('/user/profile', [AuthController::class, 'updateProfile']); 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/summary', [DashboardController::class, 'summary'])->middleware('permission:reports.view');
Route::get('/dashboard/charts', [DashboardController::class, 'charts'])->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::get('/users', [UserController::class, 'index'])->middleware(['permission:team.view', 'throttle:sensitive']);
Route::post('/users', [UserController::class, 'store'])->middleware(['permission:team.create', '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::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::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::delete('/users/{user}', [UserController::class, 'destroy'])->middleware(['permission:team.delete', 'throttle:sensitive']);
Route::patch('/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']); 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::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}/workload', [UserController::class, 'updateWorkload'])->middleware('permission:team.view');
Route::get('/users/{user}/tasks', [UserController::class, 'taskStats']); Route::get('/users/{user}/tasks', [UserController::class, 'taskStats'])->middleware('permission:team.view');
Route::get('/projects', [ProjectController::class, 'index'])->middleware('permission:projects.view'); Route::get('/projects', [ProjectController::class, 'index'])->middleware('permission:projects.view');
Route::post('/projects', [ProjectController::class, 'store'])->middleware('permission:projects.create'); 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('/my-tasks', [TaskController::class, 'myTasks']);
Route::get('/delayed-tasks', [TaskController::class, 'delayedTasks']); Route::get('/delayed-tasks', [TaskController::class, 'delayedTasks']);
Route::apiResource('tasks.checklists', ChecklistController::class)->shallow(); Route::apiResource('tasks.checklists', ChecklistController::class)->shallow()->middleware('permission:tasks.edit');
Route::put('/checklists/{checklist}/toggle', [ChecklistController::class, 'toggleComplete']); Route::put('/checklists/{checklist}/toggle', [ChecklistController::class, 'toggleComplete'])->middleware('permission:tasks.edit');
Route::apiResource('tasks.subtasks', SubtaskController::class)->shallow(); Route::apiResource('tasks.subtasks', SubtaskController::class)->shallow()->middleware('permission:tasks.edit');
Route::put('/subtasks/{subtask}/status', [SubtaskController::class, 'updateStatus']); Route::put('/subtasks/{subtask}/status', [SubtaskController::class, 'updateStatus'])->middleware('permission:tasks.edit');
Route::get('/sprints', [SprintController::class, 'index'])->middleware('permission:sprints.view'); Route::get('/sprints', [SprintController::class, 'index'])->middleware('permission:sprints.view');
Route::post('/sprints', [SprintController::class, 'store'])->middleware('permission:sprints.create'); 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}', [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::put('/sprints/{sprint}', [SprintController::class, 'update'])->middleware('permission:sprints.edit');
Route::patch('/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'); 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::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::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::get('/meetings', [MeetingController::class, 'index'])->middleware('permission:meetings.view');
Route::post('/meetings', [MeetingController::class, 'store'])->middleware('permission:meetings.create'); 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}', [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::put('/meetings/{meeting}', [MeetingController::class, 'update'])->middleware('permission:meetings.edit');
Route::patch('/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::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::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::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'); 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::get('/notifications', [NotificationController::class, 'index'])->middleware('permission:notifications.view');
Route::post('/notifications/{notification}/read', [NotificationController::class, 'markAsRead'])->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::post('/notifications/read-all', [NotificationController::class, 'markAllAsRead'])->middleware('permission:notifications.view');
Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy'])->middleware('permission:notifications.view'); Route::delete('/notifications/{notification}', [NotificationController::class, 'destroy'])->middleware('permission:notifications.view');

مشاهده پرونده

@ -3,9 +3,11 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase; use Tests\TestCase;
class AuthTest extends TestCase class AuthTest extends TestCase
@ -91,4 +93,41 @@ class AuthTest extends TestCase
->getJson('/api/user') ->getJson('/api/user')
->assertUnauthorized(); ->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);
}
} }

مشاهده پرونده

@ -3,7 +3,11 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\File; use App\Models\File;
use App\Models\Meeting;
use App\Models\MeetingActionItem;
use App\Models\Permission;
use App\Models\Project; use App\Models\Project;
use App\Models\Role;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@ -84,4 +88,118 @@ class SecurityHardeningTest extends TestCase
->assertForbidden() ->assertForbidden()
->assertJsonPath('success', false); ->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);
}
} }

مشاهده پرونده

@ -58,7 +58,10 @@ class SettingTest extends TestCase
$this->withToken($token) $this->withToken($token)
->getJson('/api/settings') ->getJson('/api/settings')
->assertOk() ->assertOk()
->assertJsonPath('data.0.value', 'شرکت پیشگام'); ->assertJsonFragment([
'key' => 'company_name',
'value' => 'شرکت پیشگام',
]);
$this->withToken($token) $this->withToken($token)
->putJson("/api/settings/{$plain->id}", [ ->putJson("/api/settings/{$plain->id}", [

مشاهده پرونده

@ -0,0 +1,169 @@
<?php
namespace Tests\Feature;
use App\Models\Meeting;
use App\Models\MeetingType;
use App\Models\Notification;
use App\Models\Project;
use App\Models\Sprint;
use App\Models\Task;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class WorkspaceFeaturesTest extends TestCase
{
use RefreshDatabase;
private function authenticatedAdmin(): array
{
$user = User::factory()->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');
}
}

مشاهده پرونده

@ -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.

مشاهده پرونده

@ -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.

2
frontend/.gitignore فروخته شده
مشاهده پرونده

@ -10,6 +10,8 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
test-results
playwright-report
*.local *.local
# Editor directories and files # Editor directories and files

مشاهده پرونده

@ -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([]);
});

مشاهده پرونده

@ -2,9 +2,11 @@
<html lang="fa" dir="rtl"> <html lang="fa" dir="rtl">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/png" sizes="32x32" href="/brand/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/brand/favicon-16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/brand/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#2563eb" /> <meta name="theme-color" content="#061a3a" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>مدیریت پروژه | سامانه مدیریت پروژه‌های سازمانی</title> <title>مدیریت پروژه | سامانه مدیریت پروژه‌های سازمانی</title>
<link href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css" rel="stylesheet" type="text/css" /> <link href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css" rel="stylesheet" type="text/css" />

64
frontend/package-lock.json تولید شده
مشاهده پرونده

@ -17,6 +17,7 @@
"recharts": "^3.9.0" "recharts": "^3.9.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
@ -434,6 +435,22 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@reduxjs/toolkit": { "node_modules/@reduxjs/toolkit": {
"version": "2.12.0", "version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
@ -1824,6 +1841,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

مشاهده پرونده

@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"axios": "^1.18.1", "axios": "^1.18.1",
@ -18,6 +19,7 @@
"recharts": "^3.9.0" "recharts": "^3.9.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",

36
frontend/playwright.config.js normal فایل
مشاهده پرونده

@ -0,0 +1,36 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 45_000,
expect: { timeout: 8_000 },
retries: 0,
reporter: [['list']],
use: {
baseURL: 'http://127.0.0.1:4004',
locale: 'fa-IR',
timezoneId: 'Asia/Tehran',
viewport: { width: 1440, height: 1000 },
colorScheme: 'light',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
launchOptions: {
executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH
|| (process.platform === 'win32' ? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' : undefined),
},
},
webServer: [
{
command: 'php ../backend/artisan serve --host=127.0.0.1 --port=8000',
url: 'http://127.0.0.1:8000',
reuseExistingServer: true,
timeout: 30_000,
},
{
command: 'npm run dev -- --host=127.0.0.1',
url: 'http://127.0.0.1:4004',
reuseExistingServer: true,
timeout: 30_000,
},
],
});

دودویی (BIN)
frontend/public/brand/app-icon-192.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 51 KiB

دودویی (BIN)
frontend/public/brand/app-icon-512.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 330 KiB

دودویی (BIN)
frontend/public/brand/apple-touch-icon.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 45 KiB

دودویی (BIN)
frontend/public/brand/favicon-16.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 873 B

دودویی (BIN)
frontend/public/brand/favicon-32.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 2.5 KiB

دودویی (BIN)
frontend/public/brand/project-mark-original.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.3 MiB

دودویی (BIN)
frontend/public/brand/project-mark.png normal فایل

فایل باینری نشان داده نشده است.

پس از

عرض:  |  ارتفاع:  |  اندازه: 1.3 MiB

مشاهده پرونده

@ -7,13 +7,19 @@
"display": "standalone", "display": "standalone",
"start_url": "/pwa", "start_url": "/pwa",
"scope": "/", "scope": "/",
"theme_color": "#2563eb", "theme_color": "#061a3a",
"background_color": "#f8fafc", "background_color": "#061a3a",
"icons": [ "icons": [
{ {
"src": "/favicon.svg", "src": "/brand/app-icon-192.png",
"sizes": "any", "sizes": "192x192",
"type": "image/svg+xml", "type": "image/png",
"purpose": "any maskable"
},
{
"src": "/brand/app-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable" "purpose": "any maskable"
} }
] ]

مشاهده پرونده

@ -3,61 +3,65 @@ import { Toaster } from 'react-hot-toast';
import { AuthProvider, useAuth } from './context/AuthContext'; import { AuthProvider, useAuth } from './context/AuthContext';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import Header from './components/Header'; import Header from './components/Header';
import { useState } from 'react';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Projects from './pages/Projects';
import ProjectDetail from './pages/ProjectDetail';
import Tasks from './pages/Tasks';
import Kanban from './pages/Kanban';
import Backlog from './pages/Backlog';
import Sprints from './pages/Sprints';
import Team from './pages/Team';
import MeetingList from './pages/MeetingList';
import Files from './pages/Files';
import Reports from './pages/Reports';
import Notifications from './pages/Notifications';
import Roles from './pages/Roles';
import Settings from './pages/Settings';
import Profile from './pages/Profile'; import Profile from './pages/Profile';
import Organization from './pages/Organization'; import { lazy, Suspense, useEffect, useState } from 'react';
import PwaLayout from './pwa/PwaLayout';
import { defaultRouteForAppMode, isStandalonePwa } from './utils/appMode'; import { defaultRouteForAppMode, isStandalonePwa } from './utils/appMode';
import './styles/pwa.css'; import './styles/pwa.css';
const pageTitles = { const Login = lazy(() => import('./pages/Login'));
'/': 'داشبورد', const Dashboard = lazy(() => import('./pages/Dashboard'));
'/projects': 'پروژه‌ها', const Projects = lazy(() => import('./pages/Projects'));
'/tasks': 'تسک‌ها', const ProjectDetail = lazy(() => import('./pages/ProjectDetail'));
'/kanban': 'برد کاری', const Tasks = lazy(() => import('./pages/Tasks'));
'/backlog': 'بک‌لاگ', const Kanban = lazy(() => import('./pages/Kanban'));
'/sprints': 'اسپرینت‌ها', const Backlog = lazy(() => import('./pages/Backlog'));
'/team': 'اعضای تیم', const Sprints = lazy(() => import('./pages/Sprints'));
'/meetings': 'جلسات', const Team = lazy(() => import('./pages/Team'));
'/files': 'فایل‌ها', const MeetingList = lazy(() => import('./pages/MeetingList'));
'/reports': 'گزارش‌ها', const Files = lazy(() => import('./pages/Files'));
'/notifications': 'اعلان‌ها', const Reports = lazy(() => import('./pages/Reports'));
'/roles': 'نقش‌ها و دسترسی‌ها', const Notifications = lazy(() => import('./pages/Notifications'));
'/organization': 'ساختار سازمانی', const Roles = lazy(() => import('./pages/Roles'));
'/settings': 'تنظیمات', const Settings = lazy(() => import('./pages/Settings'));
'/profile': 'پروفایل کاربری', const Organization = lazy(() => import('./pages/Organization'));
}; const SprintWorkspace = lazy(() => import('./pages/SprintWorkspace'));
const MeetingWorkspace = lazy(() => import('./pages/MeetingWorkspace'));
const PwaLayout = lazy(() => import('./pwa/PwaLayout'));
function ProtectedRoute({ children }) { function PageFallback() {
const { user, loading } = useAuth(); return <div className="page-loading" role="status">در حال بارگذاری...</div>;
if (loading) return <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>در حال بارگذاری...</div>;
if (!user) return <Navigate to="/login" />;
return children;
} }
function AppLayout({ children, title }) { function AppLayout({ children, title }) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
useEffect(() => {
const handleKeyDown = (event) => {
if (event.key === 'Escape') setMobileNavOpen(false);
};
const handleResize = () => {
if (window.innerWidth > 768) setMobileNavOpen(false);
};
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('resize', handleResize);
};
}, []);
const toggleMobileNav = () => {
setCollapsed(false);
setMobileNavOpen((current) => !current);
};
return ( return (
<div className="layout"> <div className="layout">
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} /> <Sidebar collapsed={collapsed} onToggle={() => setCollapsed(!collapsed)} mobileOpen={mobileNavOpen} onMobileClose={() => setMobileNavOpen(false)} />
<div className="main-content" style={{ marginRight: collapsed ? '72px' : 'var(--sidebar-width)', transition: 'margin-right 0.2s ease' }}> {mobileNavOpen && <button type="button" className="mobile-sidebar-overlay" aria-label="بستن منوی اصلی" onClick={() => setMobileNavOpen(false)} />}
<Header title={title} /> <div className={`main-content ${collapsed ? 'sidebar-collapsed' : ''}`}>
<Header title={title} collapsed={collapsed} mobileNavOpen={mobileNavOpen} onToggleMobileNav={toggleMobileNav} />
<div className="main-inner">{children}</div> <div className="main-inner">{children}</div>
</div> </div>
</div> </div>
@ -77,10 +81,12 @@ function AppRoutes() {
if (!user) { if (!user) {
return ( return (
<Routes> <Suspense fallback={<PageFallback />}>
<Route path="/login" element={<Login />} /> <Routes>
<Route path="*" element={<Navigate to="/login" replace />} /> <Route path="/login" element={<Login />} />
</Routes> <Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
</Suspense>
); );
} }
@ -88,27 +94,31 @@ function AppRoutes() {
if (isStandalonePwa() && !isPwaPath) return <Navigate to="/pwa" replace />; if (isStandalonePwa() && !isPwaPath) return <Navigate to="/pwa" replace />;
return ( return (
<Routes> <Suspense fallback={<PageFallback />}>
<Route path="/login" element={<Navigate to={defaultRouteForAppMode()} replace />} /> <Routes>
<Route path="/pwa/*" element={<PwaLayout />} /> <Route path="/login" element={<Navigate to={defaultRouteForAppMode()} replace />} />
<Route path="/" element={<DefaultAppRoute />} /> <Route path="/pwa/*" element={<PwaLayout />} />
<Route path="/projects" element={<AppLayout title="پروژه‌ها"><Projects /></AppLayout>} /> <Route path="/" element={<DefaultAppRoute />} />
<Route path="/projects/:id" element={<AppLayout title="جزئیات پروژه"><ProjectDetail /></AppLayout>} /> <Route path="/projects" element={<AppLayout title="پروژه‌ها"><Projects /></AppLayout>} />
<Route path="/tasks" element={<AppLayout title="تسک‌ها"><Tasks /></AppLayout>} /> <Route path="/projects/:id" element={<AppLayout title="جزئیات پروژه"><ProjectDetail /></AppLayout>} />
<Route path="/kanban" element={<AppLayout title="برد کاری"><Kanban /></AppLayout>} /> <Route path="/tasks" element={<AppLayout title="تسک‌ها"><Tasks /></AppLayout>} />
<Route path="/backlog" element={<AppLayout title="بک‌لاگ"><Backlog /></AppLayout>} /> <Route path="/kanban" element={<AppLayout title="برد کاری"><Kanban /></AppLayout>} />
<Route path="/sprints" element={<AppLayout title="اسپرینت‌ها"><Sprints /></AppLayout>} /> <Route path="/backlog" element={<AppLayout title="بک‌لاگ"><Backlog /></AppLayout>} />
<Route path="/team" element={<AppLayout title="اعضای تیم"><Team /></AppLayout>} /> <Route path="/sprints" element={<AppLayout title="اسپرینت‌ها"><Sprints /></AppLayout>} />
<Route path="/meetings" element={<AppLayout title="جلسات"><MeetingList /></AppLayout>} /> <Route path="/sprints/:id/workspace" element={<AppLayout title="مرکز فرمان Sprint"><SprintWorkspace /></AppLayout>} />
<Route path="/files" element={<AppLayout title="فایل‌ها"><Files /></AppLayout>} /> <Route path="/team" element={<AppLayout title="اعضای تیم"><Team /></AppLayout>} />
<Route path="/reports" element={<AppLayout title="گزارش‌ها"><Reports /></AppLayout>} /> <Route path="/meetings" element={<AppLayout title="جلسات"><MeetingList /></AppLayout>} />
<Route path="/notifications" element={<AppLayout title="اعلان‌ها"><Notifications /></AppLayout>} /> <Route path="/meetings/:id/workspace" element={<AppLayout title="فضای کاری جلسه"><MeetingWorkspace /></AppLayout>} />
<Route path="/roles" element={<AppLayout title="نقش‌ها و دسترسی‌ها"><Roles /></AppLayout>} /> <Route path="/files" element={<AppLayout title="فایل‌ها"><Files /></AppLayout>} />
<Route path="/organization" element={<AppLayout title="ساختار سازمانی"><Organization /></AppLayout>} /> <Route path="/reports" element={<AppLayout title="گزارش‌ها"><Reports /></AppLayout>} />
<Route path="/settings" element={<AppLayout title="تنظیمات"><Settings /></AppLayout>} /> <Route path="/notifications" element={<AppLayout title="اعلان‌ها"><Notifications /></AppLayout>} />
<Route path="/profile" element={<AppLayout title="پروفایل کاربری"><Profile /></AppLayout>} /> <Route path="/roles" element={<AppLayout title="نقش‌ها و دسترسی‌ها"><Roles /></AppLayout>} />
<Route path="*" element={<Navigate to="/" />} /> <Route path="/organization" element={<AppLayout title="ساختار سازمانی"><Organization /></AppLayout>} />
</Routes> <Route path="/settings" element={<AppLayout title="تنظیمات"><Settings /></AppLayout>} />
<Route path="/profile" element={<AppLayout title="پروفایل کاربری"><Profile /></AppLayout>} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
); );
} }

مشاهده پرونده

@ -0,0 +1,161 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CalendarDays, ChevronLeft, ChevronRight, Circle, RefreshCw } from 'lucide-react';
import api from '../services/api';
import {
getJalaliWeekday,
jalaliMonthLength,
jalaliToIso,
todayJalaliParts,
} from '../utils/date';
const monthNames = ['فروردین', 'اردیبهشت', 'خرداد', 'تیر', 'مرداد', 'شهریور', 'مهر', 'آبان', 'آذر', 'دی', 'بهمن', 'اسفند'];
const weekdays = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
const typeLabels = { meeting: 'جلسه', task: 'تسک', sprint: 'اسپرینت', project: 'پروژه', action_item: 'اقدام' };
function moveMonth(view, delta) {
let jm = view.jm + delta;
let jy = view.jy;
if (jm > 12) { jm = 1; jy += 1; }
if (jm < 1) { jm = 12; jy -= 1; }
return { jy, jm };
}
export default function CalendarPopover({ onSelectEvent }) {
const today = useMemo(() => todayJalaliParts(), []);
const [view, setView] = useState({ jy: today.jy, jm: today.jm });
const [selectedIso, setSelectedIso] = useState(jalaliToIso(today.jy, today.jm, today.jd));
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [activeTypes, setActiveTypes] = useState(Object.keys(typeLabels));
const [agendaPage, setAgendaPage] = useState(0);
const monthLength = jalaliMonthLength(view.jy, view.jm);
const firstWeekday = getJalaliWeekday(view.jy, view.jm, 1);
const from = jalaliToIso(view.jy, view.jm, 1);
const to = jalaliToIso(view.jy, view.jm, monthLength);
const loadEvents = useCallback(async () => {
setLoading(true);
setError('');
try {
const { data } = await api.get('/calendar/events', { params: { from, to } });
setEvents(data.data || []);
} catch {
setError('دریافت رویدادها ناموفق بود.');
} finally {
setLoading(false);
}
}, [from, to]);
useEffect(() => { loadEvents(); }, [loadEvents]);
const eventsByDate = useMemo(() => events.reduce((result, event) => {
const date = String(event.start_at || '').slice(0, 10);
if (!result[date]) result[date] = [];
result[date].push(event);
return result;
}, {}), [events]);
const selectedEvents = (eventsByDate[selectedIso] || []).filter((event) => activeTypes.includes(event.event_type));
const agendaPageSize = 2;
const agendaPageCount = Math.max(1, Math.ceil(selectedEvents.length / agendaPageSize));
const visibleEvents = selectedEvents.slice(agendaPage * agendaPageSize, agendaPage * agendaPageSize + agendaPageSize);
const cells = [
...Array.from({ length: firstWeekday }, () => null),
...Array.from({ length: monthLength }, (_, index) => index + 1),
];
const goToday = () => {
setView({ jy: today.jy, jm: today.jm });
setSelectedIso(jalaliToIso(today.jy, today.jm, today.jd));
};
const toggleType = (type) => {
setActiveTypes((current) => current.includes(type) ? current.filter((item) => item !== type) : [...current, type]);
};
useEffect(() => { setAgendaPage(0); }, [selectedIso, activeTypes]);
return (
<section className="calendar-popover" aria-label="تقویم رویدادها">
<header className="calendar-popover-head">
<div>
<strong>{monthNames[view.jm - 1]} {view.jy}</strong>
<span>{events.length} رویداد</span>
</div>
<div className="calendar-controls">
<button type="button" onClick={() => setView(moveMonth(view, -1))} aria-label="ماه قبل"><ChevronRight size={18} /></button>
<button type="button" className="calendar-today" onClick={goToday}>امروز</button>
<button type="button" onClick={() => setView(moveMonth(view, 1))} aria-label="ماه بعد"><ChevronLeft size={18} /></button>
</div>
</header>
<div className="calendar-weekdays">
{weekdays.map((day) => <span key={day}>{day}</span>)}
</div>
<div className="calendar-grid">
{cells.map((day, index) => {
if (!day) return <span key={`blank-${index}`} className="calendar-day blank" />;
const iso = jalaliToIso(view.jy, view.jm, day);
const dayEvents = (eventsByDate[iso] || []).filter((event) => activeTypes.includes(event.event_type));
const isToday = iso === jalaliToIso(today.jy, today.jm, today.jd);
return (
<button
type="button"
key={iso}
className={`calendar-day ${selectedIso === iso ? 'selected' : ''} ${isToday ? 'today' : ''}`}
onClick={() => setSelectedIso(iso)}
aria-label={`${day} ${monthNames[view.jm - 1]}، ${dayEvents.length} رویداد`}
>
<span>{day}</span>
{dayEvents.length > 0 && <i>{dayEvents.slice(0, 3).map((event) => <b key={event.id} className={`tone-${event.color_token}`} />)}</i>}
</button>
);
})}
</div>
<div className="calendar-filters" aria-label="فیلتر نوع رویداد">
{Object.entries(typeLabels).map(([type, label]) => (
<button type="button" key={type} className={activeTypes.includes(type) ? 'active' : ''} onClick={() => toggleType(type)}>
<Circle size={8} fill="currentColor" />{label}
</button>
))}
</div>
<div className="calendar-agenda">
<div className="calendar-agenda-title">
<strong>برنامه روز</strong>
<span>{selectedEvents.length} مورد</span>
</div>
{loading ? (
<div className="calendar-state"><RefreshCw className="spin" size={18} /> در حال دریافت...</div>
) : error ? (
<button type="button" className="calendar-state error" onClick={loadEvents}>{error} تلاش دوباره</button>
) : selectedEvents.length === 0 ? (
<div className="calendar-state"><CalendarDays size={20} /> رویدادی برای این روز وجود ندارد.</div>
) : (
<>
{visibleEvents.map((event) => (
<button type="button" className="calendar-event" key={event.id} onClick={() => onSelectEvent(event)}>
<i className={`tone-${event.color_token}`} />
<span>
<strong>{event.title}</strong>
<small>{typeLabels[event.event_type]}{event.project?.title ? ` · ${event.project.title}` : ''}</small>
</span>
<ChevronLeft size={16} />
</button>
))}
{agendaPageCount > 1 && (
<div className="popover-pagination">
<button type="button" disabled={agendaPage === 0} onClick={() => setAgendaPage((page) => Math.max(0, page - 1))}><ChevronRight size={16} /> قبلی</button>
<span>{agendaPage + 1} از {agendaPageCount}</span>
<button type="button" disabled={agendaPage >= agendaPageCount - 1} onClick={() => setAgendaPage((page) => Math.min(agendaPageCount - 1, page + 1))}>بعدی <ChevronLeft size={16} /></button>
</div>
)}
</>
)}
</div>
</section>
);
}

مشاهده پرونده

@ -0,0 +1,88 @@
import { ArrowLeft, CalendarDays, ExternalLink, FolderKanban, Info, Timer } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import Modal from './Modal';
import { formatJalaliDateTime } from '../utils/date';
const typeLabels = {
meeting: 'جلسه',
task: 'تسک',
sprint: 'اسپرینت',
project: 'پروژه',
action_item: 'اقدام',
notification: 'اعلان',
};
export default function EntityPreviewModal({ item, onClose }) {
const navigate = useNavigate();
if (!item) return null;
const title = item.title || item.preview?.title || 'جزئیات';
const body = item.body || item.preview?.body || item.message;
const entityType = item.entity_type || item.event_type || 'notification';
const targetUrl = item.target_url;
const previewEntries = item.preview && !item.preview.title
? Object.entries(item.preview).filter(([, value]) => value !== null && value !== undefined && value !== '')
: [];
const goToTarget = () => {
if (!targetUrl) return;
onClose();
navigate(targetUrl);
};
return (
<Modal
title="مشاهده سریع"
onClose={onClose}
size="sm"
footer={(
<>
<button type="button" className="btn btn-secondary" onClick={onClose}>بستن</button>
{targetUrl && (
<button type="button" className="btn btn-primary" onClick={goToTarget}>
رفتن به بخش مربوطه
<ArrowLeft size={16} />
</button>
)}
</>
)}
>
<article className="entity-preview">
<div className={`entity-preview-icon tone-${item.color_token || 'primary'}`}>
<Info size={24} />
</div>
<div className="entity-preview-heading">
<span className="badge badge-primary">{typeLabels[entityType] || entityType}</span>
<h2>{title}</h2>
{body && <p>{body}</p>}
</div>
<div className="entity-preview-meta">
{(item.start_at || item.created_at) && (
<div><CalendarDays size={16} /><span>{formatJalaliDateTime(item.start_at || item.created_at)}</span></div>
)}
{item.end_at && (
<div><Timer size={16} /><span>تا {formatJalaliDateTime(item.end_at)}</span></div>
)}
{item.project?.title && (
<div><FolderKanban size={16} /><span>{item.project.title}</span></div>
)}
{item.sprint?.title && (
<div><ExternalLink size={16} /><span>{item.sprint.title}</span></div>
)}
</div>
{previewEntries.length > 0 && (
<dl className="entity-preview-details">
{previewEntries.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{String(value)}</dd>
</div>
))}
</dl>
)}
</article>
</Modal>
);
}

مشاهده پرونده

@ -1,142 +1,161 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { Bell, CalendarDays, LogOut, Menu, Search, Settings } from 'lucide-react';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import api from '../services/api'; import api from '../services/api';
import { Search, Bell, CheckCheck } from 'lucide-react';
import ThemeModeToggle from './ThemeModeToggle'; import ThemeModeToggle from './ThemeModeToggle';
import CalendarPopover from './CalendarPopover';
import NotificationCenter from './NotificationCenter';
import EntityPreviewModal from './EntityPreviewModal';
import useDismissibleLayer from '../hooks/useDismissibleLayer';
import Modal from './Modal';
import Profile from '../pages/Profile';
export default function Header({ title }) { export default function Header({ title, collapsed = false, mobileNavOpen = false, onToggleMobileNav }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [showMenu, setShowMenu] = useState(false); const [activeLayer, setActiveLayer] = useState(null);
const [previewItem, setPreviewItem] = useState(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]); const [searchResults, setSearchResults] = useState([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [showNotifications, setShowNotifications] = useState(false);
const [notifications, setNotifications] = useState([]);
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const calendarButtonRef = useRef(null);
const calendarPanelRef = useRef(null);
const notificationButtonRef = useRef(null);
const notificationPanelRef = useRef(null);
const profileButtonRef = useRef(null);
const fetchNotifications = async () => { const closeLayer = useCallback(() => setActiveLayer(null), []);
const layerRefs = activeLayer === 'calendar'
? [calendarButtonRef, calendarPanelRef]
: [notificationButtonRef, notificationPanelRef];
useDismissibleLayer(activeLayer === 'calendar' || activeLayer === 'notifications', layerRefs, closeLayer);
const fetchUnreadCount = useCallback(async () => {
try { try {
const { data } = await api.get('/notifications', { params: { per_page: 8 } }); const { data } = await api.get('/notifications', { params: { per_page: 1, unread: 1 } });
setNotifications(data.data || []); setUnreadCount(data.meta?.unread_count || 0);
setUnreadCount(data.meta?.unread_count ?? (data.data || []).filter((item) => !item.read_at).length); } catch {
} catch {} setUnreadCount(0);
}; }
useEffect(() => {
fetchNotifications();
const timer = setInterval(fetchNotifications, 60000);
return () => clearInterval(timer);
}, []); }, []);
const handleSearch = async (e) => { useEffect(() => {
const q = e.target.value; fetchUnreadCount();
setSearchQuery(q); const timer = window.setInterval(fetchUnreadCount, 60000);
if (q.length < 2) { setSearchResults([]); return; } return () => window.clearInterval(timer);
}, [fetchUnreadCount]);
const handleSearch = async (event) => {
const query = event.target.value;
setSearchQuery(query);
if (query.length < 2) { setSearchResults([]); return; }
setSearching(true); setSearching(true);
try { try {
const res = await api.get(`/search?q=${q}`); const { data } = await api.get('/search', { params: { q: query } });
setSearchResults(res.data.data || []); setSearchResults(data.data || []);
} catch {} finally { setSearching(false); } } catch {
}; setSearchResults([]);
} finally {
const notificationUrl = (notification) => notification.data?.url || (notification.type?.includes('meeting') ? '/meetings' : notification.type?.includes('task') || notification.type === 'mention' ? '/kanban' : '/notifications'); setSearching(false);
const markNotificationRead = async (notification) => {
if (!notification.read_at) {
try {
await api.post(`/notifications/${notification.id}/read`);
} catch {}
} }
setShowNotifications(false);
fetchNotifications();
navigate(notificationUrl(notification));
}; };
const markAllNotificationsRead = async () => { const toggleLayer = (name) => setActiveLayer((current) => current === name ? null : name);
try {
await api.post('/notifications/read-all');
fetchNotifications();
} catch {}
};
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
navigate('/login'); navigate('/login');
}; };
const openPreview = (item) => {
closeLayer();
setPreviewItem(item);
};
return ( return (
<header style={{ <>
position: 'fixed', top: 0, left: 0, right: 'var(--sidebar-width)', height: 'var(--header-height)', <header className="app-header" style={{ right: collapsed ? 72 : 'var(--sidebar-width)' }}>
background: 'var(--surface)', borderBottom: '1px solid var(--gray-200)', display: 'flex', alignItems: 'center', <h1>{title}</h1>
justifyContent: 'space-between', padding: '0 1.5rem', zIndex: 50, gap: '1rem', <div className="header-search">
}}> <input value={searchQuery} onChange={handleSearch} placeholder="جستجوی پروژه، تسک، کاربر..." className="form-input" />
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: 'var(--gray-900)' }}>{title}</h2> <Search size={18} />
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flex: 1, maxWidth: 400, position: 'relative' }}> {searching && <span className="header-searching">...</span>}
<input {searchResults.length > 0 && (
value={searchQuery} <div className="header-search-results">
onChange={handleSearch} {searchResults.slice(0, 5).map((item, index) => (
placeholder="جستجوی پروژه، تسک، کاربر..." <Link key={`${item.url}-${index}`} to={item.url || '#'} onClick={() => { setSearchResults([]); setSearchQuery(''); }}>
className="form-input" <strong>{item.title || item.name}</strong>
style={{ paddingRight: '2.5rem' }} {item.type && <small>{item.type}</small>}
/> </Link>
<Search size={18} style={{ position: 'absolute', right: '0.75rem', color: 'var(--gray-400)' }} />
{searchResults.length > 0 && (
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'var(--surface)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)', border: '1px solid var(--gray-200)', zIndex: 100, maxHeight: 300, overflow: 'auto', marginTop: 4 }}>
{searchResults.map((item, i) => (
<Link key={i} to={item.url || '#'} style={{ display: 'block', padding: '0.75rem 1rem', borderBottom: '1px solid var(--gray-100)', fontSize: '0.875rem', color: 'var(--gray-700)' }}>
{item.title || item.name}
</Link>
))}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<ThemeModeToggle compact />
<div style={{ position: 'relative' }}>
<button className="header-bell" onClick={() => { setShowNotifications(!showNotifications); if (!showNotifications) fetchNotifications(); }} title="اعلان‌ها">
<Bell size={20} />
{unreadCount > 0 && <span className="header-bell-dot" />}
</button>
{showNotifications && (
<div className="notification-popover">
<div className="notification-popover-head">
<strong>اعلانها</strong>
{unreadCount > 0 && <button className="btn btn-sm btn-outline" onClick={markAllNotificationsRead}><CheckCheck size={14} /> خواندن همه</button>}
</div>
{notifications.length === 0 ? (
<div className="notification-empty">اعلان جدیدی ندارید</div>
) : notifications.map((notification) => (
<button key={notification.id} className={`notification-item ${notification.read_at ? '' : 'unread'}`} onClick={() => markNotificationRead(notification)}>
<span>{notification.title || notification.message}</span>
<small>{notification.read_at ? 'خوانده‌شده' : 'جدید'}</small>
</button>
))} ))}
<Link className="notification-all-link" to="/notifications" onClick={() => setShowNotifications(false)}>مشاهده همه اعلانها</Link>
</div> </div>
)} )}
</div> </div>
<div style={{ position: 'relative' }}>
<div onClick={() => setShowMenu(!showMenu)} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer' }}> <div className="header-actions">
{user?.avatar_url ? <img className="avatar avatar-sm avatar-img" src={user.avatar_url} alt={user?.name || 'avatar'} /> : <div className="avatar avatar-sm">{user?.name?.[0] || '?'}</div>} <button
<span style={{ fontSize: '0.875rem', fontWeight: 500, color: 'var(--gray-700)' }}>{user?.name}</span> type="button"
className={`header-icon-button mobile-nav-toggle ${mobileNavOpen ? 'active' : ''}`}
onClick={onToggleMobileNav}
aria-label={mobileNavOpen ? 'بستن منوی اصلی' : 'باز کردن منوی اصلی'}
aria-controls="main-navigation-drawer"
aria-expanded={mobileNavOpen}
>
<Menu size={20} />
</button>
<div className="header-layer">
<button ref={calendarButtonRef} type="button" className={`header-icon-button ${activeLayer === 'calendar' ? 'active' : ''}`} onClick={() => toggleLayer('calendar')} aria-label="تقویم رویدادها" aria-expanded={activeLayer === 'calendar'}>
<CalendarDays size={20} />
</button>
{activeLayer === 'calendar' && <div ref={calendarPanelRef} className="header-popover calendar-layer"><CalendarPopover onSelectEvent={openPreview} /></div>}
</div> </div>
{showMenu && (
<div style={{ position: 'absolute', left: 0, top: '100%', marginTop: 8, background: 'var(--surface)', borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)', border: '1px solid var(--gray-200)', minWidth: 180, zIndex: 100 }}> <ThemeModeToggle compact />
<Link to="/profile" style={{ display: 'block', padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--gray-700)', borderBottom: '1px solid var(--gray-100)' }} onClick={() => setShowMenu(false)}>
پروفایل کاربری <div className="header-layer">
</Link> <button ref={notificationButtonRef} type="button" className={`header-icon-button ${activeLayer === 'notifications' ? 'active' : ''}`} onClick={() => toggleLayer('notifications')} aria-label="اعلان‌ها" aria-expanded={activeLayer === 'notifications'}>
<Link to="/settings" style={{ display: 'block', padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--gray-700)', borderBottom: '1px solid var(--gray-100)' }} onClick={() => setShowMenu(false)}> <Bell size={20} />
{unreadCount > 0 && <span className="header-badge">{unreadCount > 99 ? '۹۹+' : unreadCount}</span>}
</button>
{activeLayer === 'notifications' && (
<div ref={notificationPanelRef} className="header-popover notification-layer">
<NotificationCenter onOpenNotification={openPreview} onCountChange={setUnreadCount} />
</div>
)}
</div>
<div className="header-layer">
<button ref={profileButtonRef} type="button" className={`header-profile ${activeLayer === 'profile' ? 'active' : ''}`} onClick={() => toggleLayer('profile')} aria-label="باز کردن پروفایل کاربری" aria-haspopup="dialog" aria-expanded={activeLayer === 'profile'}>
{user?.avatar_url ? <img className="avatar avatar-sm avatar-img" src={user.avatar_url} alt="" /> : <span className="avatar avatar-sm">{user?.name?.[0] || '?'}</span>}
<span>{user?.name}</span>
</button>
</div>
</div>
</header>
{activeLayer === 'profile' && (
<Modal
title="پروفایل کاربری"
size="xl"
onClose={closeLayer}
footer={(
<div className="profile-modal-actions">
<button type="button" className="btn btn-secondary" onClick={() => { closeLayer(); navigate('/settings'); }}>
<Settings size={17} />
تنظیمات تنظیمات
</Link> </button>
<button onClick={handleLogout} style={{ display: 'block', width: '100%', textAlign: 'right', padding: '0.75rem 1rem', fontSize: '0.875rem', color: 'var(--danger)' }}> <button type="button" className="btn btn-danger profile-logout-button" onClick={handleLogout}>
خروج <LogOut size={17} />
خروج از حساب
</button> </button>
</div> </div>
)} )}
</div> >
</div> <Profile embedded />
</header> </Modal>
)}
<EntityPreviewModal item={previewItem} onClose={() => setPreviewItem(null)} />
</>
); );
} }

مشاهده پرونده

@ -1,18 +1,43 @@
import { useEffect } from 'react'; import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
export default function Modal({ title, children, onClose, footer, size }) { export default function Modal({ title, children, onClose, footer, size }) {
const modalRef = useRef(null);
const lastFocusedRef = useRef(null);
useEffect(() => { useEffect(() => {
lastFocusedRef.current = document.activeElement;
const previousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const modal = modalRef.current;
const focusable = modal?.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
focusable?.focus();
const handleEscape = (e) => { if (e.key === 'Escape') onClose(); }; const handleEscape = (e) => { if (e.key === 'Escape') onClose(); };
const handleTab = (event) => {
if (event.key !== 'Tab' || !modal) return;
const items = [...modal.querySelectorAll('button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])')];
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
};
document.addEventListener('keydown', handleEscape); document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape); document.addEventListener('keydown', handleTab);
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('keydown', handleTab);
document.body.style.overflow = previousBodyOverflow;
lastFocusedRef.current?.focus?.();
};
}, [onClose]); }, [onClose]);
return ( return (
<div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}> <div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className={`modal${size ? ` modal-${size}` : ''}`}> <div ref={modalRef} className={`modal${size ? ` modal-${size}` : ''}`} role="dialog" aria-modal="true" aria-label={title}>
<div className="modal-header"> <div className="modal-header">
<h3 className="modal-title">{title}</h3> <h3 className="modal-title">{title}</h3>
<button className="modal-close" onClick={onClose}>&times;</button> <button type="button" className="modal-close" onClick={onClose} aria-label="بستن"><X size={20} /></button>
</div> </div>
<div className="modal-body">{children}</div> <div className="modal-body">{children}</div>
{footer && <div className="modal-footer">{footer}</div>} {footer && <div className="modal-footer">{footer}</div>}

مشاهده پرونده

@ -0,0 +1,166 @@
import { useCallback, useEffect, useState } from 'react';
import { Archive, Bell, CheckCheck, ChevronLeft, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../services/api';
import { formatJalaliDateTime } from '../utils/date';
import useBidirectionalNotificationSwipe from '../pwa/useBidirectionalNotificationSwipe';
function NotificationRow({ notification, archived, onOpen, onRemove, onRestore }) {
const runAction = async (action) => {
try {
if (action === 'archive') {
await api.patch(`/notifications/${notification.id}/archive`);
toast.success('اعلان آرشیو شد');
} else if (action === 'delete') {
await api.delete(`/notifications/${notification.id}`);
toast.success('اعلان حذف شد');
}
onRemove(notification.id);
} catch {
toast.error('انجام عملیات اعلان ناموفق بود');
}
};
const swipe = useBidirectionalNotificationSwipe({
rightAction: 'archive',
leftAction: 'delete',
onExitComplete: runAction,
});
return (
<div
className={`notification-swipe-row ${swipe.direction || ''} ${swipe.dragging ? 'dragging' : ''} ${swipe.exiting ? 'exiting' : ''}`}
style={{
'--notification-translate': `${swipe.translateX}px`,
'--notification-progress': swipe.progress,
}}
{...swipe.swipeHandlers}
>
<div className="notification-swipe-action archive"><Archive size={18} /><span>آرشیو</span></div>
<div className="notification-swipe-action delete"><Trash2 size={18} /><span>حذف</span></div>
<div className={`notification-center-item ${notification.read_at ? '' : 'unread'}`}>
<button type="button" className="notification-center-main" onClick={() => onOpen(notification)}>
<span className="notification-center-icon"><Bell size={17} /></span>
<span>
<strong>{notification.title || notification.message || 'اعلان'}</strong>
<small>{notification.body || notification.preview?.body || formatJalaliDateTime(notification.created_at)}</small>
</span>
</button>
<div className="notification-center-actions">
{archived ? (
<button type="button" onClick={() => onRestore(notification)} title="بازیابی" aria-label="بازیابی اعلان"><RotateCcw size={15} /></button>
) : (
<button type="button" onClick={() => runAction('archive')} title="آرشیو" aria-label="آرشیو اعلان"><Archive size={15} /></button>
)}
<button type="button" className="danger" onClick={() => runAction('delete')} title="حذف" aria-label="حذف اعلان"><Trash2 size={15} /></button>
</div>
</div>
</div>
);
}
export default function NotificationCenter({ onOpenNotification, onCountChange }) {
const [tab, setTab] = useState('unread');
const [notifications, setNotifications] = useState([]);
const [meta, setMeta] = useState({});
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
const pageSize = 4;
const fetchNotifications = useCallback(async () => {
setLoading(true);
try {
const params = { per_page: 20 };
if (tab === 'unread') params.unread = 1;
if (tab === 'archived') params.archived = 1;
const { data } = await api.get('/notifications', { params });
setNotifications(data.data || []);
setMeta(data.meta || {});
onCountChange?.(data.meta?.unread_count || 0);
} catch {
toast.error('دریافت اعلان‌ها ناموفق بود');
} finally {
setLoading(false);
}
}, [tab, onCountChange]);
useEffect(() => { fetchNotifications(); }, [fetchNotifications]);
useEffect(() => { setPage(0); }, [tab]);
const pageCount = Math.max(1, Math.ceil(notifications.length / pageSize));
const visibleNotifications = notifications.slice(page * pageSize, page * pageSize + pageSize);
const markAllRead = async () => {
try {
await api.post('/notifications/read-all');
toast.success('همه اعلان‌ها خوانده شدند');
fetchNotifications();
} catch {
toast.error('به‌روزرسانی اعلان‌ها ناموفق بود');
}
};
const openNotification = async (notification) => {
try {
const { data } = await api.get(`/notifications/${notification.id}/preview`);
setNotifications((current) => current.map((item) => item.id === notification.id ? { ...item, read_at: new Date().toISOString() } : item));
onCountChange?.(Math.max(0, (meta.unread_count || 0) - (notification.read_at ? 0 : 1)));
onOpenNotification(data.data);
} catch {
onOpenNotification(notification);
}
};
const restore = async (notification) => {
try {
await api.patch(`/notifications/${notification.id}/restore`);
setNotifications((current) => current.filter((item) => item.id !== notification.id));
toast.success('اعلان بازیابی شد');
} catch {
toast.error('بازیابی اعلان ناموفق بود');
}
};
return (
<section className="notification-center" aria-label="مرکز اعلان‌ها">
<header className="notification-center-head">
<div>
<strong>اعلانها</strong>
<span>{meta.unread_count || 0} خواندهنشده</span>
</div>
{(meta.unread_count || 0) > 0 && (
<button type="button" className="btn btn-sm btn-outline" onClick={markAllRead}><CheckCheck size={15} /> خواندن همه</button>
)}
</header>
<div className="notification-center-tabs" role="tablist">
<button type="button" className={tab === 'unread' ? 'active' : ''} onClick={() => setTab('unread')}>جدید</button>
<button type="button" className={tab === 'all' ? 'active' : ''} onClick={() => setTab('all')}>همه</button>
<button type="button" className={tab === 'archived' ? 'active' : ''} onClick={() => setTab('archived')}>آرشیو</button>
</div>
<div className="notification-center-list">
{loading ? (
Array.from({ length: 4 }).map((_, index) => <div key={index} className="skeleton notification-center-skeleton" />)
) : notifications.length === 0 ? (
<div className="notification-center-empty"><Bell size={24} /><strong>اعلانی در این بخش نیست</strong></div>
) : visibleNotifications.map((notification) => (
<NotificationRow
key={notification.id}
notification={notification}
archived={tab === 'archived'}
onOpen={openNotification}
onRemove={(id) => setNotifications((current) => current.filter((item) => item.id !== id))}
onRestore={restore}
/>
))}
</div>
{!loading && notifications.length > pageSize && (
<div className="popover-pagination notification-pagination">
<button type="button" disabled={page === 0} onClick={() => setPage((current) => Math.max(0, current - 1))}><ChevronRight size={16} /> قبلی</button>
<span>{page + 1} از {pageCount}</span>
<button type="button" disabled={page >= pageCount - 1} onClick={() => setPage((current) => Math.min(pageCount - 1, current + 1))}>بعدی <ChevronLeft size={16} /></button>
</div>
)}
<footer className="notification-center-hint">برای آرشیو به راست و برای حذف به چپ بکشید؛ دکمهها نیز همیشه در دسترساند.</footer>
</section>
);
}

برخی از فایل ها نشان داده نشدند زیرا تعداد زیادی فایل در این تفاوت تغییر کرده اند نمایش بیشتر