77use App \Enums \ExportFormat ;
88use App \Enums \Role ;
99use App \Exceptions \Api \FeatureIsNotAvailableInFreePlanApiException ;
10+ use App \Exceptions \Api \OverlappingTimeEntryApiException ;
1011use App \Exceptions \Api \PdfRendererIsNotConfiguredException ;
1112use App \Exceptions \Api \TimeEntryCanNotBeRestartedApiException ;
1213use App \Exceptions \Api \TimeEntryStillRunningApiException ;
4546use Illuminate \Http \File ;
4647use Illuminate \Http \JsonResponse ;
4748use Illuminate \Http \Resources \Json \JsonResource ;
49+ use Illuminate \Support \Carbon ;
4850use Illuminate \Support \Collection ;
4951use Illuminate \Support \Facades \Auth ;
5052use Illuminate \Support \Facades \Blade ;
5658
5759class TimeEntryController extends Controller
5860{
61+ private function assertNoOverlap (Organization $ organization , Member $ member , \Illuminate \Support \Carbon $ start , ?\Illuminate \Support \Carbon $ end , ?TimeEntry $ exclude = null ): void
62+ {
63+ if (! $ organization ->prevent_overlapping_time_entries ) {
64+ return ;
65+ }
66+
67+ $ query = TimeEntry::query ()
68+ ->where ('organization_id ' , $ organization ->getKey ())
69+ ->where ('user_id ' , $ member ->user_id )
70+ ->when ($ exclude !== null , function (Builder $ q ) use ($ exclude ): void {
71+ $ q ->where ('id ' , '!= ' , $ exclude ->getKey ());
72+ })
73+ ->where (function (Builder $ q ) use ($ start , $ end ): void {
74+ $ q ->where (function (Builder $ q2 ) use ($ start ): void {
75+ $ q2 ->where ('end ' , '> ' , $ start )
76+ ->where ('start ' , '< ' , $ start );
77+ });
78+
79+ if ($ end !== null ) {
80+ $ q ->orWhere (function (Builder $ q4 ) use ($ end ): void {
81+ $ q4 ->where ('start ' , '< ' , $ end )
82+ ->where ('end ' , '> ' , $ end );
83+ });
84+ // Check if the new entry completely surrounds an existing entry
85+ $ q ->orWhere (function (Builder $ q6 ) use ($ start , $ end ): void {
86+ $ q6 ->where ('start ' , '>= ' , $ start )
87+ ->where ('end ' , '<= ' , $ end );
88+ });
89+ }
90+
91+ });
92+
93+ if ($ query ->exists ()) {
94+ throw new OverlappingTimeEntryApiException ;
95+ }
96+ }
97+
5998 protected function checkPermission (Organization $ organization , string $ permission , ?TimeEntry $ timeEntry = null ): void
6099 {
61100 parent ::checkPermission ($ organization , $ permission );
@@ -549,17 +588,15 @@ public function store(Organization $organization, TimeEntryStoreRequest $request
549588 throw new TimeEntryStillRunningApiException ;
550589 }
551590
591+ // Overlap check for create
592+ $ start = Carbon::parse ($ request ->input ('start ' ));
593+ $ end = $ request ->input ('end ' ) !== null ? Carbon::parse ($ request ->input ('end ' )) : null ;
594+ $ this ->assertNoOverlap ($ organization , $ member , $ start , $ end );
595+
552596 $ project = $ request ->input ('project_id ' ) !== null ? Project::findOrFail ((string ) $ request ->input ('project_id ' )) : null ;
553597 $ client = $ project ?->client;
554598 $ task = $ request ->input ('task_id ' ) !== null ? $ project ->tasks ()->findOrFail ((string ) $ request ->input ('task_id ' )) : null ;
555599
556- if ($ project !== null ) {
557- RecalculateSpentTimeForProject::dispatch ($ project );
558- }
559- if ($ task !== null ) {
560- RecalculateSpentTimeForTask::dispatch ($ task );
561- }
562-
563600 $ timeEntry = new TimeEntry ;
564601 $ timeEntry ->fill ($ request ->validated ());
565602 $ timeEntry ->client ()->associate ($ client );
@@ -569,6 +606,13 @@ public function store(Organization $organization, TimeEntryStoreRequest $request
569606 $ timeEntry ->setComputedAttributeValue ('billable_rate ' );
570607 $ timeEntry ->save ();
571608
609+ if ($ project !== null ) {
610+ RecalculateSpentTimeForProject::dispatch ($ project );
611+ }
612+ if ($ task !== null ) {
613+ RecalculateSpentTimeForTask::dispatch ($ task );
614+ }
615+
572616 return new TimeEntryResource ($ timeEntry );
573617 }
574618
@@ -593,6 +637,13 @@ public function update(Organization $organization, TimeEntry $timeEntry, TimeEnt
593637 throw new TimeEntryCanNotBeRestartedApiException ;
594638 }
595639
640+ // Overlap check for update (exclude current)
641+ /** @var Member $effectiveMember */
642+ $ effectiveMember = $ request ->has ('member_id ' ) ? Member::query ()->findOrFail ($ request ->input ('member_id ' )) : $ timeEntry ->member ;
643+ $ effectiveStart = $ request ->has ('start ' ) ? Carbon::parse ($ request ->input ('start ' )) : $ timeEntry ->start ;
644+ $ effectiveEnd = $ request ->has ('end ' ) ? ($ request ->input ('end ' ) !== null ? Carbon::parse ($ request ->input ('end ' )) : null ) : $ timeEntry ->end ;
645+ $ this ->assertNoOverlap ($ organization , $ effectiveMember , $ effectiveStart , $ effectiveEnd , $ timeEntry );
646+
596647 $ oldProject = $ timeEntry ->project ;
597648 $ oldTask = $ timeEntry ->task ;
598649
0 commit comments