diff --git a/.gitignore b/.gitignore index 446e4e1..c09ab17 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /docker/data /.claude CLAUDE.md +/coverage \ No newline at end of file diff --git a/backend/app/Domain/CalendarSlot/Policies/CalendarSlotPolicy.php b/backend/app/Domain/CalendarSlot/Policies/CalendarSlotPolicy.php new file mode 100644 index 0000000..a23d08b --- /dev/null +++ b/backend/app/Domain/CalendarSlot/Policies/CalendarSlotPolicy.php @@ -0,0 +1,34 @@ +created_by_user_id === $user->id; + } + + /** + * Determine if the user can update the calendar slot + */ + public function update(User $user, CalendarSlot $calendarSlot): bool + { + return $calendarSlot->trip->created_by_user_id === $user->id; + } + + /** + * Determine if the user can reorder items in the calendar slot + */ + public function reorder(User $user, CalendarSlot $calendarSlot): bool + { + return $calendarSlot->trip->created_by_user_id === $user->id; + } +} diff --git a/backend/app/Domain/PlannableItem/Policies/PlannableItemPolicy.php b/backend/app/Domain/PlannableItem/Policies/PlannableItemPolicy.php new file mode 100644 index 0000000..0bb267b --- /dev/null +++ b/backend/app/Domain/PlannableItem/Policies/PlannableItemPolicy.php @@ -0,0 +1,42 @@ +created_by_user_id === $user->id; + } + + /** + * Determine if the user can view the plannable item + */ + public function view(User $user, PlannableItem $plannableItem): bool + { + return $plannableItem->trip->created_by_user_id === $user->id; + } + + /** + * Determine if the user can update the plannable item + */ + public function update(User $user, PlannableItem $plannableItem): bool + { + return $plannableItem->trip->created_by_user_id === $user->id; + } + + /** + * Determine if the user can delete the plannable item + */ + public function delete(User $user, PlannableItem $plannableItem): bool + { + return $plannableItem->trip->created_by_user_id === $user->id; + } +} diff --git a/backend/app/Domain/PlannedItem/Actions/CreatePlannedItemAction.php b/backend/app/Domain/PlannedItem/Actions/CreatePlannedItemAction.php new file mode 100644 index 0000000..ed2523a --- /dev/null +++ b/backend/app/Domain/PlannedItem/Actions/CreatePlannedItemAction.php @@ -0,0 +1,104 @@ +toDateString(); + + // Verify the plannable item belongs to the trip + $plannableItem = PlannableItem::findOrFail($data['plannable_item_id']); + if ($plannableItem->trip_id !== $data['trip_id']) { + throw new \Exception('Plannable item does not belong to this trip'); + } + + // Use database transaction to prevent race conditions + $calendarSlot = \DB::transaction(function () use ($data, $plannableItem, $startDatetime, $endDatetime, $slotDate) { + // Use firstOrCreate to prevent duplicate slots with same times + $calendarSlot = CalendarSlot::firstOrCreate( + [ + 'trip_id' => $data['trip_id'], + 'datetime_start' => $startDatetime, + 'datetime_end' => $endDatetime, + 'slot_date' => $slotDate, + ], + [ + 'name' => $plannableItem->name, + 'slot_order' => 0, // Will be recalculated + ] + ); + + // Recalculate slot orders to ensure consistency + $this->calendarSlotService->recalculateSlotOrdersForDate( + $data['trip_id'], + $slotDate + ); + + // Create PlannedItem link (use firstOrCreate to prevent race condition duplicates) + PlannedItem::firstOrCreate( + [ + 'plannable_item_id' => $data['plannable_item_id'], + 'calendar_slot_id' => $calendarSlot->id, + ], + [ + 'sort_order' => $data['sort_order'] ?? 0, + ] + ); + + return $calendarSlot; + }); + + return $calendarSlot->load(['plannedItems.plannableItem']); + } + + /** + * Create planned item from existing calendar slot (legacy flow) + * + * @param array $data ['plannable_item_id', 'calendar_slot_id', 'sort_order'?] + * @return PlannedItem + * @throws \Exception + */ + public function executeFromSlot(array $data): PlannedItem + { + // Validate that calendar slot and plannable item belong to the same trip + $plannableItem = PlannableItem::findOrFail($data['plannable_item_id']); + $calendarSlot = CalendarSlot::findOrFail($data['calendar_slot_id']); + + if ($plannableItem->trip_id !== $calendarSlot->trip_id) { + throw new \Exception('Calendar slot and plannable item must belong to the same trip'); + } + + $plannedItem = PlannedItem::updateOrCreate( + [ + 'plannable_item_id' => $data['plannable_item_id'], + 'calendar_slot_id' => $data['calendar_slot_id'], + ], + [ + 'sort_order' => $data['sort_order'] ?? 0, + ] + ); + + return $plannedItem->load(['plannableItem', 'calendarSlot']); + } +} diff --git a/backend/app/Domain/PlannedItem/Actions/DeletePlannedItemAction.php b/backend/app/Domain/PlannedItem/Actions/DeletePlannedItemAction.php new file mode 100644 index 0000000..cfc43bf --- /dev/null +++ b/backend/app/Domain/PlannedItem/Actions/DeletePlannedItemAction.php @@ -0,0 +1,19 @@ +delete(); + } +} diff --git a/backend/app/Domain/PlannedItem/Actions/UpdatePlannedItemAction.php b/backend/app/Domain/PlannedItem/Actions/UpdatePlannedItemAction.php new file mode 100644 index 0000000..88b89c8 --- /dev/null +++ b/backend/app/Domain/PlannedItem/Actions/UpdatePlannedItemAction.php @@ -0,0 +1,22 @@ +update($data); + + return $plannedItem->load(['plannableItem', 'calendarSlot']); + } +} diff --git a/backend/app/Domain/PlannedItem/Policies/PlannedItemPolicy.php b/backend/app/Domain/PlannedItem/Policies/PlannedItemPolicy.php new file mode 100644 index 0000000..fec6fc2 --- /dev/null +++ b/backend/app/Domain/PlannedItem/Policies/PlannedItemPolicy.php @@ -0,0 +1,54 @@ +where('id', $tripId) + ->where('created_by_user_id', $user->id) + ->exists(); + } + + /** + * Determine if the user can update the planned item + */ + public function update(User $user, PlannedItem $plannedItem): bool + { + return \DB::table('planned_items') + ->join('calendar_slots', 'planned_items.calendar_slot_id', '=', 'calendar_slots.id') + ->join('trips', 'calendar_slots.trip_id', '=', 'trips.id') + ->where('planned_items.id', $plannedItem->id) + ->where('trips.created_by_user_id', $user->id) + ->exists(); + } + + /** + * Determine if the user can delete the planned item + */ + public function delete(User $user, PlannedItem $plannedItem): bool + { + return $this->update($user, $plannedItem); + } + + /** + * Determine if the user can move a planned item to a new calendar slot + */ + public function moveToSlot(User $user, int $calendarSlotId): bool + { + return \DB::table('calendar_slots') + ->join('trips', 'calendar_slots.trip_id', '=', 'trips.id') + ->where('calendar_slots.id', $calendarSlotId) + ->where('trips.created_by_user_id', $user->id) + ->exists(); + } +} diff --git a/backend/app/Domain/Trip/Policies/TripPolicy.php b/backend/app/Domain/Trip/Policies/TripPolicy.php new file mode 100644 index 0000000..5b7ea87 --- /dev/null +++ b/backend/app/Domain/Trip/Policies/TripPolicy.php @@ -0,0 +1,33 @@ +created_by_user_id === $user->id; + } + + /** + * Determine if the user can update the trip + */ + public function update(User $user, Trip $trip): bool + { + return $trip->created_by_user_id === $user->id; + } + + /** + * Determine if the user can delete the trip + */ + public function delete(User $user, Trip $trip): bool + { + return $trip->created_by_user_id === $user->id; + } +} diff --git a/backend/app/Domain/Trip/Services/CalendarSlotService.php b/backend/app/Domain/Trip/Services/CalendarSlotService.php index 24fa3b1..a7d84df 100644 --- a/backend/app/Domain/Trip/Services/CalendarSlotService.php +++ b/backend/app/Domain/Trip/Services/CalendarSlotService.php @@ -69,4 +69,43 @@ public function deleteSlotsForTrip(Trip $trip): void { $trip->calendarSlots()->delete(); } + + /** + * Calculate the slot_order for a new slot based on datetime_start + * Orders chronologically by start time within the same day + */ + public function calculateSlotOrder(int $tripId, string $slotDate, Carbon $datetimeStart): int + { + $maxOrder = CalendarSlot::where('trip_id', $tripId) + ->where('slot_date', $slotDate) + ->where('datetime_start', '<', $datetimeStart) + ->max('slot_order'); + + return ($maxOrder ?? -1) + 1; + } + + /** + * Recalculate slot_order for all slots on a given date + * Orders by datetime_start ASC + * Uses database transaction with individual updates for safety + */ + public function recalculateSlotOrdersForDate(int $tripId, string $slotDate): void + { + $slots = CalendarSlot::where('trip_id', $tripId) + ->where('slot_date', $slotDate) + ->orderBy('datetime_start') + ->get(); + + if ($slots->isEmpty()) { + return; + } + + // Update each slot's order within a transaction + \DB::transaction(function () use ($slots) { + foreach ($slots as $index => $slot) { + $slot->slot_order = $index; + $slot->save(); + } + }); + } } \ No newline at end of file diff --git a/backend/app/Infrastructure/Http/Controllers/API/CalendarSlot/CalendarSlotController.php b/backend/app/Infrastructure/Http/Controllers/API/CalendarSlot/CalendarSlotController.php index 5051908..58b1d76 100644 --- a/backend/app/Infrastructure/Http/Controllers/API/CalendarSlot/CalendarSlotController.php +++ b/backend/app/Infrastructure/Http/Controllers/API/CalendarSlot/CalendarSlotController.php @@ -2,6 +2,7 @@ namespace App\Infrastructure\Http\Controllers\API\CalendarSlot; +use App\Domain\CalendarSlot\Policies\CalendarSlotPolicy; use App\Infrastructure\Http\Controllers\Controller; use App\Models\CalendarSlot; use App\Models\Trip; @@ -11,16 +12,19 @@ class CalendarSlotController extends Controller { + public function __construct( + private CalendarSlotPolicy $policy + ) {} public function index(Trip $trip): JsonResponse { - // Check if user owns the trip - if ($trip->created_by_user_id !== auth()->id()) { + if (!$this->policy->viewAny(auth()->user(), $trip)) { return response()->json(['message' => 'Forbidden'], 403); } $calendarSlots = $trip->calendarSlots() - ->with(['plannableItems']) - ->orderBy('slot_order') + ->with(['plannedItems.plannableItem']) + ->orderBy('slot_date') + ->orderBy('datetime_start') ->get(); return response()->json(['data' => $calendarSlots]); @@ -28,8 +32,7 @@ public function index(Trip $trip): JsonResponse public function update(Request $request, CalendarSlot $calendarSlot): JsonResponse { - // Check if user owns the trip - if ($calendarSlot->trip->created_by_user_id !== auth()->id()) { + if (!$this->policy->update(auth()->user(), $calendarSlot)) { return response()->json(['message' => 'Forbidden'], 403); } @@ -44,18 +47,35 @@ public function update(Request $request, CalendarSlot $calendarSlot): JsonRespon public function reorder(Request $request, CalendarSlot $calendarSlot): JsonResponse { + if (!$this->policy->reorder(auth()->user(), $calendarSlot)) { + return response()->json(['message' => 'Forbidden'], 403); + } + $validated = $request->validate([ 'items' => 'required|array', 'items.*.plannable_item_id' => 'required|exists:plannable_items,id', 'items.*.sort_order' => 'required|integer', ]); + // Validate all plannable items belong to the same trip + $trip = $calendarSlot->trip; + $validPlannableItemIds = $trip->plannableItems()->pluck('id')->toArray(); + foreach ($validated['items'] as $item) { - PlannedItem::where('calendar_slot_id', $calendarSlot->id) - ->where('plannable_item_id', $item['plannable_item_id']) - ->update(['sort_order' => $item['sort_order']]); + if (!in_array($item['plannable_item_id'], $validPlannableItemIds)) { + return response()->json(['message' => 'Invalid plannable item for this trip'], 422); + } } + // Update sort orders in transaction + \DB::transaction(function () use ($validated, $calendarSlot) { + foreach ($validated['items'] as $item) { + PlannedItem::where('calendar_slot_id', $calendarSlot->id) + ->where('plannable_item_id', $item['plannable_item_id']) + ->update(['sort_order' => $item['sort_order']]); + } + }); + return response()->json(['message' => 'Items reordered successfully']); } } \ No newline at end of file diff --git a/backend/app/Infrastructure/Http/Controllers/API/PlannableItem/PlannableItemController.php b/backend/app/Infrastructure/Http/Controllers/API/PlannableItem/PlannableItemController.php index 2d3bb0f..5ed4cb3 100644 --- a/backend/app/Infrastructure/Http/Controllers/API/PlannableItem/PlannableItemController.php +++ b/backend/app/Infrastructure/Http/Controllers/API/PlannableItem/PlannableItemController.php @@ -2,6 +2,7 @@ namespace App\Infrastructure\Http\Controllers\API\PlannableItem; +use App\Domain\PlannableItem\Policies\PlannableItemPolicy; use App\Infrastructure\Http\Controllers\Controller; use App\Models\PlannableItem; use App\Models\Trip; @@ -10,10 +11,12 @@ class PlannableItemController extends Controller { + public function __construct( + private PlannableItemPolicy $policy + ) {} public function index(Trip $trip): JsonResponse { - // Check if user owns the trip - if ($trip->created_by_user_id !== auth()->id()) { + if (!$this->policy->create(auth()->user(), $trip)) { return response()->json(['message' => 'Forbidden'], 403); } @@ -26,8 +29,7 @@ public function index(Trip $trip): JsonResponse public function store(Request $request, Trip $trip): JsonResponse { - // Check if user owns the trip - if ($trip->created_by_user_id !== auth()->id()) { + if (!$this->policy->create(auth()->user(), $trip)) { return response()->json(['message' => 'Forbidden'], 403); } @@ -46,14 +48,17 @@ public function store(Request $request, Trip $trip): JsonResponse public function show(PlannableItem $plannableItem): JsonResponse { + if (!$this->policy->view(auth()->user(), $plannableItem)) { + return response()->json(['message' => 'Forbidden'], 403); + } + $plannableItem->load(['calendarSlots', 'trip']); return response()->json($plannableItem); } public function update(Request $request, PlannableItem $plannableItem): JsonResponse { - // Check if user owns the trip - if ($plannableItem->trip->created_by_user_id !== auth()->id()) { + if (!$this->policy->update(auth()->user(), $plannableItem)) { return response()->json(['message' => 'Forbidden'], 403); } @@ -72,6 +77,10 @@ public function update(Request $request, PlannableItem $plannableItem): JsonResp public function destroy(PlannableItem $plannableItem): JsonResponse { + if (!$this->policy->delete(auth()->user(), $plannableItem)) { + return response()->json(['message' => 'Forbidden'], 403); + } + $plannableItem->delete(); return response()->json(null, 204); diff --git a/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php b/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php index 0c93c77..07b894a 100644 --- a/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php +++ b/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php @@ -2,51 +2,90 @@ namespace App\Infrastructure\Http\Controllers\API\PlannedItem; +use App\Domain\PlannedItem\Actions\CreatePlannedItemAction; +use App\Domain\PlannedItem\Actions\UpdatePlannedItemAction; +use App\Domain\PlannedItem\Actions\DeletePlannedItemAction; +use App\Domain\PlannedItem\Policies\PlannedItemPolicy; use App\Infrastructure\Http\Controllers\Controller; use App\Models\PlannedItem; -use App\Models\PlannableItem; -use App\Models\CalendarSlot; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Gate; class PlannedItemController extends Controller { + public function __construct( + private CreatePlannedItemAction $createAction, + private UpdatePlannedItemAction $updateAction, + private DeletePlannedItemAction $deleteAction, + private PlannedItemPolicy $policy + ) {} + public function store(Request $request): JsonResponse { $validated = $request->validate([ 'plannable_item_id' => 'required|exists:plannable_items,id', - 'calendar_slot_id' => 'required|exists:calendar_slots,id', + 'trip_id' => 'required|exists:trips,id', + 'start_datetime' => 'required|date', + 'end_datetime' => 'required|date|after:start_datetime', + 'calendar_slot_id' => 'sometimes|exists:calendar_slots,id', 'sort_order' => 'nullable|integer', ]); - $plannedItem = PlannedItem::updateOrCreate( - [ - 'plannable_item_id' => $validated['plannable_item_id'], - 'calendar_slot_id' => $validated['calendar_slot_id'], - ], - [ - 'sort_order' => $validated['sort_order'] ?? 0, - ] - ); + // Check authorization using Policy + if (!$this->policy->create(auth()->user(), $validated['trip_id'])) { + return response()->json(['message' => 'Forbidden'], 403); + } - return response()->json($plannedItem->load(['plannableItem', 'calendarSlot']), 201); + // If calendar_slot_id is provided, use existing flow (backward compatibility) + if (isset($validated['calendar_slot_id'])) { + $plannedItem = $this->createAction->executeFromSlot($validated); + return response()->json($plannedItem, 201); + } + + // New flow: Create CalendarSlot from datetime using Action + try { + $calendarSlot = $this->createAction->execute($validated); + return response()->json($calendarSlot, 201); + } catch (\Exception $e) { + return response()->json(['message' => $e->getMessage()], 422); + } } public function update(Request $request, PlannedItem $plannedItem): JsonResponse { + // Check authorization using Policy + if (!$this->policy->update(auth()->user(), $plannedItem)) { + return response()->json(['message' => 'Forbidden'], 403); + } + $validated = $request->validate([ 'calendar_slot_id' => 'sometimes|required|exists:calendar_slots,id', 'sort_order' => 'nullable|integer', ]); - $plannedItem->update($validated); + // If changing calendar slot, verify user owns the new slot's trip + if (isset($validated['calendar_slot_id'])) { + if (!$this->policy->moveToSlot(auth()->user(), $validated['calendar_slot_id'])) { + return response()->json(['message' => 'Forbidden'], 403); + } + } - return response()->json($plannedItem->load(['plannableItem', 'calendarSlot'])); + // Execute update using Action + $updatedItem = $this->updateAction->execute($plannedItem, $validated); + + return response()->json($updatedItem); } public function destroy(PlannedItem $plannedItem): JsonResponse { - $plannedItem->delete(); + // Check authorization using Policy + if (!$this->policy->delete(auth()->user(), $plannedItem)) { + return response()->json(['message' => 'Forbidden'], 403); + } + + // Execute delete using Action + $this->deleteAction->execute($plannedItem); return response()->json(null, 204); } diff --git a/backend/app/Infrastructure/Http/Controllers/API/Trip/TripController.php b/backend/app/Infrastructure/Http/Controllers/API/Trip/TripController.php index d17bdc6..19bf89b 100644 --- a/backend/app/Infrastructure/Http/Controllers/API/Trip/TripController.php +++ b/backend/app/Infrastructure/Http/Controllers/API/Trip/TripController.php @@ -2,14 +2,17 @@ namespace App\Infrastructure\Http\Controllers\API\Trip; +use App\Domain\Trip\Policies\TripPolicy; use App\Infrastructure\Http\Controllers\Controller; use App\Models\Trip; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; -use Illuminate\Validation\ValidationException; class TripController extends Controller { + public function __construct( + private TripPolicy $policy + ) {} /** * Display a listing of the resource. */ @@ -44,11 +47,11 @@ public function store(Request $request): JsonResponse /** * Display the specified resource. */ - public function show(Request $request, string $id): JsonResponse + public function show(Request $request, Trip $trip): JsonResponse { - $trip = Trip::where('id', $id) - ->where('created_by_user_id', $request->user()->id) - ->firstOrFail(); + if (!$this->policy->view($request->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } return response()->json(['data' => $trip]); } @@ -56,11 +59,11 @@ public function show(Request $request, string $id): JsonResponse /** * Update the specified resource in storage. */ - public function update(Request $request, string $id): JsonResponse + public function update(Request $request, Trip $trip): JsonResponse { - $trip = Trip::where('id', $id) - ->where('created_by_user_id', $request->user()->id) - ->firstOrFail(); + if (!$this->policy->update($request->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } $validated = $request->validate([ 'name' => 'required|string|max:255', @@ -77,11 +80,11 @@ public function update(Request $request, string $id): JsonResponse /** * Remove the specified resource from storage. */ - public function destroy(Request $request, string $id): JsonResponse + public function destroy(Request $request, Trip $trip): JsonResponse { - $trip = Trip::where('id', $id) - ->where('created_by_user_id', $request->user()->id) - ->firstOrFail(); + if (!$this->policy->delete($request->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } $trip->delete(); diff --git a/backend/database/migrations/2025_09_28_064407_create_calendar_slots_table.php b/backend/database/migrations/2025_09_28_064407_create_calendar_slots_table.php index fa145c9..940a299 100644 --- a/backend/database/migrations/2025_09_28_064407_create_calendar_slots_table.php +++ b/backend/database/migrations/2025_09_28_064407_create_calendar_slots_table.php @@ -21,8 +21,9 @@ public function up(): void $table->integer('slot_order')->default(0); $table->timestamps(); - $table->index(['trip_id', 'slot_date']); - $table->index(['trip_id', 'slot_order']); + // Composite indexes for performance + $table->index(['trip_id', 'slot_date', 'datetime_start']); + $table->index(['trip_id', 'slot_date', 'slot_order']); }); } diff --git a/backend/tests/Feature/CalendarSlotTest.php b/backend/tests/Feature/CalendarSlotTest.php index edf578e..4efb070 100644 --- a/backend/tests/Feature/CalendarSlotTest.php +++ b/backend/tests/Feature/CalendarSlotTest.php @@ -227,4 +227,110 @@ public function test_slots_created_when_dates_added_to_trip() $trip->refresh(); $this->assertCount(2, $trip->calendarSlots); } + + public function test_calendar_slots_include_planned_items_with_plannable_item() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-01' + ]); + + // Create a plannable item and schedule it + $plannableItem = \App\Models\PlannableItem::factory()->create([ + 'trip_id' => $trip->id, + 'name' => 'Eiffel Tower' + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson('/api/planned-items', [ + 'plannable_item_id' => $plannableItem->id, + 'trip_id' => $trip->id, + 'start_datetime' => '2024-01-01 14:00:00', + 'end_datetime' => '2024-01-01 16:00:00', + ]); + + $response->assertStatus(201); + + // Fetch calendar slots and verify relationships are loaded + $slotsResponse = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$trip->id}/calendar-slots"); + + $slotsResponse->assertStatus(200); + + // Find the slot we just created + $slots = $slotsResponse->json('data'); + $scheduledSlot = collect($slots)->firstWhere('name', 'Eiffel Tower'); + + $this->assertNotNull($scheduledSlot); + $this->assertArrayHasKey('planned_items', $scheduledSlot); + $this->assertCount(1, $scheduledSlot['planned_items']); + $this->assertArrayHasKey('plannable_item', $scheduledSlot['planned_items'][0]); + $this->assertEquals('Eiffel Tower', $scheduledSlot['planned_items'][0]['plannable_item']['name']); + } + + public function test_calendar_slots_ordered_by_date_and_time() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02' + ]); + + // Create plannable items + $item1 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Breakfast']); + $item2 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Lunch']); + $item3 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Dinner Day 1']); + $item4 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Breakfast Day 2']); + + // Schedule in non-chronological order + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $item3->id, + 'trip_id' => $trip->id, + 'start_datetime' => '2024-01-01 19:00:00', + 'end_datetime' => '2024-01-01 21:00:00', + ]); + + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $item1->id, + 'trip_id' => $trip->id, + 'start_datetime' => '2024-01-01 08:00:00', + 'end_datetime' => '2024-01-01 09:00:00', + ]); + + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $item4->id, + 'trip_id' => $trip->id, + 'start_datetime' => '2024-01-02 08:00:00', + 'end_datetime' => '2024-01-02 09:00:00', + ]); + + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $item2->id, + 'trip_id' => $trip->id, + 'start_datetime' => '2024-01-01 12:00:00', + 'end_datetime' => '2024-01-01 13:00:00', + ]); + + // Fetch slots and verify they're ordered correctly + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$trip->id}/calendar-slots"); + + $response->assertStatus(200); + + $slots = $response->json('data'); + $scheduledSlots = collect($slots)->filter(fn($slot) => $slot['datetime_start'] !== null)->values(); + + // Should be ordered: Breakfast (Day 1, 8am), Lunch (Day 1, 12pm), Dinner (Day 1, 7pm), Breakfast (Day 2, 8am) + $this->assertEquals('Breakfast', $scheduledSlots[0]['name']); + $this->assertEquals('Lunch', $scheduledSlots[1]['name']); + $this->assertEquals('Dinner Day 1', $scheduledSlots[2]['name']); + $this->assertEquals('Breakfast Day 2', $scheduledSlots[3]['name']); + } } \ No newline at end of file diff --git a/backend/tests/Feature/PlannedItemTest.php b/backend/tests/Feature/PlannedItemTest.php new file mode 100644 index 0000000..b5820ef --- /dev/null +++ b/backend/tests/Feature/PlannedItemTest.php @@ -0,0 +1,321 @@ +user = User::factory()->create(); + $this->trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2025-01-15', + 'end_date' => '2025-01-17', + ]); + $this->plannableItem = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id, + 'name' => 'Eiffel Tower', + ]); + } + + /** @test */ + public function it_creates_calendar_slot_when_scheduling_item_with_datetime() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('calendar_slots', [ + 'trip_id' => $this->trip->id, + 'name' => 'Eiffel Tower', + 'slot_date' => '2025-01-15', + ]); + $this->assertDatabaseHas('planned_items', [ + 'plannable_item_id' => $this->plannableItem->id, + ]); + } + + /** @test */ + public function it_calculates_slot_order_correctly() + { + // Create first slot at 10:00 + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 10:00:00', + 'end_datetime' => '2025-01-15 11:00:00', + ]); + + // Create another plannable item + $secondItem = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id, + 'name' => 'Louvre', + ]); + + // Create second slot at 14:00 + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $secondItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + // Create third slot at 08:00 (should have slot_order 0 after recalculation) + $thirdItem = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id, + 'name' => 'Breakfast', + ]); + + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $thirdItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 08:00:00', + 'end_datetime' => '2025-01-15 09:00:00', + ]); + + $slots = CalendarSlot::where('trip_id', $this->trip->id) + ->where('slot_date', '2025-01-15') + ->orderBy('slot_order') + ->get(); + + // After recalculation, order should be by datetime_start + $this->assertEquals('Breakfast', $slots[0]->name); + $this->assertEquals(0, $slots[0]->slot_order); + $this->assertEquals('Eiffel Tower', $slots[1]->name); + $this->assertEquals(1, $slots[1]->slot_order); + $this->assertEquals('Louvre', $slots[2]->name); + $this->assertEquals(2, $slots[2]->slot_order); + } + + /** @test */ + public function it_validates_end_time_is_after_start_time() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 16:00:00', + 'end_datetime' => '2025-01-15 14:00:00', + ]); + + $response->assertStatus(422); + } + + /** @test */ + public function it_requires_plannable_item_to_belong_to_trip() + { + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + ]); + $otherItem = PlannableItem::factory()->create([ + 'trip_id' => $otherTrip->id, + ]); + + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $otherItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(422); + $response->assertJson([ + 'error' => 'Plannable item does not belong to this trip' + ]); + } + + /** @test */ + public function it_supports_backward_compatible_calendar_slot_id_flow() + { + $calendarSlot = CalendarSlot::factory()->create([ + 'trip_id' => $this->trip->id, + ]); + + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot->id, + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('planned_items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot->id, + ]); + } + + /** @test */ + public function it_requires_authentication_to_create_planned_item() + { + $response = $this->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(401); + } + + /** @test */ + public function it_validates_required_fields() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['plannable_item_id', 'trip_id', 'start_datetime', 'end_datetime']); + } + + /** @test */ + public function it_validates_plannable_item_exists() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => 99999, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['plannable_item_id']); + } + + /** @test */ + public function it_validates_trip_exists() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => 99999, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['trip_id']); + } + + /** @test */ + public function it_returns_calendar_slot_with_relationships() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure([ + 'id', + 'trip_id', + 'name', + 'datetime_start', + 'datetime_end', + 'slot_date', + 'slot_order', + 'planned_items' => [ + '*' => [ + 'id', + 'plannable_item_id', + 'calendar_slot_id', + 'plannable_item' => [ + 'id', + 'name', + 'trip_id', + ] + ] + ] + ]); + } + + /** @test */ + public function it_can_update_planned_item_slot() + { + $calendarSlot1 = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]); + $calendarSlot2 = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]); + + $plannedItem = PlannedItem::create([ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot1->id, + 'sort_order' => 0, + ]); + + $response = $this->actingAs($this->user)->putJson("/api/planned-items/{$plannedItem->id}", [ + 'calendar_slot_id' => $calendarSlot2->id, + 'sort_order' => 5, + ]); + + $response->assertStatus(200); + $this->assertDatabaseHas('planned_items', [ + 'id' => $plannedItem->id, + 'calendar_slot_id' => $calendarSlot2->id, + 'sort_order' => 5, + ]); + } + + /** @test */ + public function it_can_delete_planned_item() + { + $calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]); + $plannedItem = PlannedItem::create([ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot->id, + 'sort_order' => 0, + ]); + + $response = $this->actingAs($this->user)->deleteJson("/api/planned-items/{$plannedItem->id}"); + + $response->assertStatus(204); + $this->assertDatabaseMissing('planned_items', [ + 'id' => $plannedItem->id, + ]); + } + + /** @test */ + public function it_requires_authentication_to_update_planned_item() + { + $calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]); + $plannedItem = PlannedItem::create([ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot->id, + ]); + + $response = $this->putJson("/api/planned-items/{$plannedItem->id}", [ + 'sort_order' => 1, + ]); + + $response->assertStatus(401); + } + + /** @test */ + public function it_requires_authentication_to_delete_planned_item() + { + $calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]); + $plannedItem = PlannedItem::create([ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot->id, + ]); + + $response = $this->deleteJson("/api/planned-items/{$plannedItem->id}"); + + $response->assertStatus(401); + } +} diff --git a/backend/tests/Unit/CalendarSlotServiceTest.php b/backend/tests/Unit/CalendarSlotServiceTest.php new file mode 100644 index 0000000..68b4ad3 --- /dev/null +++ b/backend/tests/Unit/CalendarSlotServiceTest.php @@ -0,0 +1,235 @@ +service = new CalendarSlotService(); + $this->trip = Trip::factory()->create([ + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-03', + ]); + } + + /** @test */ + public function it_calculates_slot_order_for_first_slot_on_date() + { + $order = $this->service->calculateSlotOrder( + $this->trip->id, + '2024-01-01', + Carbon::parse('2024-01-01 10:00:00') + ); + + $this->assertEquals(0, $order); + } + + /** @test */ + public function it_calculates_slot_order_based_on_chronological_position() + { + // Create existing slots + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Morning', + 'datetime_start' => '2024-01-01 08:00:00', + 'datetime_end' => '2024-01-01 09:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 0, + ]); + + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Afternoon', + 'datetime_start' => '2024-01-01 14:00:00', + 'datetime_end' => '2024-01-01 15:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 1, + ]); + + // Calculate order for noon slot (should be between morning and afternoon) + $order = $this->service->calculateSlotOrder( + $this->trip->id, + '2024-01-01', + Carbon::parse('2024-01-01 12:00:00') + ); + + $this->assertEquals(1, $order); + } + + /** @test */ + public function it_calculates_slot_order_for_earliest_time() + { + // Create existing slot + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Late Morning', + 'datetime_start' => '2024-01-01 10:00:00', + 'datetime_end' => '2024-01-01 11:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 0, + ]); + + // Calculate order for earlier time + $order = $this->service->calculateSlotOrder( + $this->trip->id, + '2024-01-01', + Carbon::parse('2024-01-01 08:00:00') + ); + + $this->assertEquals(0, $order); + } + + /** @test */ + public function it_calculates_slot_order_for_latest_time() + { + // Create existing slots + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Morning', + 'datetime_start' => '2024-01-01 08:00:00', + 'datetime_end' => '2024-01-01 09:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 0, + ]); + + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Noon', + 'datetime_start' => '2024-01-01 12:00:00', + 'datetime_end' => '2024-01-01 13:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 1, + ]); + + // Calculate order for later time + $order = $this->service->calculateSlotOrder( + $this->trip->id, + '2024-01-01', + Carbon::parse('2024-01-01 20:00:00') + ); + + $this->assertEquals(2, $order); + } + + /** @test */ + public function it_only_considers_slots_from_same_date() + { + // Create slot on different date + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Other Day', + 'datetime_start' => '2024-01-02 08:00:00', + 'datetime_end' => '2024-01-02 09:00:00', + 'slot_date' => '2024-01-02', + 'slot_order' => 0, + ]); + + // Calculate order for Jan 1 (should start at 0, not affected by Jan 2) + $order = $this->service->calculateSlotOrder( + $this->trip->id, + '2024-01-01', + Carbon::parse('2024-01-01 10:00:00') + ); + + $this->assertEquals(0, $order); + } + + /** @test */ + public function it_recalculates_slot_orders_for_date() + { + // Create slots in non-chronological slot_order + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Evening', + 'datetime_start' => '2024-01-01 20:00:00', + 'datetime_end' => '2024-01-01 21:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 5, // Wrong order + ]); + + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Morning', + 'datetime_start' => '2024-01-01 08:00:00', + 'datetime_end' => '2024-01-01 09:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 3, // Wrong order + ]); + + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Afternoon', + 'datetime_start' => '2024-01-01 14:00:00', + 'datetime_end' => '2024-01-01 15:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 1, // Wrong order + ]); + + // Recalculate + $this->service->recalculateSlotOrdersForDate($this->trip->id, '2024-01-01'); + + // Verify correct chronological order + $slots = CalendarSlot::where('trip_id', $this->trip->id) + ->where('slot_date', '2024-01-01') + ->orderBy('slot_order') + ->get(); + + $this->assertEquals('Morning', $slots[0]->name); + $this->assertEquals(0, $slots[0]->slot_order); + + $this->assertEquals('Afternoon', $slots[1]->name); + $this->assertEquals(1, $slots[1]->slot_order); + + $this->assertEquals('Evening', $slots[2]->name); + $this->assertEquals(2, $slots[2]->slot_order); + } + + /** @test */ + public function it_only_recalculates_slots_for_specified_date() + { + // Create slots on Jan 1 + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Jan 1 Slot', + 'datetime_start' => '2024-01-01 10:00:00', + 'datetime_end' => '2024-01-01 11:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 5, + ]); + + // Create slots on Jan 2 + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Jan 2 Slot', + 'datetime_start' => '2024-01-02 10:00:00', + 'datetime_end' => '2024-01-02 11:00:00', + 'slot_date' => '2024-01-02', + 'slot_order' => 7, + ]); + + // Recalculate only Jan 1 + $this->service->recalculateSlotOrdersForDate($this->trip->id, '2024-01-01'); + + // Jan 1 should be recalculated to 0 + $jan1Slot = CalendarSlot::where('slot_date', '2024-01-01')->first(); + $this->assertEquals(0, $jan1Slot->slot_order); + + // Jan 2 should remain unchanged + $jan2Slot = CalendarSlot::where('slot_date', '2024-01-02')->first(); + $this->assertEquals(7, $jan2Slot->slot_order); + } +} diff --git a/docker/backend/Dockerfile.dev b/docker/backend/Dockerfile.dev index 3bc831e..0974091 100644 --- a/docker/backend/Dockerfile.dev +++ b/docker/backend/Dockerfile.dev @@ -41,4 +41,4 @@ RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessio EXPOSE 8000 # Start Laravel development server with composer install -CMD sh -c "composer install && php artisan key:generate --force && php artisan serve --host=0.0.0.0 --port=8000" \ No newline at end of file +CMD sh -c "composer install && php artisan key:generate --force && php artisan migrate --force && php artisan serve --host=0.0.0.0 --port=8000" \ No newline at end of file diff --git a/frontend/src/components/TripDetail.css b/frontend/src/components/TripDetail.css index df4cbea..27ef97c 100644 --- a/frontend/src/components/TripDetail.css +++ b/frontend/src/components/TripDetail.css @@ -67,22 +67,26 @@ /* Content Layout */ .trip-detail-content { flex: 1; - display: flex; + display: grid; + grid-template-columns: 1fr var(--sidebar-width); gap: 0; height: calc(100vh - var(--header-height)); + min-height: 0; +} + +.trip-detail-main { + padding: var(--spacing-xl); + overflow-y: auto; + background: var(--color-bg-secondary); + order: 1; } .trip-detail-sidebar { width: var(--sidebar-width); background: var(--color-bg-primary); - border-right: 1px solid var(--color-border); - overflow-y: auto; -} - -.trip-detail-main { - flex: 1; - padding: var(--spacing-xl); + border-left: 1px solid var(--color-border); overflow-y: auto; + order: 2; } /* Calendar Placeholder */ @@ -165,19 +169,23 @@ /* Responsive */ @media (max-width: 768px) { .trip-detail-content { - flex-direction: column; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; height: auto; } - .trip-detail-sidebar { - width: 100%; - border-right: none; - border-bottom: 1px solid var(--color-border); - min-height: 400px; + .trip-detail-main { + order: 2; + padding: var(--spacing-md); } - .trip-detail-main { - padding: var(--spacing-md); + .trip-detail-sidebar { + order: 1; + width: 100%; + border-left: none; + border-bottom: 1px solid var(--color-border); + min-height: 300px; + max-height: 50vh; } .trip-detail-header { diff --git a/frontend/src/components/TripDetail.jsx b/frontend/src/components/TripDetail.jsx index 7183be1..85b376b 100644 --- a/frontend/src/components/TripDetail.jsx +++ b/frontend/src/components/TripDetail.jsx @@ -3,12 +3,17 @@ import { useParams, useNavigate, Link } from 'react-router-dom'; import { formatDate } from '../utils/dateFormatter'; import { useTrip } from '../hooks/useTrip'; import PlannablesList from './plannables/PlannablesList'; +import TripTimeline from './timeline/TripTimeline'; +import axios from 'axios'; import './TripDetail.css'; +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + const TripDetail = () => { const { id } = useParams(); const navigate = useNavigate(); const [trip, setTrip] = useState(null); + const [plannableItems, setPlannableItems] = useState([]); const { fetchTrip, loading, error } = useTrip(); useEffect(() => { @@ -24,6 +29,24 @@ const TripDetail = () => { loadTrip(); }, [id, fetchTrip]); + useEffect(() => { + const loadPlannableItems = async () => { + try { + const token = localStorage.getItem('token'); + const response = await axios.get(`${API_URL}/api/trips/${id}/plannables`, { + headers: { Authorization: `Bearer ${token}` } + }); + setPlannableItems(response.data.data || []); + } catch (err) { + console.error('Error loading plannable items:', err); + } + }; + + if (id) { + loadPlannableItems(); + } + }, [id]); + // Memoize trip dates display to prevent unnecessary re-renders const tripDatesDisplay = useMemo(() => { if (!trip) return null; @@ -79,13 +102,16 @@ const TripDetail = () => {
Calendar view will be implemented here in the future
-No items planned for this day
- ) : ( - items.map(item => ( -{dateString}
+