diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php new file mode 100644 index 0000000..116bf70 --- /dev/null +++ b/app/Http/Controllers/CommentController.php @@ -0,0 +1,90 @@ + $project]); + $taskModel = Task::where([['project_id', $project ], ['id', $task]])->firstOrFail(); + if (can('isDefiniteGuestInProject', ['project_id' => $project])){ // is guest in project (only guest) + return $taskModel->assignee_id == \auth()->id() ? + Comment::where([ + ['business_id', $business], + ['project_id', $project], + ['task_id', $task], + ])->get(): + abort(Response::HTTP_FORBIDDEN); // not allowed + } else { + return Comment::where([ + ['business_id', $business], + ['project_id', $project], + ['task_id', $task], + ])->get(); + } + } + + public function store($business, $project, $task, Request $request) + { + permit('projectAccess', ['project_id' => $project]); + $taskModel = Task::where([['project_id', $project ], ['id', $task]])->firstOrFail(); + if (can('isDefiniteGuestInProject', ['project_id' => $project])){ // is guest in project (only guest) + return $taskModel->assignee_id == \auth()->id() ? + Comment::create($request->merge([ + 'business_id' => $business, + 'project_id' => $project, + 'task_id' => $task, + 'user_id' => \auth()->id(), + ])->except('_business_info')) : + abort(Response::HTTP_FORBIDDEN); // not allowed + } else { + return Comment::create($request->merge([ + 'business_id' => $business, + 'project_id' => $project, + 'task_id' => $task, + 'user_id' => \auth()->id(), + ])->except('_business_info')); + } + } + + public function show($business, $project, $task, $comment) + { + permit('projectAccess', ['project_id' => $project]); + $taskModel = Task::where([['project_id', $project ], ['id', $task]])->firstOrFail(); + if (can('isDefiniteGuestInProject', ['project_id' => $project])){ // is guest in project (only guest) + return $taskModel->assignee_id == \auth()->id() ? + Comment::findOrFail($comment) : + abort(Response::HTTP_FORBIDDEN); // not allowed + } else { + return Comment::findOrFail($comment); + } + } + + public function update($business, $project, $task, $comment, Request $request) + { + permit('projectAccess', ['project_id' => $project]); + $comment = Comment::findOrFail($comment); + if ($comment->user_id == \auth()->id()) { + $comment->update($request->except('_business_info')); + return $comment; + } + return abort(Response::HTTP_FORBIDDEN); // not allowed + } + + public function destroy($business, $project, $task, $comment) + { + permit('projectAccess', ['project_id' => $project]); + $comment = Comment::findOrFail($comment); + if ($comment->user_id == \auth()->id()) { + $comment->delete(); + return \response()->json(['message' => 'comment deleted successfully.'], Response::HTTP_OK); + } + return abort(Response::HTTP_FORBIDDEN); // not allowed + } +} diff --git a/app/Http/Controllers/StatisticController.php b/app/Http/Controllers/StatisticController.php new file mode 100644 index 0000000..572aae5 --- /dev/null +++ b/app/Http/Controllers/StatisticController.php @@ -0,0 +1,112 @@ + 0, // The sum of all tasks that are under review + 'total' => 0, // Sum of all tasks + 'overdue' => 0, // The sum of all overworked tasks + 'spent_time' => 0, // The sum of all the minutes spent on tasks + 'estimated_time' => 0, // The sum of all the minutes spent performing tasks is estimated + 'over_spent_time' => 0, // The sum of all the minutes spent overworking tasks + 'under_spent_time' => 0, // The sum of all the minutes left until estimated time + ]; + + public function index(Request $request, int $business, ?int $project = null) + { + $builder = DB::table('tasks')->where('business_id','=',$business); + if ($project) { + $builder->where('project_id', '=', $project); + } + $tasks = $builder->get(); + + $tags = DB::table('tag_task') + ->whereIn('task_id', $tasks->pluck('id')->toArray()) + ->get() + ->groupBy('task_id') + ->toArray(); + + + $result = []; + + foreach ($tasks as $task) { + + $this->addProjects($result, $task); + + $this->addSprints($result, $task); + + $this->addSystems($result, $task); + + $this->addTags($result, $task, $tags[$task->id]); + } + + return $result; + } + + + public function addProjects(&$result, $task) + { + $key = "projects.{$task->project_id}"; + + $this->subset($key, $result, $task); + } + + public function addSprints(&$result, $task) + { + $key = "projects.{$task->project_id}."; + $key .= 'sprints.'; + $key .= ($task->sprint_id ?? 0) . '.'; + $key .= ($task->assignee_id ?? 0) . '.'; + $key .= $task->workflow_id . '.'; + $key .= $task->status_id; + + $this->subset($key, $result, $task); + } + + public function addSystems(&$result, $task) + { + $key = "projects.{$task->project_id}."; + $key .= 'systems.'; + $key .= ($task->system_id ?? 0) . '.'; + $key .= ($task->assignee_id ?? 0) . '.'; + $key .= $task->workflow_id . '.'; + $key .= $task->status_id; + + $this->subset($key, $result, $task); + } + + public function addTags(&$result, $task, $tags) + { + foreach ($tags as $tag) { + $key = "projects.{$task->project_id}."; + $key .= 'tags.'; + $key .= $tag->id . '.'; + $key .= ($task->assignee_id ?? 0) . '.'; + $key .= $task->workflow_id . '.'; + $key .= $task->status_id; + + $this->subset($key, $result, $task); + } + } + + public function subset($key , &$result, $task) + { + $node = Arr::get($result, $key, $this->map); + + $node['test'] += $task->ready_to_test; + $node['total'] += 1; + $node['overdue'] += !$task->on_time; + $node['spent_time'] += $task->spent_time; + $node['estimated_time'] += $task->estimated_time; + $node['over_spent_time'] += $task->estimated_time < $task->spent_time ? $task->spent_time - $task->estimated_time : 0; + $node['under_spent_time'] += $task->estimated_time > $task->spent_time ? $task->estimated_time - $task->spent_time : 0; + + Arr::set($result, $key, $node); + } +} diff --git a/app/Http/Controllers/TaskController.php b/app/Http/Controllers/TaskController.php new file mode 100644 index 0000000..f447782 --- /dev/null +++ b/app/Http/Controllers/TaskController.php @@ -0,0 +1,295 @@ +limit > 100 ? 10 : $request->limit; + $this->indexValidation($request); + $tasks = $this->indexFiltering($business) + ->when($request->filled('group'), function ($q) use ($request) { + return $q->report($request->group); + }); + + return $request->filled('group') ? + $tasks->get()->groupBy($request->group) + ->map(function ($q) { return $q->keyBy('status_id'); }) + : /*response()->json(collect(['now' => Carbon::now()->toDateString()])->merge(*/new TaskCollection($tasks->paginate($per_page));//)); + } + + public function indexValidation($request) + { + $bound = 10; + $this->validate($request, [ + 'filter.project_id' => [new maxBound($bound)] , + 'filter.creator_id' => [new maxBound($bound)] , + 'filter.assignee_id' => [new maxBound($bound)] , + 'filter.system_id' => [new maxBound($bound)] , + 'filter.workflow_id' => [new maxBound($bound)] , + 'filter.status_id' => [new maxBound($bound)] , + 'filter.approver_id' => [new maxBound($bound)] , + 'filter.priority_min' => 'nullable|numeric|between:1,10' , + 'filter.priority_max' => 'nullable|numeric|between:1,10' , + 'filter.ready_to_test' => 'nullable|boolean' , + 'filter.on_time' => 'nullable|boolean' , + ]); + } + + public function indexFiltering($business) + { + $query = Task::where('business_id', $business); + $taskQ = QueryBuilder::for($query) + ->with('tagTask') + ->select(DB::raw('tasks.* , (spent_time - estimated_time) as over_spent')) + ->allowedFilters([ + AllowedFilter::exact('project_id'), + AllowedFilter::exact('system_id'), + AllowedFilter::exact('creator_id'), + AllowedFilter::exact('assignee_id'), + AllowedFilter::exact('approver_id'), + AllowedFilter::exact('sprint_id'), + AllowedFilter::exact('workflow_id'), + AllowedFilter::exact('status_id'), + AllowedFilter::exact('on_time'), + AllowedFilter::exact('ready_to_test'), + AllowedFilter::exact('tagTask.tag_id'), + AllowedFilter::scope('priority_min'), + AllowedFilter::scope('priority_max'), + AllowedFilter::scope('creates_before'), + AllowedFilter::scope('creates_after'), + AllowedFilter::scope('creates_in'), + AllowedFilter::scope('updates_before'), + AllowedFilter::scope('updates_after'), + AllowedFilter::scope('updates_in'), + AllowedFilter::scope('spent_from'), + AllowedFilter::scope('spent_to'), + AllowedFilter::scope('estimated_from'), + AllowedFilter::scope('estimated_to'), + AllowedFilter::scope('starts_before'), + AllowedFilter::scope('starts_after'), + AllowedFilter::scope('starts_in'), + AllowedFilter::scope('finish_before'), + AllowedFilter::scope('finish_after'), + AllowedFilter::scope('finish_in'), + AllowedFilter::scope('completes_before'), + AllowedFilter::scope('completes_after'), + AllowedFilter::scope('completes_in'), + AllowedFilter::scope('over_spent_from'), + AllowedFilter::scope('over_spent_to'), + AllowedFilter::scope('due_date_before'), + AllowedFilter::scope('due_date_after'), + AllowedFilter::scope('due_date_in'), + AllowedFilter::scope('my_watching'), + AllowedFilter::scope('over_due'), + ]); + if (\request('_business_info')['info']['users'][\auth()->id()]['level'] != enum('levels.owner.id')) { + $requested_projects = isset(\request('filter')['project_id']) ? + array_unique(explode(',',\request('filter')['project_id'] ?? null )) : + null; + $requested_projects = collect($requested_projects)->keyBy(null)->toArray(); + $project_ids = $this->myStateProjects($requested_projects); + $taskQ->where(function ($q) use ($project_ids) { + $q->whereIn('project_id', $project_ids['non_guest_ids']) + ->orWhere(function ($q) use ($project_ids) { + $q->whereIn('project_id', $project_ids['guest_ids']) + ->where('assignee_id', auth()->id()); + }); + }); + } + return $taskQ; + } + + public function myStateProjects($requested_projects) + { + $non_guest_ids = []; + $guest_ids = []; + $is_empty = empty($requested_projects); + + foreach (\request('_business_info')['info']['projects'] as $p_id => $p) { + + $level = \request('_business_info')['info']['projects'][$p_id]['members'][\auth()->id()]['level']; + + if (( $is_empty || isset($requested_projects[$p_id])) + && $level > enum('levels.guest.id')) { + array_push($non_guest_ids, $p_id); + } + if (( $is_empty || isset($requested_projects[$p_id])) + && $level == enum('levels.guest.id')) { + array_push($guest_ids, $p_id); + } + } + + return ['non_guest_ids' => $non_guest_ids, 'guest_ids' => $guest_ids]; + } + + public function store($business, $project, Request $request) + { + permit('projectTasks', ['project_id' => $project]); + + $task = Task::create($request->merge( + ['business_id' => $business, 'project_id' => $project, 'creator_id' => \auth()->id()] + )->except(['_business_info', 'completed_at', 'on_time', 'ready_to_test'])); + + + return new TaskResource($task); + } + + public function storeTagTasks($tags, $task) { + $tagModels = []; + if (isset($tags)) { + foreach ($tags as $tag) { + array_push($tagModels, + new TagTask(['tag_id' => $tag, 'task_id' => $task->id]) + ); + } + $task->tags()->saveMany($tagModels); + } + } + + /** + * Rule's: + * 1) guest's only can see self task + * 2) user is active in project + */ + public function show($business, $task, $project =null) + { + $task = Task::findOrFail($task); + $project = $task->project_id; + permit('projectAccess', ['project_id' => $project]); + if (can('isDefiniteGuestInProject', ['project_id' => $project])){ // is guest in project (only guest) + return $task->assignee_id == \auth()->id() ? + new TaskResource($task) : + abort(Response::HTTP_METHOD_NOT_ALLOWED); // not allowed + } else { + return new TaskResource($task); + } + } + + /** + * Rule's: + * 1) update assignee_id when not exist work time and active user + * 2) update approver_id when atLeast colleague + * 3) update ready_to_test when assignee_id == auth xor assignee_id == null and isAdmin + * 4) update tags + * 5) update completed_at when status in [done, close] + * 6) due_date before sprint end_time + */ + public function update($business, $project, $task, Request $request) + { + permit('isProjectGuest', ['project_id' => $project]); + $taskModel = Task::where([['project_id', $project ], ['id', $task]])->firstOrFail(); + + if ($taskModel->assignee_id != \auth()->id() && can('isDefiniteGuestInProject', ['project_id' => $project])) { + permit('isDefiniteGuestInProject', ['project_id' => $project]); + } + + if($request->filled('watchers')) { + $watchers = $taskModel->watchers ?? []; + if(!can('isDefiniteGuestInProject', ['project_id' => $project]) && !can('projectTasks', ['project_id' => $project])) { + if (array_search(auth()->id(), $watchers) !== false) { + // remove auth from watchers + $watchers = array_values(\array_diff($watchers, [auth()->id()])); + } else { + // add auth to watchers + $watchers = array_merge($watchers, [auth()->id()]); + } + } + if(can('projectTasks', ['project_id' => $project])) { + $watchers = array_intersect($watchers ?? [], $request->watchers); + } + $request->merge(['watchers' => $watchers]); + } + + if (!can('projectTasks', ['project_id' => $project])) { + $request->replace($request->only(['_business_info', 'ready_to_test', 'status_id', 'watchers'])); + } + if ($taskModel->assignee_id != \auth()->id()) { + $request->request->remove('ready_to_test'); + } + + + if (isset(\request('_business_info')['workflows'][$request->workflow_id]['statuses'][$request->status_id]['state'])) { + $state = \request('_business_info')['workflows'][$request->workflow_id]['statuses'][$request->status_id]['state']; + if ($state == enum('status.states.close.id') || $state == enum('status.states.done.id')) { + //ToDo: is needed to check before state is done or close? + $request->merge([ + 'completed_at' => Carbon::now(), + 'work_finish' => Work::where('task_id', $task)->latest()->first()->ended_at ?? null + ]); + if (isset($taskModel->due_date) && $taskModel->due_date < date('yy-m-d')) { + //check if before set due date and miss, we change on time flag + $request->merge(['on_time' => false]); + } + } + } + if (!$request->has('tags')) { + $request->merge(['tags' => []]); + } + $taskModel->update($request->except('_business_info')); + return new TaskResource($taskModel); + } + + public function updateReadyToTest($business, $project, $task) + { + permit('isProjectGuest', ['project_id' => $project]); + $task = Task::where([['project_id', $project ], ['id', $task]])->firstOrFail(); + if ($task->assignee_id == \auth()->id()) { + $task->update([ + 'ready_to_test' => 1 + ]); + } else { + return abort(Response::HTTP_FORBIDDEN); // not allowed + } + return $task->load(['tagTask'=> fn($q) => $q->select('id', 'tag_id', 'task_id'), 'works', 'comments']); + } + + /** + * Rule's: + * 1) delete only not work time (soft delete) + */ + public function destroy($task) + { + $taskModel = Task::findOrFail($task); + if (Work::where('task_id', $task)->exists()) { + return \response()->json(['task_id' => 'The task id cannot be deleted.'], Response::HTTP_UNPROCESSABLE_ENTITY); + } + $taskModel->delete(); + return \response()->json(['message' => 'task deleted successfully.'], Response::HTTP_OK); + } + + public function toggleWatcher($business, $task, $project =null) + { + permit('isProjectGuest', ['project_id' => $project]); + $taskModel = Task::findOrFail($task); + $watchers = $taskModel->watchers ?? []; + + if (array_search(auth()->id(), $watchers) !== false) { + // remove auth from watchers + $new_watchers = array_values(\array_diff($watchers, [auth()->id()])); + } else { + // add auth to watchers + $new_watchers = array_merge($watchers, [auth()->id()]); + } + + $taskModel->update([ + 'watchers' => $new_watchers + ]); + return new TaskResource($taskModel); + } +} diff --git a/app/Http/Controllers/WorkController.php b/app/Http/Controllers/WorkController.php new file mode 100644 index 0000000..a2fa6ba --- /dev/null +++ b/app/Http/Controllers/WorkController.php @@ -0,0 +1,242 @@ +limit > 100 ? 10 : $request->limit; + $workQ = $this->indexFiltering($business) + ->when($request->filled('group'), function ($q) use ($request) { + return $request->group == 'user' ? $q->report() : $q->reportByDate(); + }); + + return $request->filled('group') ? $workQ->get() : + $workQ->defaultSort('-id') + ->allowedSorts('id', 'started_at')->paginate($per_page); + } + + public function indexFiltering($business) + { + $query = Work::where('works.business_id', $business); + $workQ = queryBuilder::for($query) + ->join('tasks', 'tasks.id', 'works.task_id') + ->select('works.*', 'tasks.title', 'tasks.sprint_id', 'tasks.system_id') + ->allowedFilters([ + AllowedFilter::exact('project_id'), + AllowedFilter::exact('tasks.sprint_id', null, false), + AllowedFilter::exact('tasks.system_id', null, false), + AllowedFilter::exact('user_id'), + AllowedFilter::scope('started_at_in'), + AllowedFilter::scope('started_at'), + AllowedFilter::scope('spent_time_from'), + AllowedFilter::scope('spent_time_to'), + ]); + if (\request('_business_info')['info']['users'][\auth()->id()]['level'] != enum('levels.owner.id')) { + $requested_projects = isset(\request('filter')['project_id']) ? + array_unique(explode(',',\request('filter')['project_id'] ?? null )) : + null; + $requested_projects = collect($requested_projects)->keyBy(null)->toArray(); + $project_ids = $this->myStateProjects($requested_projects); + $workQ->where(function ($q) use ($project_ids) { + $q->whereIn('project_id', $project_ids['non_guest_ids']) + ->orWhere(function ($q) use ($project_ids) { + $q->whereIn('project_id', $project_ids['guest_ids']) + ->where('assignee_id', auth()->id()); + }); + }); + } + return $workQ; + } + + public function myStateProjects($requested_projects) + { + $non_guest_ids = []; + $guest_ids = []; + $is_empty = empty($requested_projects); + + foreach (\request('_business_info')['info']['projects'] as $p_id => $p) { + + $level = \request('_business_info')['info']['projects'][$p_id]['members'][\auth()->id()]['level']; + + if (( $is_empty || isset($requested_projects[$p_id])) + && $level > enum('levels.guest.id')) { + array_push($non_guest_ids, $p_id); + } + if (( $is_empty || isset($requested_projects[$p_id])) + && $level == enum('levels.guest.id')) { + array_push($guest_ids, $p_id); + } + } + + return ['non_guest_ids' => $non_guest_ids, 'guest_ids' => $guest_ids]; + } + + public function show($business, $project, $task, $work) + { + permit('projectAccess', ['project_id' => $project]); + $work = Work::where([['project_id', $project ], ['task_id', $task], ['id', $work]])->firstOrFail(); + if (can('isDefiniteGuestInProject', ['project_id' => $project])){ // is guest in project (only guest) + return $work->user_id == \auth()->id() ? $work : abort(Response::HTTP_FORBIDDEN); // not allowed + } else { + return $work; + } + } + /** + * Rule's: + * 1) only assignee_id can store work + * 2) started_at after task created_at + * 3) ended_at after started_at + * 4) not any work before in this work + */ + public function store($business, $project, $task, Request $request) + { + $taskModel = Task::findOrFail($task); + if ($taskModel->assignee_id != auth()->id()) { + abort(Response::HTTP_FORBIDDEN); // not allowed + } + + $end = Carbon::createFromFormat('Y-m-d H:i', $request->ended_at); + $start = Carbon::createFromFormat('Y-m-d H:i', $request->started_at); + $diff_in_min = $end->diffInMinutes($start); + $work = Work::create($request->merge([ + 'business_id' => $business, + 'project_id' => $project, + 'task_id' => $task, + 'user_id' => auth()->id(), + 'minute_sum' => $diff_in_min, + 'task' => [ + 'spent_time' => $taskModel->spent_time + $diff_in_min + ] + ])->except('_business_info')); + $taskModel->refresh(); +// $taskModel->update([ +// 'work_start' => Work::where('task_id', $taskModel->id)->orderBy('started_at')->first()->started_at ?? null, +// 'spent_time' => $taskModel->spent_time + $diff_in_min +// ]); + return $taskModel->load(['tagTask'=> fn($q) => $q->select('id', 'tag_id', 'task_id'), 'works', 'comments']); + } + + public function storeValidation($request, $taskModel) + { + $this->validate($request, [ + 'message' => 'nullable|string|min:3|max:225', + 'started_at' => 'required|date_format:Y-m-d H:i|after:'.$taskModel->created_at, + 'ended_at' => 'required|date_format:Y-m-d H:i|after:started_at', + ]); + $state = \request('_business_info')['workflows'][$taskModel->workflow_id]['statuses'][$taskModel->status_id]['state'] ?? null; + if ($state == enum('status.states.close.id') || $state == enum('status.states.done.id')) { + throw ValidationException::withMessages(['task' => 'The selected task is invalid.']); + } + $works = Work::where([ + ['ended_at', '>', $request->started_at], + ['ended_at', '<', $request->ended_at], + ])->orWhere([ + ['started_at', '>', $request->started_at], + ['started_at', '<', $request->ended_at], + ])->orWhere([ + ['started_at', '>=', $request->started_at], + ['ended_at', '<=', $request->ended_at], + ])->exists(); + if ($works) { + throw ValidationException::withMessages(['work' => 'The selected work is invalid.']); + } + } + + /** + * Rule's: + * 1) only assignee_id can store work + * 2) started_at after task created_at + * 3) ended_at after started_at + * 4) not any work before in this work + */ + public function update($business, $project, $task, $work, Request $request) + { + $taskModel = Task::findOrFail($task); + $workModel = Work::findOrFail($work); + if ($taskModel->assignee_id != auth()->id()) { + abort(Response::HTTP_FORBIDDEN); // not allowed + } + + $end = Carbon::createFromFormat('Y-m-d H:i', $request->ended_at); + $start = Carbon::createFromFormat('Y-m-d H:i', $request->started_at); + $new_diff_in_min = $end->diffInMinutes($start); + $old_diff_in_min = $workModel->minute_sum; + $workModel->update($request->merge([ + 'business_id' => $business, + 'project_id' => $project, + 'task_id' => $task, + 'user_id' => auth()->id(), + 'minute_sum' => $new_diff_in_min, + 'task' => [ + 'spent_time' => ($taskModel->spent_time - $old_diff_in_min) + $new_diff_in_min + ] + ])->except('_business_info')); + $taskModel->refresh(); +// $taskModel->update([ +// 'work_start' => Work::where('task_id', $taskModel->id)->orderBy('started_at')->first()->started_at ?? null, +// 'spent_time' => ($taskModel->spent_time - $old_diff_in_min) + $new_diff_in_min +// ]); + return $taskModel->load(['tagTask'=> fn($q) => $q->select('id', 'tag_id', 'task_id'), 'works', 'comments']); + } + + public function updateValidation($request, $taskModel, $workModel) + { + $this->validate($request, [ + 'message' => 'nullable|string|min:3|max:225', + 'started_at' => 'nullable|date_format:Y-m-d H:i|after:'.$taskModel->created_at, + 'ended_at' => 'nullable|date_format:Y-m-d H:i|after:started_at', + ]); + //ToDo: is needed to check status is active or idea?? + $works = false; + if ($request->filled('started_at') || $request->filled('ended_at')) { + $started_at = $request->started_at ?? $workModel->started_at->format('Y-m-d H:i'); + $ended_at = $request->ended_at ?? $workModel->ended_at->format('Y-m-d H:i'); + if (strtotime($ended_at) <= strtotime($started_at)) { + throw ValidationException::withMessages(['ended_at' => 'The ended at must be a date after started at.']); + } + $works = Work::where([ + ['ended_at', '>', $started_at], + ['ended_at', '<', $ended_at], + ])->orWhere([ + ['started_at', '>', $started_at], + ['started_at', '<', $ended_at], + ])->orWhere([ + ['started_at', '>=', $started_at], + ['ended_at', '<=', $ended_at], + ])->where('id', '!=', $workModel->id)->exists(); + $end = Carbon::createFromFormat('Y-m-d H:i', $ended_at); + $start = Carbon::createFromFormat('Y-m-d H:i', $started_at); + \request()->merge(['minute_sum' => $end->diffInMinutes($start)]); + } + if ($works) { + throw ValidationException::withMessages(['work' => 'The selected work is invalid.']); + } + } + + public function destroy($business, $project, $task, $work) + { + $taskModel = Task::findOrFail($task); + $workModel = Work::findOrFail($work); + if ($taskModel->assignee_id != auth()->id()) { + abort(Response::HTTP_FORBIDDEN); // not allowed + } + $diff_in_min = $workModel->minute_sum; + $workModel->delete(); + $taskModel->update([ + 'work_start' => Work::where('task_id', $taskModel->id)->orderBy('started_at')->first()->started_at ?? null, + 'spent_time' => ($taskModel->spent_time - $diff_in_min) + ]); + return $taskModel->load(['tagTask'=> fn($q) => $q->select('id', 'tag_id', 'task_id'), 'works','comments']); + } +} diff --git a/app/Http/Resources/CommentResource.php b/app/Http/Resources/CommentResource.php new file mode 100644 index 0000000..eecc44a --- /dev/null +++ b/app/Http/Resources/CommentResource.php @@ -0,0 +1,34 @@ + 'task', + '_resource' => 'comment', + ]; + + foreach ($this->getAttributes() as $attribute => $value) { + switch ($attribute) { + case 'task_id' : + case 'user_id' : + case 'body' : + $resource[$attribute] = $value; + break; + } + } + + return $resource; + } +} diff --git a/app/Http/Resources/TaskCollection.php b/app/Http/Resources/TaskCollection.php new file mode 100644 index 0000000..be5944b --- /dev/null +++ b/app/Http/Resources/TaskCollection.php @@ -0,0 +1,23 @@ + TaskResource::collection($this->collection), + 'now' => Carbon::now()->toDateString() + ]; + } +} diff --git a/app/Http/Resources/TaskResource.php b/app/Http/Resources/TaskResource.php new file mode 100644 index 0000000..6737a5a --- /dev/null +++ b/app/Http/Resources/TaskResource.php @@ -0,0 +1,30 @@ +getAttributes() as $attribute => $value) { + $resource[$attribute] = $value; + if ($attribute == 'watchers') { + $resource[$attribute] = json_decode($value); + } + } + + $resource['tags'] = $this->tagTask()->pluck('tag_id')->toArray(); + $resource['works'] = $this->works; + $resource['comments'] = $this->comments; + + return $resource; + } +} diff --git a/app/Models/Comment.php b/app/Models/Comment.php new file mode 100644 index 0000000..b392b45 --- /dev/null +++ b/app/Models/Comment.php @@ -0,0 +1,19 @@ + 'required|string|min:3|max:1000', + ]; + } +} diff --git a/app/Models/Task.php b/app/Models/Task.php new file mode 100644 index 0000000..b08e588 --- /dev/null +++ b/app/Models/Task.php @@ -0,0 +1,308 @@ + 'tag_task'] + ]; + protected $fillable_relations = [ + 'tags' + ]; + + protected $casts = [ + 'work_start' => 'datetime:Y-m-d H:i', + 'work_finish' => 'datetime:Y-m-d H:i', + 'watchers' => 'array', + ]; + + public function rules() + { + $validations = [ + 'assignee_id' => ['nullable', 'numeric', + function ($attribute, $value, $fail) { + //check assignee at least guest in project + if (!can('isProjectGuest', ['project_id' => request()->route('project'), 'user_id' => $value])) { + $fail('The selected '.$attribute.' is invalid.'); + } + if (request()->method() === 'PUT' && ($this->assignee_id != $value && Work::where('task_id', $this->id)->exists())) { + //when update execute + //check if change assignee then should be on task not work set + $fail('The selected '.$attribute.' is invalid.'); + } + }], + 'system_id' => ['nullable', 'numeric', + Rule::in(\request('_business_info')['info']['projects'][request()->route('project')]['systems'])], + 'sprint_id' => ['nullable', 'numeric', + Rule::in(\request('_business_info')['info']['projects'][request()->route('project')]['sprints'])], + 'workflow_id' => ['required', 'numeric', + Rule::in(array_keys(\request('_business_info')['info']['workflows']))], + 'status_id' => 'required|numeric', + 'approver_id' => ['nullable', 'numeric', + function ($attribute, $value, $fail) { + //check approval at least colleague in project + if (!can('isProjectColleague', ['project_id' => request()->route('project'), 'user_id' => $value])) { + $fail('The selected '.$attribute.' is invalid.'); + } + }], + 'title' => 'required|string|min:3|max:254', + 'description' => 'nullable|string|min:2|max:1000', + 'priority' => 'nullable|numeric|between:1,10', + 'estimated_time' => 'nullable|numeric|min:30', + 'due_date' => 'bail|nullable|date|date_format:Y-m-d|after_or_equal:'. + ((request()->method() === 'POST') ? date('yy-m-d') : $this->created_at->toDateString()), + 'tags' => 'nullable|array', + 'tags.*' => [new RequiredIf(isset($request->tags)), 'numeric', + Rule::in(\request('_business_info')['info']['tags'])], + ] ; + $workflow_id = $this->workflow_id; + if (request()->filled('workflow_id') && isset(request('_business_info')['info']['workflows'][request()->workflow_id])) { + $workflow_id = request()->workflow_id; + } + if (isset($workflow_id)) { + $validations['status_id'] = ['bail', 'required', 'numeric', + //check status exists in status list + Rule::in(\request('_business_info')['info']['workflows'][$workflow_id]['statuses']), + function ($attribute, $value, $fail) use ($workflow_id) { + //check not close or done + $state = \request('_business_info')['workflows'][$workflow_id]['statuses'][$value]['state']; + if (request()->method() == 'POST' && ($state == enum('status.states.close.id') || $state == enum('status.states.done.id'))) { + $fail('The selected '.$attribute.' is invalid.'); + } + if ((request()->method() == 'PUT') && !can('projectTasks', ['project_id' => request()->route('project')]) + && $state != enum('status.states.active.id')) { + $fail('The selected '.$attribute.' is invalid.'); + } + }]; + } + + return $validations; + } + public function updateRelations() + { + // tags relations + $this->dirties['tags'] = $this->tags()->sync($this->filled_relations['tags']); + + //old code +// if (!empty($this->filled_relations['tags'])) { +// $this->dirties['tags'] = $this->tags()->sync($this->filled_relations['tags']); +// } + + } + + public function getValueOf(?string $key) + { + $values = [ + 'business_id' => $this->business_id, + 'project_id' => $this->project_id, + 'sprint_id' => $this->sprint_id, + 'workflow_id' => $this->workflow_id, + 'status_id' => $this->status_id, + 'system_id' => $this->system_id, + 'task_id' => $this->id, + 'subject_id' => $this->id, + 'user_id' => $this->assignee_id, + ]; + + if ($key && isset($values, $key)) { + return $values[$key]; + } + + return $values; + } + + public function business() + { + return $this->belongsTo(Business::class, 'business_id', 'id', __FUNCTION__); + } + + public function creator() + { + return $this->belongsTo(User::class, 'creator_id', 'id', __FUNCTION__); + } + + public function user() + { + return $this->belongsTo(User::class, 'user_id', 'id', __FUNCTION__); + } + + public function project() + { + return $this->belongsTo(Project::class, 'project_id', 'id', __FUNCTION__); + } + + public function tags() + { + return $this->belongsToMany( + Tag::class, 'tag_task', 'task_id', 'tag_id', + 'id', 'id', __FUNCTION__ + )->using(ReportableRelation::class); + } + + public function tagTask() + { + return $this->hasMany(TagTask::class, 'task_id', 'id'); + } + + public function works() + { + return $this->hasMany(Work::class, 'task_id', 'id'); + } + + public function comments() + { + return $this->hasMany(Comment::class, 'task_id', 'id'); + } + + public function scopePriorityMin($query, $min) + { + return $query->where('tasks.priority', '>=', $min); + } + public function scopePriorityMax($query, $max) + { + return $query->where('tasks.priority', '<=', $max); + } + public function scopeCreatesBefore($query, $date) + { + //ToDo: comment lines have better performance but should be test +// return $query->whereDate('tasks.created_at', '<=', Carbon::parse($date.' 23:59:59')); +// return $query->where('tasks.created_at', '<', (new DateTime('2014-07-10'))->modify('+1 day')->format('Y-m-d')); + return $query->whereDate('tasks.created_at', '<=', Carbon::parse($date)); + } + public function scopeCreatesAfter($query, $date) + { + return $query->whereDate('tasks.created_at', '>=', Carbon::parse($date)); + } + public function scopeCreatesIn($query, $days) + { + return $query->whereDate('tasks.created_at', '>=', Carbon::now()->modify('-'.$days.' day')->toDate()); + } + public function scopeUpdatesBefore($query, $date) + { + return $query->whereDate('tasks.updated_at', '<=', Carbon::parse($date)); + } + public function scopeUpdatesAfter($query, $date) + { + return $query->whereDate('tasks.updated_at', '>=', Carbon::parse($date)); + } + public function scopeUpdatesIn($query, $days) + { + return $query->whereDate('tasks.updated_at', '>=', Carbon::now()->modify('-'.$days.' day')->toDate()); + } + public function scopeSpentFrom($query, $minute) + { + return $query->where('tasks.spent_time', '>=', $minute); + } + public function scopeSpentTo($query, $minute) + { + return $query->where('tasks.spent_time', '<=', $minute); + } + public function scopeEstimatedFrom($query, $minute) + { + return $query->where('tasks.estimated_time', '>=', $minute); + } + public function scopeEstimatedTo($query, $minute) + { + return $query->where('tasks.estimated_time', '<=', $minute); + } + public function scopeStartsBefore($query, $date) + { + return $query->whereDate('tasks.work_start', '<=', Carbon::parse($date)); + } + public function scopeStartsAfter($query, $date) + { + return $query->whereDate('tasks.work_start', '>=', Carbon::parse($date)); + } + public function scopeStartsIn($query, $days) + { + return $query->whereDate('tasks.work_start', '>=', Carbon::now()->modify('-'.$days.' day')->toDate()); + } + public function scopeFinishBefore($query, $date) + { + return $query->whereDate('tasks.work_finish', '<=', Carbon::parse($date)); + } + public function scopeFinishAfter($query, $date) + { + return $query->whereDate('tasks.work_finish', '>=', Carbon::parse($date)); + } + public function scopeFinishIn($query, $days) + { + return $query->whereDate('tasks.work_finish', '>=', Carbon::now()->modify('-'.$days.' day')->toDate()); + } + public function scopeCompletesBefore($query, $date) + { + return $query->where('tasks.completed_at', '<=', $date); + } + public function scopeCompletesAfter($query, $date) + { + return $query->where('tasks.completed_at', '>=', $date); + } + public function scopeCompletesIn($query, $days) + { + return $query->whereDate('tasks.completed_at', '>=', Carbon::now()->modify('-'.$days.' day')->toDate()); + } + public function scopeDueDateBefore($query, $date) + { + return $query->where('tasks.due_date', '<=', $date); + } + public function scopeDueDateAfter($query, $date) + { + return $query->where('tasks.due_date', '>=', $date); + } + public function scopeDueDateIn($query, $days) + { + return $query->whereDate('tasks.due_date', '>=', Carbon::now()->modify('-'.$days.' day')->toDate()); + } + public function scopeOverSpentFrom($query, $minute) + { + return $query->whereColumn('spent_time', '>', 'estimated_time') + ->having('over_spent', '>=', $minute); + } + public function scopeOverSpentTo($query, $minute) + { + return $query->whereColumn('spent_time', '>', 'estimated_time') + ->having('over_spent', '<=', $minute); + } + public function scopeMyWatching($query) + { + return $query->whereJsonContains('watchers', auth()->id()); + } + public function scopeOverDue($query) + { + return $query->whereColumn('due_date', '<', 'completed_at'); + } + public function scopeReport($query, $group_field) + { + return $query->select(DB::raw($group_field.' , status_id, COUNT(*) as total, SUM(spent_time) as spent_sum, + SUM(estimated_time) as estimated_sum,SUM(ready_to_test) as test_sum, + COUNT(completed_at) as completed_total, SUM(on_time) as on_time_total, + SUM(Case When spent_time > estimated_time Then (spent_time - estimated_time) Else 0 End) as over_spent_sum, + SUM(Case When (spent_time <= estimated_time) And (completed_at) Then (estimated_time - spent_time) Else 0 End) + as under_spent_sum')) + ->groupBy($group_field, 'status_id'); + } +} diff --git a/app/Models/Work.php b/app/Models/Work.php new file mode 100644 index 0000000..b378737 --- /dev/null +++ b/app/Models/Work.php @@ -0,0 +1,126 @@ + 'datetime:Y-m-d H:i', + 'ended_at' => 'datetime:Y-m-d H:i' + ]; + + protected $reportable = [ + 'message', 'minute_sum', 'started_at', 'ended_at', + ]; + + public function rules() + { + $started_at = request()->started_at ?? $this->started_at->format('Y-m-d H:i'); + $ended_at = request()->ended_at ?? $this->ended_at->format('Y-m-d H:i'); + return [ + 'message' => 'nullable|string|min:3|max:225', + 'started_at' => 'required|date_format:Y-m-d H:i|after:'.$this->task()->first()->created_at, + 'ended_at' => [ + 'required', 'date_format:Y-m-d H:i', 'after:'.$started_at, + function ($attribute, $value, $fail) use ($ended_at, $started_at) { + $works = \App\Work::where([ + ['ended_at', '>', $started_at], + ['ended_at', '<', $ended_at], + ])->orWhere([ + ['started_at', '>', $started_at], + ['started_at', '<', $ended_at], + ])->orWhere([ + ['started_at', '>=', $started_at], + ['ended_at', '<=', $ended_at], + ])->when(isset($this->id), function ($query) { + return $query->where('id', '!=', $this->id); + })->exists(); + if ($works) { + $fail('The selected work is invalid.'); + } + } + ], + ]; + + } + + public function getValueOf(?string $key) + { + $values = [ + 'business_id' => $this->business_id, + 'project_id' => $this->project_id, + 'sprint_id' => null, + 'workflow_id' => null, + 'status_id' => null, + 'system_id' => null, + 'task_id' => $this->task->id, + 'subject_id' => $this->id, + 'user_id' => null, + ]; + + if ($key && isset($values, $key)) { + return $values[$key]; + } + + return $values; + } + + public function updateRelations() + { + $this->filled_relations['task']['work_start'] = Work::where('task_id', $this->task_id)->orderBy('started_at')->first()->started_at ?? null; + $this->task()->update($this->filled_relations['task']); + } + + public function task() + { + return $this->belongsTo(Task::class, 'task_id', 'id'); + } + + public function scopeStartedAtIn($query, $days) + { + return $query->whereDate('started_at', '>=', Carbon::now()->modify('-'.$days.' day')->toDate()); + } + + public function scopeStartedAt($query, $date) + { + return $query->whereDate('started_at', '=', $date); + } + + public function scopeSpentTimeFrom($query, $data) + { + return $query->where('minute_sum', '>=', $data); + } + + public function scopeSpentTimeTo($query, $data) + { + return $query->where('minute_sum', '<=', $data); + } + + public function scopeReport($query) + { + return $query->select( + DB::raw('user_id , MIN(started_at) as started_at_min, MAX(ended_at) as ended_at_max, SUM(minute_sum) as work_minute_sum') + )->groupBy('user_id'); + } + + public function scopeReportByDate($query) + { + return $query->select( + DB::raw('DATE(started_at) as date, SUM(minute_sum) as work_minute_sum') + )->groupBy('date'); + } +} diff --git a/composer.json b/composer.json index 3795a6d..c47bcbc 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,8 @@ "fruitcake/laravel-cors": "^2.0", "guzzlehttp/guzzle": "^7.0.1", "laravel/framework": "^8.12", - "laravel/tinker": "^2.5" + "laravel/tinker": "^2.5", + "spatie/laravel-query-builder": "^3.3" }, "require-dev": { "facade/ignition": "^2.5", diff --git a/composer.lock b/composer.lock index a5ae24e..06cb69e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4a16c3c541bd99241cab1c21ce8e83ac", + "content-hash": "727372edb248a9d83eed7a324b8eda99", "packages": [ { "name": "asm89/stack-cors", @@ -2226,6 +2226,76 @@ ], "time": "2020-08-18T17:17:46+00:00" }, + { + "name": "spatie/laravel-query-builder", + "version": "3.3.4", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-query-builder.git", + "reference": "2e131b0c8ae600b6e3aabb5a1501c721862a0b8f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-query-builder/zipball/2e131b0c8ae600b6e3aabb5a1501c721862a0b8f", + "reference": "2e131b0c8ae600b6e3aabb5a1501c721862a0b8f", + "shasum": "" + }, + "require": { + "illuminate/database": "^6.0|^7.0|^8.0", + "illuminate/http": "^6.0|^7.0|^8.0", + "illuminate/support": "^6.0|^7.0|^8.0", + "php": "^7.3|^8.0" + }, + "require-dev": { + "ext-json": "*", + "laravel/legacy-factories": "^1.0.4", + "mockery/mockery": "^1.4", + "orchestra/testbench": "^4.9|^5.8|^6.3", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\QueryBuilder\\QueryBuilderServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\QueryBuilder\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily build Eloquent queries from API requests", + "homepage": "https://github.com/spatie/laravel-query-builder", + "keywords": [ + "laravel-query-builder", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-query-builder/issues", + "source": "https://github.com/spatie/laravel-query-builder" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2020-11-26T14:51:30+00:00" + }, { "name": "swiftmailer/swiftmailer", "version": "v6.2.5", @@ -6968,5 +7038,5 @@ "php": "^7.3|^8.0" }, "platform-dev": [], - "plugin-api-version": "1.1.0" + "plugin-api-version": "2.0.0" } diff --git a/database/migrations/2021_03_02_082607_create_tasks_table.php b/database/migrations/2021_03_02_082607_create_tasks_table.php new file mode 100644 index 0000000..0ce9c7f --- /dev/null +++ b/database/migrations/2021_03_02_082607_create_tasks_table.php @@ -0,0 +1,55 @@ +id(); + $table->unsignedBigInteger('business_id'); + $table->unsignedBigInteger('project_id'); + $table->unsignedBigInteger('creator_id'); + $table->unsignedBigInteger('assignee_id')->nullable(); + $table->unsignedBigInteger('system_id')->nullable(); + $table->unsignedBigInteger('sprint_id')->nullable(); + $table->unsignedBigInteger('workflow_id'); + $table->unsignedBigInteger('status_id'); + $table->unsignedBigInteger('approver_id')->nullable(); + + $table->string('title'); + $table->text('description')->nullable(); + $table->integer('priority')->default(1); + $table->boolean('on_time')->default(true); + $table->boolean('ready_to_test')->default(false); + + $table->json('watchers')->nullable(); + + $table->integer('spent_time')->default(0); + $table->integer('estimated_time')->default(0); + $table->date('due_date')->nullable(); + $table->date('completed_at')->nullable(); + $table->timestamp('work_start')->nullable(); + $table->timestamp('work_finish')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('tasks'); + } +} diff --git a/database/migrations/2021_03_02_085913_create_works_table.php b/database/migrations/2021_03_02_085913_create_works_table.php new file mode 100644 index 0000000..4274ff8 --- /dev/null +++ b/database/migrations/2021_03_02_085913_create_works_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('business_id'); + $table->unsignedBigInteger('project_id'); + $table->unsignedBigInteger('task_id'); + $table->unsignedBigInteger('user_id'); + $table->string('message')->nullable(); + $table->integer('minute_sum')->default(0); + $table->dateTime('started_at'); + $table->dateTime('ended_at'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('works'); + } +} diff --git a/database/migrations/2021_03_02_092444_create_comments_table.php b/database/migrations/2021_03_02_092444_create_comments_table.php new file mode 100644 index 0000000..c653af3 --- /dev/null +++ b/database/migrations/2021_03_02_092444_create_comments_table.php @@ -0,0 +1,36 @@ +id(); + $table->unsignedBigInteger('business_id'); + $table->unsignedBigInteger('project_id'); + $table->unsignedBigInteger('task_id'); + $table->unsignedBigInteger('user_id'); + $table->text('body'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('comments'); + } +}