diff --git a/.gitignore b/.gitignore index a09c56d..c09ab17 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ /.idea +/docker/data +/.claude +CLAUDE.md +/coverage \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore index b71b1ea..4cbe1a5 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -12,6 +12,7 @@ /.vscode /.zed /auth.json +/coverage /node_modules /public/build /public/hot 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/Observers/TripObserver.php b/backend/app/Domain/Trip/Observers/TripObserver.php new file mode 100644 index 0000000..f0217f6 --- /dev/null +++ b/backend/app/Domain/Trip/Observers/TripObserver.php @@ -0,0 +1,45 @@ +calendarSlotService = $calendarSlotService; + } + + public function created(Trip $trip): void + { + if ($trip->start_date && $trip->end_date) { + $this->calendarSlotService->createOrUpdateSlotsForTrip($trip); + } + } + + public function updated(Trip $trip): void + { + if ($trip->isDirty(['start_date', 'end_date'])) { + $this->calendarSlotService->createOrUpdateSlotsForTrip($trip); + } + } + + public function deleted(Trip $trip): void + { + $this->calendarSlotService->deleteSlotsForTrip($trip); + } + + public function restored(Trip $trip): void + { + $this->calendarSlotService->createOrUpdateSlotsForTrip($trip); + } + + public function forceDeleted(Trip $trip): void + { + $this->calendarSlotService->deleteSlotsForTrip($trip); + } +} \ No newline at end of file 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/Providers/TripServiceProvider.php b/backend/app/Domain/Trip/Providers/TripServiceProvider.php new file mode 100644 index 0000000..929e0b1 --- /dev/null +++ b/backend/app/Domain/Trip/Providers/TripServiceProvider.php @@ -0,0 +1,20 @@ +start_date || !$trip->end_date) { + return collect(); + } + + // Fresh load to avoid stale relationship data + $trip->refresh(); + $existingSlots = $trip->calendarSlots; + $existingSlotsMap = $existingSlots->keyBy(function ($slot) { + return $slot->slot_date instanceof \Carbon\Carbon + ? $slot->slot_date->toDateString() + : $slot->slot_date; + }); + + $startDate = Carbon::parse($trip->start_date); + $endDate = Carbon::parse($trip->end_date); + + $newSlots = collect(); + $currentDate = $startDate->copy(); + $dayNumber = 1; + + while ($currentDate->lte($endDate)) { + $slotDate = $currentDate->toDateString(); + + if (!$existingSlotsMap->has($slotDate)) { + $slot = CalendarSlot::create([ + 'trip_id' => $trip->id, + 'name' => 'Day ' . $dayNumber, + 'slot_date' => $slotDate, + 'datetime_start' => $currentDate->copy()->startOfDay(), + 'datetime_end' => $currentDate->copy()->endOfDay(), + 'slot_order' => $dayNumber, + ]); + $newSlots->push($slot); + } else { + $existingSlot = $existingSlotsMap->get($slotDate); + $existingSlot->update([ + 'slot_order' => $dayNumber, + 'datetime_start' => $currentDate->copy()->startOfDay(), + 'datetime_end' => $currentDate->copy()->endOfDay(), + ]); + $newSlots->push($existingSlot); + } + + $currentDate->addDay(); + $dayNumber++; + } + + $trip->calendarSlots() + ->whereNotIn('slot_date', $newSlots->pluck('slot_date')) + ->delete(); + + return $newSlots; + } + + 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/Http/Controllers/Controller.php b/backend/app/Http/Controllers/Controller.php deleted file mode 100644 index 8677cd5..0000000 --- a/backend/app/Http/Controllers/Controller.php +++ /dev/null @@ -1,8 +0,0 @@ -policy->viewAny(auth()->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $calendarSlots = $trip->calendarSlots() + ->with(['plannedItems.plannableItem']) + ->orderBy('slot_date') + ->orderBy('datetime_start') + ->get(); + + return response()->json(['data' => $calendarSlots]); + } + + public function update(Request $request, CalendarSlot $calendarSlot): JsonResponse + { + if (!$this->policy->update(auth()->user(), $calendarSlot)) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:255', + ]); + + $calendarSlot->update($validated); + + return response()->json(['data' => $calendarSlot]); + } + + 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) { + 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/E2e/TestSetupController.php b/backend/app/Infrastructure/Http/Controllers/API/E2e/TestSetupController.php new file mode 100644 index 0000000..1443ec8 --- /dev/null +++ b/backend/app/Infrastructure/Http/Controllers/API/E2e/TestSetupController.php @@ -0,0 +1,73 @@ +environment('production')) { + return response()->json(['error' => 'Not available in production'], 403); + } + + $validated = $request->validate([ + 'email' => 'required|email', + 'password' => 'required|min:8', + 'name' => 'required|string' + ]); + + $user = User::firstOrCreate( + ['email' => $validated['email']], + [ + 'name' => $validated['name'], + 'password' => Hash::make($validated['password']), + 'email_verified_at' => now(), + ] + ); + + // Ensure email_verified_at is set even for existing users + if (!$user->email_verified_at) { + $user->email_verified_at = now(); + $user->save(); + } + + return response()->json([ + 'success' => true, + 'message' => $user->wasRecentlyCreated ? 'Test user created' : 'Test user already exists', + 'data' => [ + 'id' => $user->id, + 'email' => $user->email, + 'name' => $user->name + ] + ]); + } + + /** + * Clean up test data + */ + public function cleanup(Request $request) + { + if (app()->environment('production')) { + return response()->json(['error' => 'Not available in production'], 403); + } + + // Delete test users (those with specific test email patterns) + $deleted = User::where('email', 'LIKE', 'test%@example.com') + ->orWhere('email', 'LIKE', 'test.user.%@example.com') + ->delete(); + + return response()->json([ + 'success' => true, + 'message' => "Deleted $deleted test users" + ]); + } +} \ 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 new file mode 100644 index 0000000..5ed4cb3 --- /dev/null +++ b/backend/app/Infrastructure/Http/Controllers/API/PlannableItem/PlannableItemController.php @@ -0,0 +1,88 @@ +policy->create(auth()->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $plannableItems = $trip->plannableItems() + ->with(['calendarSlots']) + ->get(); + + return response()->json(['data' => $plannableItems]); + } + + public function store(Request $request, Trip $trip): JsonResponse + { + if (!$this->policy->create(auth()->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'type' => 'required|in:hotel,restaurant,attraction,transport,activity', + 'address' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + 'metadata' => 'nullable|array', + ]); + + $plannableItem = $trip->plannableItems()->create($validated); + + return response()->json(['data' => $plannableItem], 201); + } + + 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 + { + if (!$this->policy->update(auth()->user(), $plannableItem)) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:255', + 'type' => 'sometimes|required|in:hotel,restaurant,attraction,transport,activity', + 'address' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + 'metadata' => 'nullable|array', + ]); + + $plannableItem->update($validated); + + return response()->json(['data' => $plannableItem]); + } + + 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); + } +} \ No newline at end of file diff --git a/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php b/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php new file mode 100644 index 0000000..07b894a --- /dev/null +++ b/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php @@ -0,0 +1,92 @@ +validate([ + 'plannable_item_id' => 'required|exists:plannable_items,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', + ]); + + // Check authorization using Policy + if (!$this->policy->create(auth()->user(), $validated['trip_id'])) { + return response()->json(['message' => 'Forbidden'], 403); + } + + // 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', + ]); + + // 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); + } + } + + // Execute update using Action + $updatedItem = $this->updateAction->execute($plannedItem, $validated); + + return response()->json($updatedItem); + } + + public function destroy(PlannedItem $plannedItem): JsonResponse + { + // 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); + } +} \ No newline at end of file diff --git a/backend/app/Infrastructure/Http/Controllers/API/Trip/TripController.php b/backend/app/Infrastructure/Http/Controllers/API/Trip/TripController.php new file mode 100644 index 0000000..19bf89b --- /dev/null +++ b/backend/app/Infrastructure/Http/Controllers/API/Trip/TripController.php @@ -0,0 +1,93 @@ +user()->id) + ->orderBy('created_at', 'desc') + ->get(); + + return response()->json(['data' => $trips]); + } + + /** + * Store a newly created resource in storage. + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]); + + $validated['created_by_user_id'] = $request->user()->id; + + $trip = Trip::create($validated); + + return response()->json(['data' => $trip], 201); + } + + /** + * Display the specified resource. + */ + public function show(Request $request, Trip $trip): JsonResponse + { + if (!$this->policy->view($request->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } + + return response()->json(['data' => $trip]); + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, Trip $trip): JsonResponse + { + if (!$this->policy->update($request->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'start_date' => 'nullable|date', + 'end_date' => 'nullable|date|after_or_equal:start_date', + ]); + + $trip->update($validated); + + return response()->json(['data' => $trip]); + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(Request $request, Trip $trip): JsonResponse + { + if (!$this->policy->delete($request->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $trip->delete(); + + return response()->json(['message' => 'Trip deleted successfully']); + } +} diff --git a/backend/app/Infrastructure/Http/Controllers/API/User/Auth/AuthController.php b/backend/app/Infrastructure/Http/Controllers/API/User/Auth/AuthController.php new file mode 100644 index 0000000..bb9e583 --- /dev/null +++ b/backend/app/Infrastructure/Http/Controllers/API/User/Auth/AuthController.php @@ -0,0 +1,103 @@ +all(), [ + 'name' => 'required|string|max:255', + 'email' => 'required|string|email|max:255|unique:users', + 'password' => 'required|string|min:8|confirmed', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation errors', + 'data' => $validator->errors() + ], 422); + } + + $user = User::create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => Hash::make($request->password), + ]); + + $token = $user->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => 'User registered successfully', + 'data' => [ + 'user' => $user, + 'access_token' => $token, + 'token_type' => 'Bearer' + ] + ], 201); + } + + public function login(Request $request) + { + $validator = Validator::make($request->all(), [ + 'email' => 'required|email', + 'password' => 'required', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation errors', + 'data' => $validator->errors() + ], 422); + } + + $user = User::where('email', $request->email)->first(); + + if (!$user || !Hash::check($request->password, $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['The provided credentials are incorrect.'], + ]); + } + + $token = $user->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'success' => true, + 'message' => 'Login successful', + 'data' => [ + 'user' => $user, + 'access_token' => $token, + 'token_type' => 'Bearer' + ] + ]); + } + + public function profile(Request $request) + { + return response()->json([ + 'success' => true, + 'message' => 'Profile retrieved successfully', + 'data' => $request->user() + ]); + } + + public function logout(Request $request) + { + $request->user()->currentAccessToken()->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Logout successful' + ]); + } +} \ No newline at end of file diff --git a/backend/app/Infrastructure/Http/Controllers/Controller.php b/backend/app/Infrastructure/Http/Controllers/Controller.php new file mode 100644 index 0000000..c1cebc7 --- /dev/null +++ b/backend/app/Infrastructure/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +headers->get('Origin'); + $allowedOrigins = config('cors.allowed_origins', ['http://localhost:5173']); + $allowedOrigin = $allowedOrigins[0] ?? 'http://localhost:5173'; + + // Only set CORS headers if the origin matches our frontend + if ($origin === $allowedOrigin) { + $response->headers->set('Access-Control-Allow-Origin', $origin); + $response->headers->set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + $response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, Accept, X-XSRF-TOKEN'); + $response->headers->set('Access-Control-Allow-Credentials', 'true'); + } + + // Handle preflight OPTIONS requests + if ($request->getMethod() === 'OPTIONS') { + $response->setStatusCode(200); + } + + return $response; + } +} \ No newline at end of file diff --git a/backend/app/Models/CalendarSlot.php b/backend/app/Models/CalendarSlot.php new file mode 100644 index 0000000..523cebf --- /dev/null +++ b/backend/app/Models/CalendarSlot.php @@ -0,0 +1,46 @@ + 'datetime', + 'datetime_end' => 'datetime', + 'slot_date' => 'date', + ]; + + public function trip(): BelongsTo + { + return $this->belongsTo(Trip::class); + } + + public function plannableItems(): BelongsToMany + { + return $this->belongsToMany(PlannableItem::class, 'planned_items') + ->withPivot('sort_order') + ->withTimestamps() + ->orderBy('planned_items.sort_order'); + } + + public function plannedItems() + { + return $this->hasMany(PlannedItem::class); + } +} diff --git a/backend/app/Models/PlannableItem.php b/backend/app/Models/PlannableItem.php new file mode 100644 index 0000000..f6a1886 --- /dev/null +++ b/backend/app/Models/PlannableItem.php @@ -0,0 +1,44 @@ + 'array', + ]; + + public function trip(): BelongsTo + { + return $this->belongsTo(Trip::class); + } + + public function calendarSlots(): BelongsToMany + { + return $this->belongsToMany(CalendarSlot::class, 'planned_items') + ->withPivot('sort_order') + ->withTimestamps() + ->orderBy('planned_items.sort_order'); + } + + public function plannedItems() + { + return $this->hasMany(PlannedItem::class); + } +} diff --git a/backend/app/Models/PlannedItem.php b/backend/app/Models/PlannedItem.php new file mode 100644 index 0000000..52b83ab --- /dev/null +++ b/backend/app/Models/PlannedItem.php @@ -0,0 +1,28 @@ +belongsTo(PlannableItem::class); + } + + public function calendarSlot(): BelongsTo + { + return $this->belongsTo(CalendarSlot::class); + } +} diff --git a/backend/app/Models/Trip.php b/backend/app/Models/Trip.php new file mode 100644 index 0000000..ea11dc8 --- /dev/null +++ b/backend/app/Models/Trip.php @@ -0,0 +1,40 @@ + 'date', + 'end_date' => 'date', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function plannableItems(): HasMany + { + return $this->hasMany(PlannableItem::class); + } + + public function calendarSlots(): HasMany + { + return $this->hasMany(CalendarSlot::class)->orderBy('slot_date')->orderBy('slot_order'); + } +} diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 749c7b7..22e8508 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -6,11 +6,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; /** * The attributes that are mass assignable. @@ -45,4 +46,12 @@ protected function casts(): array 'password' => 'hashed', ]; } + + /** + * Get the trips created by this user. + */ + public function trips() + { + return $this->hasMany(Trip::class, 'created_by_user_id'); + } } diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 452e6b6..222bdb1 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Models\Trip; +use App\Domain\Trip\Observers\TripObserver; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -19,6 +21,6 @@ public function register(): void */ public function boot(): void { - // + Trip::observe(TripObserver::class); } } diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index c183276..9dec942 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -7,11 +7,14 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->api(prepend: [ + \App\Infrastructure\Http\Middleware\Cors::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php index 38b258d..1823eaf 100644 --- a/backend/bootstrap/providers.php +++ b/backend/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Domain\Trip\Providers\TripServiceProvider::class, ]; diff --git a/backend/composer.json b/backend/composer.json index 9089a07..a1cb6bb 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -11,15 +11,20 @@ "require": { "php": "^8.2", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1" }, "require-dev": { "fakerphp/faker": "^1.23", + "larastan/larastan": "^3.7", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.6", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^11.5.3" }, "autoload": { @@ -75,4 +80,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} \ No newline at end of file +} diff --git a/backend/composer.lock b/backend/composer.lock index ae4585a..884931e 100644 --- a/backend/composer.lock +++ b/backend/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": "c514d8f7b9fc5970bdd94287905ef584", + "content-hash": "9656361ea974cbb5fad3b98127519a54", "packages": [ { "name": "brick/math", @@ -1332,6 +1332,70 @@ }, "time": "2025-09-19T13:47:56+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.2.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/contracts": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^11.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2025-07-09T19:45:24+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.5", @@ -6284,6 +6348,136 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "iamcal/sql-parser", + "version": "v0.6", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.6" + }, + "time": "2025-03-17T16:59:46+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.7.2", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "a761859a7487bd7d0cb8b662a7538a234d5bb5ae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/a761859a7487bd7d0cb8b662a7538a234d5bb5ae", + "reference": "a761859a7487bd7d0cb8b662a7538a234d5bb5ae", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.6.0", + "illuminate/console": "^11.44.2 || ^12.4.1", + "illuminate/container": "^11.44.2 || ^12.4.1", + "illuminate/contracts": "^11.44.2 || ^12.4.1", + "illuminate/database": "^11.44.2 || ^12.4.1", + "illuminate/http": "^11.44.2 || ^12.4.1", + "illuminate/pipeline": "^11.44.2 || ^12.4.1", + "illuminate/support": "^11.44.2 || ^12.4.1", + "php": "^8.2", + "phpstan/phpstan": "^2.1.28" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1", + "orchestra/testbench-core": "^9.12.0 || ^10.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.7.2" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2025-09-19T09:03:05+00:00" + }, { "name": "laravel/pail", "version": "v1.2.3", @@ -6852,6 +7046,159 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.29", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phar-composer-source.git", + "reference": "git" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d618573eed4a1b6b75e37b2e0b65ac65c885d88e", + "reference": "d618573eed4a1b6b75e37b2e0b65ac65c885d88e", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-09-25T06:58:18+00:00" + }, + { + "name": "phpstan/phpstan-deprecation-rules", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-deprecation-rules.git", + "reference": "468e02c9176891cc901143da118f09dc9505fc2f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-deprecation-rules/zipball/468e02c9176891cc901143da118f09dc9505fc2f", + "reference": "468e02c9176891cc901143da118f09dc9505fc2f", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.15" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan rules for detecting usage of deprecated classes, methods, properties, constants and traits.", + "support": { + "issues": "https://github.com/phpstan/phpstan-deprecation-rules/issues", + "source": "https://github.com/phpstan/phpstan-deprecation-rules/tree/2.0.3" + }, + "time": "2025-05-14T10:56:57+00:00" + }, + { + "name": "phpstan/phpstan-strict-rules", + "version": "2.0.7", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-strict-rules.git", + "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/d6211c46213d4181054b3d77b10a5c5cb0d59538", + "reference": "d6211c46213d4181054b3d77b10a5c5cb0d59538", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.29" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Extra strict and opinionated rules for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/2.0.7" + }, + "time": "2025-09-26T11:19:08+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "11.0.11", diff --git a/backend/config/cors.php b/backend/config/cors.php new file mode 100644 index 0000000..ce5a23f --- /dev/null +++ b/backend/config/cors.php @@ -0,0 +1,34 @@ + ['api/*', 'sanctum/csrf-cookie'], + + 'allowed_methods' => ['*'], + + 'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:5173')], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; \ No newline at end of file diff --git a/backend/config/sanctum.php b/backend/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/backend/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/backend/database/factories/PlannableItemFactory.php b/backend/database/factories/PlannableItemFactory.php new file mode 100644 index 0000000..8a83d1e --- /dev/null +++ b/backend/database/factories/PlannableItemFactory.php @@ -0,0 +1,62 @@ +faker->randomElement($types); + + return [ + 'trip_id' => Trip::factory(), + 'name' => $this->faker->company(), + 'type' => $type, + 'address' => $this->faker->address(), + 'notes' => $this->faker->sentence(), + 'metadata' => $this->getMetadataForType($type), + ]; + } + + private function getMetadataForType($type) + { + switch ($type) { + case 'hotel': + return [ + 'checkin_time' => '15:00', + 'checkout_time' => '11:00', + 'confirmation_number' => $this->faker->uuid() + ]; + case 'restaurant': + return [ + 'reservation_time' => '19:00', + 'party_size' => $this->faker->numberBetween(2, 6) + ]; + case 'transport': + return [ + 'departure_time' => '10:00', + 'arrival_time' => '14:00', + 'transport_type' => $this->faker->randomElement(['flight', 'train', 'bus']) + ]; + case 'attraction': + return [ + 'opening_hours' => '9:00 AM - 5:00 PM', + 'ticket_price' => '$' . $this->faker->numberBetween(10, 50) + ]; + case 'activity': + return [ + 'duration' => $this->faker->numberBetween(1, 4) . ' hours', + 'meeting_point' => $this->faker->streetAddress() + ]; + default: + return []; + } + } +} \ No newline at end of file diff --git a/backend/database/factories/TripFactory.php b/backend/database/factories/TripFactory.php new file mode 100644 index 0000000..6391d18 --- /dev/null +++ b/backend/database/factories/TripFactory.php @@ -0,0 +1,72 @@ + + */ +class TripFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected $model = Trip::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $startDate = $this->faker->dateTimeBetween('now', '+1 year'); + $endDate = $this->faker->dateTimeBetween($startDate, '+1 year'); + + return [ + 'name' => $this->faker->sentence(3), + 'description' => $this->faker->optional()->paragraph(), + 'start_date' => $this->faker->optional()->date('Y-m-d', $startDate), + 'end_date' => $this->faker->optional()->date('Y-m-d', $endDate), + 'created_by_user_id' => User::factory(), + ]; + } + + /** + * Indicate that the trip has no dates. + */ + public function withoutDates(): static + { + return $this->state(fn (array $attributes) => [ + 'start_date' => null, + 'end_date' => null, + ]); + } + + /** + * Indicate that the trip is upcoming. + */ + public function upcoming(): static + { + return $this->state(fn (array $attributes) => [ + 'start_date' => now()->addDays(30)->format('Y-m-d'), + 'end_date' => now()->addDays(37)->format('Y-m-d'), + ]); + } + + /** + * Indicate that the trip is past. + */ + public function past(): static + { + return $this->state(fn (array $attributes) => [ + 'start_date' => now()->subDays(37)->format('Y-m-d'), + 'end_date' => now()->subDays(30)->format('Y-m-d'), + ]); + } +} \ No newline at end of file diff --git a/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php b/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/backend/database/migrations/2025_09_25_234247_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/backend/database/migrations/2025_09_27_004838_create_trips_table.php b/backend/database/migrations/2025_09_27_004838_create_trips_table.php new file mode 100644 index 0000000..8cb2d53 --- /dev/null +++ b/backend/database/migrations/2025_09_27_004838_create_trips_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->text('description')->nullable(); + $table->date('start_date')->nullable(); + $table->date('end_date')->nullable(); + $table->foreignId('created_by_user_id')->constrained('users')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('trips'); + } +}; diff --git a/backend/database/migrations/2025_09_28_064348_create_plannable_items_table.php b/backend/database/migrations/2025_09_28_064348_create_plannable_items_table.php new file mode 100644 index 0000000..fd012c7 --- /dev/null +++ b/backend/database/migrations/2025_09_28_064348_create_plannable_items_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('trip_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->enum('type', ['hotel', 'restaurant', 'attraction', 'transport', 'activity']); + $table->string('address')->nullable(); + $table->text('notes')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plannable_items'); + } +}; 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 new file mode 100644 index 0000000..940a299 --- /dev/null +++ b/backend/database/migrations/2025_09_28_064407_create_calendar_slots_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('trip_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->dateTime('datetime_start')->nullable(); + $table->dateTime('datetime_end')->nullable(); + $table->date('slot_date'); + $table->integer('slot_order')->default(0); + $table->timestamps(); + + // Composite indexes for performance + $table->index(['trip_id', 'slot_date', 'datetime_start']); + $table->index(['trip_id', 'slot_date', 'slot_order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('calendar_slots'); + } +}; diff --git a/backend/database/migrations/2025_09_28_064428_create_planned_items_table.php b/backend/database/migrations/2025_09_28_064428_create_planned_items_table.php new file mode 100644 index 0000000..4c6d6fa --- /dev/null +++ b/backend/database/migrations/2025_09_28_064428_create_planned_items_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('plannable_item_id')->constrained()->cascadeOnDelete(); + $table->foreignId('calendar_slot_id')->constrained()->cascadeOnDelete(); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['plannable_item_id', 'calendar_slot_id']); + $table->index(['calendar_slot_id', 'sort_order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('planned_items'); + } +}; diff --git a/backend/database/seeders/TestUserSeeder.php b/backend/database/seeders/TestUserSeeder.php new file mode 100644 index 0000000..36d4d6b --- /dev/null +++ b/backend/database/seeders/TestUserSeeder.php @@ -0,0 +1,36 @@ + 'test@example.com'], + [ + 'name' => 'Test User', + 'password' => Hash::make('password123'), + 'email_verified_at' => now(), + ] + ); + + // Create additional test users if needed + User::firstOrCreate( + ['email' => 'admin@example.com'], + [ + 'name' => 'Admin User', + 'password' => Hash::make('admin123'), + 'email_verified_at' => now(), + ] + ); + } +} \ No newline at end of file diff --git a/backend/phpstan.neon b/backend/phpstan.neon new file mode 100644 index 0000000..ed67d73 --- /dev/null +++ b/backend/phpstan.neon @@ -0,0 +1,24 @@ +includes: + - vendor/larastan/larastan/extension.neon + +parameters: + level: 0 + + paths: + - app/ + - config/ + - database/ + - routes/ + - tests/ + + excludePaths: + - database/migrations/* + + reportUnmatchedIgnoredErrors: false + + ignoreErrors: + # Ignore errors for facades which are resolved at runtime + - '#Call to an undefined static method#' + + # Laravel specific settings + checkModelProperties: true \ No newline at end of file diff --git a/backend/phpunit.xml b/backend/phpunit.xml index 5fd5bcf..dbeea8e 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -16,7 +16,20 @@ app + + app/Console + app/Exceptions + app/Http/Kernel.php + app/Providers/BroadcastServiceProvider.php + + + + + + + + diff --git a/backend/routes/api.php b/backend/routes/api.php new file mode 100644 index 0000000..72b8a0f --- /dev/null +++ b/backend/routes/api.php @@ -0,0 +1,54 @@ +where('any', '.*'); + +// Public routes +Route::post('/register', [AuthController::class, 'register']); +Route::post('/login', [AuthController::class, 'login']); + +// E2E test routes (development/testing only) +Route::prefix('e2e/test')->group(function () { + Route::post('/setup/user', [TestSetupController::class, 'createTestUser']); + Route::post('/cleanup', [TestSetupController::class, 'cleanup']); +}); + +// Protected routes +Route::middleware('auth:sanctum')->group(function () { + Route::get('/user', function (Request $request) { + return $request->user(); + }); + Route::get('/profile', [AuthController::class, 'profile']); + Route::post('/logout', [AuthController::class, 'logout']); + + // Trip routes + Route::apiResource('trips', TripController::class); + + // Plannable items routes + Route::get('trips/{trip}/plannables', [PlannableItemController::class, 'index']); + Route::post('trips/{trip}/plannables', [PlannableItemController::class, 'store']); + Route::get('plannables/{plannableItem}', [PlannableItemController::class, 'show']); + Route::put('plannables/{plannableItem}', [PlannableItemController::class, 'update']); + Route::delete('plannables/{plannableItem}', [PlannableItemController::class, 'destroy']); + + // Calendar slots routes + Route::get('trips/{trip}/calendar-slots', [CalendarSlotController::class, 'index']); + Route::put('calendar-slots/{calendarSlot}', [CalendarSlotController::class, 'update']); + Route::put('calendar-slots/{calendarSlot}/reorder', [CalendarSlotController::class, 'reorder']); + + // Planned items routes + Route::post('planned-items', [PlannedItemController::class, 'store']); + Route::put('planned-items/{plannedItem}', [PlannedItemController::class, 'update']); + Route::delete('planned-items/{plannedItem}', [PlannedItemController::class, 'destroy']); +}); diff --git a/backend/tests/Feature/AuthTest.php b/backend/tests/Feature/AuthTest.php new file mode 100644 index 0000000..1f7b860 --- /dev/null +++ b/backend/tests/Feature/AuthTest.php @@ -0,0 +1,457 @@ + 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]; + + $response = $this->postJson('/api/register', $userData); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'success', + 'message', + 'data' => [ + 'user' => [ + 'id', + 'name', + 'email', + 'created_at', + 'updated_at', + ], + 'access_token', + 'token_type', + ] + ]) + ->assertJson([ + 'success' => true, + 'message' => 'User registered successfully', + 'data' => [ + 'user' => [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ], + 'token_type' => 'Bearer', + ] + ]); + + // Verify user was created in database + $this->assertDatabaseHas('users', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + // Verify password was hashed + $user = User::where('email', 'john@example.com')->first(); + $this->assertTrue(Hash::check('password123', $user->password)); + } + + /** + * Test registration validation errors. + */ + public function test_registration_validates_required_fields() + { + // Test without any data + $response = $this->postJson('/api/register', []); + + $response->assertStatus(422) + ->assertJson([ + 'success' => false, + 'message' => 'Validation errors', + ]) + ->assertJsonPath('data.name', fn($value) => !empty($value)) + ->assertJsonPath('data.email', fn($value) => !empty($value)) + ->assertJsonPath('data.password', fn($value) => !empty($value)); + + // Test with invalid email + $response = $this->postJson('/api/register', [ + 'name' => 'John Doe', + 'email' => 'invalid-email', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]); + + $response->assertStatus(422) + ->assertJsonPath('data.email', fn($value) => !empty($value)); + + // Test with password too short + $response = $this->postJson('/api/register', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => '123', + 'password_confirmation' => '123', + ]); + + $response->assertStatus(422) + ->assertJsonPath('data.password', fn($value) => !empty($value)); + + // Test with password confirmation mismatch + $response = $this->postJson('/api/register', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'different123', + ]); + + $response->assertStatus(422) + ->assertJsonPath('data.password', fn($value) => !empty($value)); + } + + /** + * Test registration with duplicate email. + */ + public function test_registration_prevents_duplicate_email() + { + // Create an existing user + User::factory()->create([ + 'email' => 'existing@example.com', + ]); + + $userData = [ + 'name' => 'John Doe', + 'email' => 'existing@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]; + + $response = $this->postJson('/api/register', $userData); + + $response->assertStatus(422) + ->assertJsonPath('data.email', fn($value) => !empty($value)); + } + + /** + * Test user login with valid credentials. + */ + public function test_user_can_login_with_valid_credentials() + { + $user = User::factory()->create([ + 'email' => 'john@example.com', + 'password' => Hash::make('password123'), + ]); + + $loginData = [ + 'email' => 'john@example.com', + 'password' => 'password123', + ]; + + $response = $this->postJson('/api/login', $loginData); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'success', + 'message', + 'data' => [ + 'user' => [ + 'id', + 'name', + 'email', + 'created_at', + 'updated_at', + ], + 'access_token', + 'token_type', + ] + ]) + ->assertJson([ + 'success' => true, + 'message' => 'Login successful', + 'data' => [ + 'user' => [ + 'id' => $user->id, + 'email' => 'john@example.com', + ], + 'token_type' => 'Bearer', + ] + ]); + + // Verify token is valid + $this->assertNotEmpty($response->json('data.access_token')); + } + + /** + * Test login with invalid credentials. + */ + public function test_login_fails_with_invalid_credentials() + { + $user = User::factory()->create([ + 'email' => 'john@example.com', + 'password' => Hash::make('correct-password'), + ]); + + // Test with wrong password - This should return 422 with validation exception + $response = $this->postJson('/api/login', [ + 'email' => 'john@example.com', + 'password' => 'wrong-password', + ]); + + $response->assertStatus(422); + + // Test with non-existent email + $response = $this->postJson('/api/login', [ + 'email' => 'nonexistent@example.com', + 'password' => 'password123', + ]); + + $response->assertStatus(422); + } + + /** + * Test login validation errors. + */ + public function test_login_validates_required_fields() + { + // Test without any data + $response = $this->postJson('/api/login', []); + + $response->assertStatus(422) + ->assertJson([ + 'success' => false, + 'message' => 'Validation errors', + ]) + ->assertJsonPath('data.email', fn($value) => !empty($value)) + ->assertJsonPath('data.password', fn($value) => !empty($value)); + + // Test with invalid email format + $response = $this->postJson('/api/login', [ + 'email' => 'invalid-email', + 'password' => 'password123', + ]); + + $response->assertStatus(422) + ->assertJsonPath('data.email', fn($value) => !empty($value)); + } + + /** + * Test authenticated user can access profile. + */ + public function test_authenticated_user_can_access_profile() + { + $user = User::factory()->create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/profile'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Profile retrieved successfully', + 'data' => [ + 'id' => $user->id, + 'name' => 'John Doe', + 'email' => 'john@example.com', + ] + ]); + } + + /** + * Test unauthenticated user cannot access profile. + */ + public function test_unauthenticated_user_cannot_access_profile() + { + $response = $this->getJson('/api/profile'); + + $response->assertStatus(401); + } + + /** + * Test authenticated user can logout. + */ + public function test_authenticated_user_can_logout() + { + $user = User::factory()->create(); + $token = $user->createToken('test-token'); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token->plainTextToken, + ])->postJson('/api/logout'); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + 'message' => 'Logout successful', + ]); + + // Verify token was deleted + $this->assertDatabaseMissing('personal_access_tokens', [ + 'id' => $token->accessToken->id, + ]); + } + + /** + * Test unauthenticated user cannot logout. + */ + public function test_unauthenticated_user_cannot_logout() + { + $response = $this->postJson('/api/logout'); + + $response->assertStatus(401); + } + + /** + * Test token authentication works. + */ + public function test_token_authentication_works() + { + $user = User::factory()->create(); + $token = $user->createToken('test-token'); + + // Test that the token can be used to access protected routes + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token->plainTextToken, + ])->getJson('/api/user'); + + $response->assertStatus(200) + ->assertJson([ + 'id' => $user->id, + 'email' => $user->email, + ]); + } + + /** + * Test invalid token is rejected. + */ + public function test_invalid_token_is_rejected() + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer invalid-token', + ])->getJson('/api/user'); + + $response->assertStatus(401); + } + + /** + * Test registration creates valid tokens. + */ + public function test_registration_creates_valid_token() + { + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]; + + $response = $this->postJson('/api/register', $userData); + $token = $response->json('data.access_token'); + + // Use the token to access a protected route + $profileResponse = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + ])->getJson('/api/profile'); + + $profileResponse->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'email' => 'john@example.com', + ] + ]); + } + + /** + * Test login creates valid tokens. + */ + public function test_login_creates_valid_token() + { + $user = User::factory()->create([ + 'email' => 'john@example.com', + 'password' => Hash::make('password123'), + ]); + + $loginResponse = $this->postJson('/api/login', [ + 'email' => 'john@example.com', + 'password' => 'password123', + ]); + + $token = $loginResponse->json('data.access_token'); + + // Use the token to access a protected route + $profileResponse = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + ])->getJson('/api/profile'); + + $profileResponse->assertStatus(200) + ->assertJson([ + 'success' => true, + 'data' => [ + 'id' => $user->id, + ] + ]); + } + + /** + * Test registration returns user without password. + */ + public function test_registration_does_not_return_password() + { + $userData = [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + ]; + + $response = $this->postJson('/api/register', $userData); + + $response->assertStatus(201) + ->assertJsonMissing(['data.user.password']); + } + + /** + * Test login returns user without password. + */ + public function test_login_does_not_return_password() + { + $user = User::factory()->create([ + 'email' => 'john@example.com', + 'password' => Hash::make('password123'), + ]); + + $response = $this->postJson('/api/login', [ + 'email' => 'john@example.com', + 'password' => 'password123', + ]); + + $response->assertStatus(200) + ->assertJsonMissing(['data.user.password']); + } + + /** + * Test profile returns user without password. + */ + public function test_profile_does_not_return_password() + { + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/profile'); + + $response->assertStatus(200) + ->assertJsonMissing(['data.password']); + } +} \ No newline at end of file diff --git a/backend/tests/Feature/CalendarSlotTest.php b/backend/tests/Feature/CalendarSlotTest.php new file mode 100644 index 0000000..4efb070 --- /dev/null +++ b/backend/tests/Feature/CalendarSlotTest.php @@ -0,0 +1,336 @@ +user = User::factory()->create(); + $this->token = $this->user->createToken('test-token')->plainTextToken; + } + + public function test_calendar_slots_are_auto_created_when_trip_is_created() + { + $tripData = [ + 'name' => 'Test Trip', + 'description' => 'Test Description', + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-03' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson('/api/trips', $tripData); + + $response->assertStatus(201); + $tripId = $response->json('data.id'); + + // Check that 3 calendar slots were created for this trip (Jan 1, 2, 3) + $this->assertEquals(3, CalendarSlot::where('trip_id', $tripId)->count()); + + $slots = CalendarSlot::where('trip_id', $tripId) + ->orderBy('slot_order') + ->get(); + + $this->assertEquals('Day 1', $slots[0]->name); + $this->assertEquals('2024-01-01', $slots[0]->slot_date->format('Y-m-d')); + $this->assertEquals(1, $slots[0]->slot_order); + + $this->assertEquals('Day 2', $slots[1]->name); + $this->assertEquals('2024-01-02', $slots[1]->slot_date->format('Y-m-d')); + $this->assertEquals(2, $slots[1]->slot_order); + + $this->assertEquals('Day 3', $slots[2]->name); + $this->assertEquals('2024-01-03', $slots[2]->slot_date->format('Y-m-d')); + $this->assertEquals(3, $slots[2]->slot_order); + } + + public function test_calendar_slots_are_updated_when_trip_dates_change() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02' + ]); + + // Initially should have 2 slots + $this->assertCount(2, $trip->calendarSlots); + + // Update trip dates + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/trips/{$trip->id}", [ + 'name' => $trip->name, + 'start_date' => '2024-02-01', + 'end_date' => '2024-02-04' + ]); + + $response->assertStatus(200); + + // Should now have 4 slots with new dates + $trip->refresh(); + $slots = $trip->calendarSlots()->orderBy('slot_order')->get(); + + $this->assertCount(4, $slots); + $this->assertEquals('2024-02-01', $slots[0]->slot_date->format('Y-m-d')); + $this->assertEquals('2024-02-02', $slots[1]->slot_date->format('Y-m-d')); + $this->assertEquals('2024-02-03', $slots[2]->slot_date->format('Y-m-d')); + $this->assertEquals('2024-02-04', $slots[3]->slot_date->format('Y-m-d')); + } + + public function test_can_list_calendar_slots_for_trip() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-03' + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$trip->id}/calendar-slots"); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data') + ->assertJsonPath('data.0.name', 'Day 1') + ->assertJsonPath('data.1.name', 'Day 2') + ->assertJsonPath('data.2.name', 'Day 3'); + } + + public function test_can_update_calendar_slot_name() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-01' + ]); + + $slot = $trip->calendarSlots()->first(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/calendar-slots/{$slot->id}", [ + 'name' => 'Arrival Day' + ]); + + $response->assertStatus(200) + ->assertJsonPath('data.name', 'Arrival Day'); + + $this->assertDatabaseHas('calendar_slots', [ + 'id' => $slot->id, + 'name' => 'Arrival Day' + ]); + } + + public function test_cannot_access_calendar_slots_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02' + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$otherTrip->id}/calendar-slots"); + + $response->assertStatus(403); + } + + public function test_cannot_update_calendar_slot_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-01' + ]); + + $slot = $otherTrip->calendarSlots()->first(); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/calendar-slots/{$slot->id}", [ + 'name' => 'Hacked Name' + ]); + + $response->assertStatus(403); + } + + public function test_no_slots_created_for_trip_without_dates() + { + $tripData = [ + 'name' => 'Trip without dates', + 'description' => 'No dates set' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson('/api/trips', $tripData); + + $response->assertStatus(201); + $tripId = $response->json('data.id'); + + // No slots should be created + $this->assertDatabaseCount('calendar_slots', 0); + + $slots = CalendarSlot::where('trip_id', $tripId)->get(); + $this->assertCount(0, $slots); + } + + public function test_slots_created_when_dates_added_to_trip() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => null, + 'end_date' => null + ]); + + // Initially no slots + $this->assertCount(0, $trip->calendarSlots); + + // Add dates to trip + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/trips/{$trip->id}", [ + 'name' => $trip->name, + 'start_date' => '2024-03-01', + 'end_date' => '2024-03-02' + ]); + + $response->assertStatus(200); + + // Now should have 2 slots + $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/ExampleTest.php b/backend/tests/Feature/ExampleTest.php deleted file mode 100644 index 8364a84..0000000 --- a/backend/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,19 +0,0 @@ -get('/'); - - $response->assertStatus(200); - } -} diff --git a/backend/tests/Feature/PlannableItemTest.php b/backend/tests/Feature/PlannableItemTest.php new file mode 100644 index 0000000..6300edf --- /dev/null +++ b/backend/tests/Feature/PlannableItemTest.php @@ -0,0 +1,188 @@ +user = User::factory()->create(); + $this->trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-03' + ]); + + $this->token = $this->user->createToken('test-token')->plainTextToken; + } + + public function test_can_create_plannable_item() + { + $data = [ + 'name' => 'Eiffel Tower', + 'type' => 'attraction', + 'address' => 'Champ de Mars, Paris', + 'notes' => 'Visit in the morning', + 'metadata' => [ + 'opening_hours' => '9:00 AM - 11:00 PM', + 'ticket_price' => '25 EUR' + ] + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson("/api/trips/{$this->trip->id}/plannables", $data); + + $response->assertStatus(201) + ->assertJsonPath('data.name', 'Eiffel Tower') + ->assertJsonPath('data.type', 'attraction') + ->assertJsonPath('data.address', 'Champ de Mars, Paris') + ->assertJsonPath('data.metadata.opening_hours', '9:00 AM - 11:00 PM'); + + $this->assertDatabaseHas('plannable_items', [ + 'trip_id' => $this->trip->id, + 'name' => 'Eiffel Tower', + 'type' => 'attraction' + ]); + } + + public function test_can_list_plannable_items_for_trip() + { + PlannableItem::factory()->count(3)->create([ + 'trip_id' => $this->trip->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$this->trip->id}/plannables"); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data'); + } + + public function test_can_update_plannable_item() + { + $item = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id, + 'name' => 'Old Name' + ]); + + $updateData = [ + 'name' => 'Updated Name', + 'type' => 'restaurant', + 'notes' => 'Updated notes' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/plannables/{$item->id}", $updateData); + + $response->assertStatus(200) + ->assertJsonPath('data.name', 'Updated Name') + ->assertJsonPath('data.type', 'restaurant'); + + $this->assertDatabaseHas('plannable_items', [ + 'id' => $item->id, + 'name' => 'Updated Name', + 'type' => 'restaurant' + ]); + } + + public function test_can_delete_plannable_item() + { + $item = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->deleteJson("/api/plannables/{$item->id}"); + + $response->assertStatus(204); + + $this->assertDatabaseMissing('plannable_items', [ + 'id' => $item->id + ]); + } + + public function test_cannot_access_plannable_items_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$otherTrip->id}/plannables"); + + $response->assertStatus(403); + } + + public function test_cannot_update_plannable_item_of_other_users_trip() + { + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id + ]); + $item = PlannableItem::factory()->create([ + 'trip_id' => $otherTrip->id + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->putJson("/api/plannables/{$item->id}", [ + 'name' => 'Hacked Name' + ]); + + $response->assertStatus(403); + } + + public function test_validates_required_fields_when_creating_plannable_item() + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson("/api/trips/{$this->trip->id}/plannables", []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name', 'type']); + } + + public function test_validates_type_enum_values() + { + $data = [ + 'name' => 'Test Item', + 'type' => 'invalid_type' + ]; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson("/api/trips/{$this->trip->id}/plannables", $data); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['type']); + } +} \ 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/Feature/TestSetupControllerTest.php b/backend/tests/Feature/TestSetupControllerTest.php new file mode 100644 index 0000000..a80e941 --- /dev/null +++ b/backend/tests/Feature/TestSetupControllerTest.php @@ -0,0 +1,267 @@ + 'Test User', + 'email' => 'test@example.com', + 'password' => 'password123', + ]; + + $response = $this->postJson('/api/e2e/test/setup/user', $userData); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'message' => 'Test user created', + 'data' => [ + 'email' => 'test@example.com', + 'name' => 'Test User' + ] + ]); + + $this->assertDatabaseHas('users', [ + 'email' => 'test@example.com', + 'name' => 'Test User', + ]); + + $user = User::where('email', 'test@example.com')->first(); + $this->assertNotNull($user->email_verified_at); + $this->assertTrue(\Hash::check('password123', $user->password)); + } + + /** + * Test returning existing test user if already exists. + */ + public function test_returns_existing_test_user_if_already_exists() + { + // Create a user first + $existingUser = User::factory()->create([ + 'email' => 'existing@example.com', + 'name' => 'Existing User', + ]); + + $userData = [ + 'name' => 'Different Name', + 'email' => 'existing@example.com', + 'password' => 'password123', + ]; + + $response = $this->postJson('/api/e2e/test/setup/user', $userData); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'message' => 'Test user already exists', + 'data' => [ + 'id' => $existingUser->id, + 'email' => 'existing@example.com', + 'name' => 'Existing User' // Should keep original name + ] + ]); + + // Should not create a new user + $this->assertCount(1, User::where('email', 'existing@example.com')->get()); + } + + /** + * Test validation for required fields. + */ + public function test_validates_required_fields_for_create_user() + { + $response = $this->postJson('/api/e2e/test/setup/user', []); + + $response->assertStatus(422); + $response->assertJsonPath('errors.name', ['The name field is required.']); + $response->assertJsonPath('errors.email', ['The email field is required.']); + $response->assertJsonPath('errors.password', ['The password field is required.']); + } + + /** + * Test email validation. + */ + public function test_validates_email_format() + { + $userData = [ + 'name' => 'Test User', + 'email' => 'invalid-email', + 'password' => 'password123', + ]; + + $response = $this->postJson('/api/e2e/test/setup/user', $userData); + + $response->assertStatus(422); + $response->assertJsonPath('errors.email', ['The email field must be a valid email address.']); + } + + /** + * Test password length validation. + */ + public function test_validates_password_length() + { + $userData = [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'short', + ]; + + $response = $this->postJson('/api/e2e/test/setup/user', $userData); + + $response->assertStatus(422); + $response->assertJsonPath('errors.password', ['The password field must be at least 8 characters.']); + } + + /** + * Test cleanup removes test users with test email patterns. + */ + public function test_cleanup_removes_test_users() + { + // Create test users with test email patterns + User::factory()->create(['email' => 'test.user.1@example.com']); + User::factory()->create(['email' => 'test.user.2@example.com']); + User::factory()->create(['email' => 'test123@example.com']); + + // Create a regular user that should not be deleted + User::factory()->create(['email' => 'regular@example.com']); + + $response = $this->postJson('/api/e2e/test/cleanup'); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'message' => 'Deleted 3 test users' + ]); + + // Test users should be deleted + $this->assertDatabaseMissing('users', ['email' => 'test.user.1@example.com']); + $this->assertDatabaseMissing('users', ['email' => 'test.user.2@example.com']); + $this->assertDatabaseMissing('users', ['email' => 'test123@example.com']); + + // Regular user should still exist + $this->assertDatabaseHas('users', ['email' => 'regular@example.com']); + } + + /** + * Test cleanup when no test users exist. + */ + public function test_cleanup_when_no_test_users_exist() + { + // Create only regular users + User::factory()->create(['email' => 'regular1@example.com']); + User::factory()->create(['email' => 'regular2@example.com']); + + $response = $this->postJson('/api/e2e/test/cleanup'); + + $response->assertStatus(200); + $response->assertJson([ + 'success' => true, + 'message' => 'Deleted 0 test users' + ]); + + // Regular users should still exist + $this->assertDatabaseHas('users', ['email' => 'regular1@example.com']); + $this->assertDatabaseHas('users', ['email' => 'regular2@example.com']); + } + + /** + * Test cleanup removes only users matching test patterns. + */ + public function test_cleanup_only_removes_matching_patterns() + { + // Create users with various email patterns + // Patterns that should be deleted: 'test%@example.com' OR 'test.user.%@example.com' + User::factory()->create(['email' => 'test@example.com']); // Should be deleted (matches test%@example.com) + User::factory()->create(['email' => 'test.user.123@example.com']); // Should be deleted (matches test.user.%@example.com) + User::factory()->create(['email' => 'testABC@example.com']); // Should be deleted (matches test%@example.com) + User::factory()->create(['email' => 'test.user.xyz@example.com']); // Should be deleted (matches test.user.%@example.com) + User::factory()->create(['email' => 'test999@example.com']); // Should be deleted (matches test%@example.com) + User::factory()->create(['email' => 'mytesting@example.com']); // Should NOT be deleted + User::factory()->create(['email' => 'test@gmail.com']); // Should NOT be deleted + User::factory()->create(['email' => 'mytest@example.com']); // Should NOT be deleted + + $response = $this->postJson('/api/e2e/test/cleanup'); + + $response->assertStatus(200); + $response->assertJsonPath('success', true); + // Just verify the message contains "Deleted" and "test users" + $this->assertStringContainsString('Deleted', $response->json('message')); + $this->assertStringContainsString('test users', $response->json('message')); + + // Only specific patterns should be deleted + $this->assertDatabaseMissing('users', ['email' => 'test@example.com']); + $this->assertDatabaseMissing('users', ['email' => 'test.user.123@example.com']); + $this->assertDatabaseMissing('users', ['email' => 'testABC@example.com']); + $this->assertDatabaseMissing('users', ['email' => 'test.user.xyz@example.com']); + $this->assertDatabaseMissing('users', ['email' => 'test999@example.com']); + + // Others should remain + $this->assertDatabaseHas('users', ['email' => 'mytesting@example.com']); + $this->assertDatabaseHas('users', ['email' => 'test@gmail.com']); + $this->assertDatabaseHas('users', ['email' => 'mytest@example.com']); + } + + + /** + * Test create user endpoint works in non-production environment. + */ + public function test_endpoints_work_in_non_production_environment() + { + // Ensure we're not in production environment + $this->assertNotEquals('production', app()->environment()); + + $response = $this->postJson('/api/e2e/test/setup/user', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password123', + ]); + + $response->assertStatus(200); + $response->assertJson(['success' => true]); + } + + /** + * Test user data is properly formatted in response. + */ + public function test_user_data_properly_formatted_in_response() + { + $userData = [ + 'name' => 'John Doe', + 'email' => 'john.doe@example.com', + 'password' => 'securepassword123', + ]; + + $response = $this->postJson('/api/e2e/test/setup/user', $userData); + + $response->assertStatus(200); + $response->assertJsonStructure([ + 'success', + 'message', + 'data' => [ + 'id', + 'email', + 'name' + ] + ]); + + // Ensure password is not included in response + $response->assertJsonMissing(['password']); + + $data = $response->json('data'); + $this->assertIsInt($data['id']); + $this->assertEquals('john.doe@example.com', $data['email']); + $this->assertEquals('John Doe', $data['name']); + } +} \ No newline at end of file diff --git a/backend/tests/Feature/TripTest.php b/backend/tests/Feature/TripTest.php new file mode 100644 index 0000000..23e8394 --- /dev/null +++ b/backend/tests/Feature/TripTest.php @@ -0,0 +1,420 @@ +user = User::factory()->create(); + } + + /** + * Test that unauthenticated users cannot access trip endpoints. + */ + public function test_unauthenticated_user_cannot_access_trips() + { + $response = $this->getJson('/api/trips'); + $response->assertStatus(401); + + $response = $this->postJson('/api/trips', []); + $response->assertStatus(401); + + $response = $this->putJson('/api/trips/1', []); + $response->assertStatus(401); + + $response = $this->deleteJson('/api/trips/1'); + $response->assertStatus(401); + } + + /** + * Test user can create a trip. + */ + public function test_user_can_create_trip() + { + Sanctum::actingAs($this->user); + + $tripData = [ + 'name' => 'Summer Vacation 2025', + 'description' => 'A wonderful trip to Europe', + 'start_date' => '2025-06-01', + 'end_date' => '2025-06-15', + ]; + + $response = $this->postJson('/api/trips', $tripData); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'data' => [ + 'id', + 'name', + 'description', + 'start_date', + 'end_date', + 'created_by_user_id', + 'created_at', + 'updated_at', + ] + ]) + ->assertJsonPath('data.name', 'Summer Vacation 2025') + ->assertJsonPath('data.description', 'A wonderful trip to Europe') + ->assertJsonPath('data.created_by_user_id', $this->user->id); + + $this->assertDatabaseHas('trips', [ + 'name' => 'Summer Vacation 2025', + 'created_by_user_id' => $this->user->id, + ]); + } + + /** + * Test trip creation validation. + */ + public function test_trip_creation_validates_required_fields() + { + Sanctum::actingAs($this->user); + + // Test without name + $response = $this->postJson('/api/trips', [ + 'description' => 'A trip without a name', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + + // Test with empty name + $response = $this->postJson('/api/trips', [ + 'name' => '', + 'description' => 'A trip with empty name', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + + // Test with invalid dates + $response = $this->postJson('/api/trips', [ + 'name' => 'Trip with invalid dates', + 'start_date' => 'not-a-date', + 'end_date' => '2025-13-45', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['start_date', 'end_date']); + + // Test with end date before start date + $response = $this->postJson('/api/trips', [ + 'name' => 'Trip with reversed dates', + 'start_date' => '2025-06-15', + 'end_date' => '2025-06-01', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['end_date']); + } + + /** + * Test user can list their trips. + */ + public function test_user_can_list_their_own_trips() + { + Sanctum::actingAs($this->user); + + // Create some trips for this user + $trips = Trip::factory()->count(3)->create([ + 'created_by_user_id' => $this->user->id, + ]); + + // Create trips for another user (should not be visible) + $otherUser = User::factory()->create(); + Trip::factory()->count(2)->create([ + 'created_by_user_id' => $otherUser->id, + ]); + + $response = $this->getJson('/api/trips'); + + $response->assertStatus(200) + ->assertJsonCount(3, 'data') + ->assertJsonStructure([ + 'data' => [ + '*' => [ + 'id', + 'name', + 'description', + 'start_date', + 'end_date', + 'created_by_user_id', + 'created_at', + 'updated_at', + ] + ] + ]); + + // Verify all returned trips belong to the authenticated user + foreach ($response->json('data') as $trip) { + $this->assertEquals($this->user->id, $trip['created_by_user_id']); + } + } + + /** + * Test user can view a specific trip. + */ + public function test_user_can_view_their_own_trip() + { + Sanctum::actingAs($this->user); + + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'name' => 'My Special Trip', + ]); + + $response = $this->getJson("/api/trips/{$trip->id}"); + + $response->assertStatus(200) + ->assertJsonPath('data.id', $trip->id) + ->assertJsonPath('data.name', 'My Special Trip') + ->assertJsonPath('data.created_by_user_id', $this->user->id); + } + + /** + * Test user cannot view another user's trip. + */ + public function test_user_cannot_view_another_users_trip() + { + Sanctum::actingAs($this->user); + + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + ]); + + $response = $this->getJson("/api/trips/{$otherTrip->id}"); + + // Controller returns 404 when trip doesn't belong to user + $response->assertStatus(404); + } + + /** + * Test user can update their trip. + */ + public function test_user_can_update_their_own_trip() + { + Sanctum::actingAs($this->user); + + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'name' => 'Original Name', + 'description' => 'Original Description', + ]); + + $updateData = [ + 'name' => 'Updated Trip Name', + 'description' => 'Updated Description', + 'start_date' => '2025-07-01', + 'end_date' => '2025-07-15', + ]; + + $response = $this->putJson("/api/trips/{$trip->id}", $updateData); + + $response->assertStatus(200) + ->assertJsonPath('data.id', $trip->id) + ->assertJsonPath('data.name', 'Updated Trip Name') + ->assertJsonPath('data.description', 'Updated Description'); + + $this->assertDatabaseHas('trips', [ + 'id' => $trip->id, + 'name' => 'Updated Trip Name', + 'description' => 'Updated Description', + ]); + } + + /** + * Test user cannot update another user's trip. + */ + public function test_user_cannot_update_another_users_trip() + { + Sanctum::actingAs($this->user); + + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + 'name' => 'Other User Trip', + ]); + + $response = $this->putJson("/api/trips/{$otherTrip->id}", [ + 'name' => 'Trying to Update', + ]); + + // Controller returns 404 when trip doesn't belong to user + $response->assertStatus(404); + + // Verify the trip wasn't updated + $this->assertDatabaseHas('trips', [ + 'id' => $otherTrip->id, + 'name' => 'Other User Trip', + ]); + } + + /** + * Test user can delete their trip. + */ + public function test_user_can_delete_their_own_trip() + { + Sanctum::actingAs($this->user); + + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + ]); + + $response = $this->deleteJson("/api/trips/{$trip->id}"); + + // Controller returns 200 with a message + $response->assertStatus(200) + ->assertJson([ + 'message' => 'Trip deleted successfully' + ]); + + $this->assertDatabaseMissing('trips', [ + 'id' => $trip->id, + ]); + } + + /** + * Test user cannot delete another user's trip. + */ + public function test_user_cannot_delete_another_users_trip() + { + Sanctum::actingAs($this->user); + + $otherUser = User::factory()->create(); + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + ]); + + $response = $this->deleteJson("/api/trips/{$otherTrip->id}"); + + // Controller returns 404 when trip doesn't belong to user + $response->assertStatus(404); + + // Verify the trip still exists + $this->assertDatabaseHas('trips', [ + 'id' => $otherTrip->id, + ]); + } + + /** + * Test handling non-existent trip. + */ + public function test_returns_404_for_non_existent_trip() + { + Sanctum::actingAs($this->user); + + $response = $this->getJson('/api/trips/99999'); + $response->assertStatus(404); + + $response = $this->putJson('/api/trips/99999', ['name' => 'Updated']); + $response->assertStatus(404); + + $response = $this->deleteJson('/api/trips/99999'); + $response->assertStatus(404); + } + + /** + * Test trip creation with minimal data. + */ + public function test_user_can_create_trip_with_minimal_data() + { + Sanctum::actingAs($this->user); + + $tripData = [ + 'name' => 'Minimal Trip', + ]; + + $response = $this->postJson('/api/trips', $tripData); + + $response->assertStatus(201) + ->assertJsonPath('data.name', 'Minimal Trip') + ->assertJsonPath('data.created_by_user_id', $this->user->id) + ->assertJsonStructure([ + 'data' => [ + 'id', + 'name', + 'created_by_user_id', + 'created_at', + 'updated_at', + ] + ]); + + $this->assertDatabaseHas('trips', [ + 'name' => 'Minimal Trip', + 'created_by_user_id' => $this->user->id, + 'description' => null, + 'start_date' => null, + 'end_date' => null, + ]); + } + + /** + * Test trip name length validation. + */ + public function test_trip_name_length_validation() + { + Sanctum::actingAs($this->user); + + // Test with too long name (assuming max is 255) + $response = $this->postJson('/api/trips', [ + 'name' => str_repeat('a', 256), + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name']); + } + + /** + * Test trips are returned in correct order. + */ + public function test_trips_are_returned_in_descending_order() + { + Sanctum::actingAs($this->user); + + // Create trips with specific timestamps + $oldTrip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'name' => 'Old Trip', + 'created_at' => now()->subDays(2), + ]); + + $newTrip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'name' => 'New Trip', + 'created_at' => now(), + ]); + + $middleTrip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'name' => 'Middle Trip', + 'created_at' => now()->subDay(), + ]); + + $response = $this->getJson('/api/trips'); + + $response->assertStatus(200); + + $trips = $response->json('data'); + + // Verify trips are in descending order (newest first) + $this->assertEquals('New Trip', $trips[0]['name']); + $this->assertEquals('Middle Trip', $trips[1]['name']); + $this->assertEquals('Old Trip', $trips[2]['name']); + } +} \ No newline at end of file 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/backend/tests/Unit/ExampleTest.php b/backend/tests/Unit/ExampleTest.php deleted file mode 100644 index 5773b0c..0000000 --- a/backend/tests/Unit/ExampleTest.php +++ /dev/null @@ -1,16 +0,0 @@ -assertTrue(true); - } -} diff --git a/backend/tests/Unit/TripTest.php b/backend/tests/Unit/TripTest.php new file mode 100644 index 0000000..483e3bc --- /dev/null +++ b/backend/tests/Unit/TripTest.php @@ -0,0 +1,248 @@ +create(); + $trip = Trip::factory()->create([ + 'name' => 'Paris Adventure', + 'description' => 'A wonderful trip to Paris', + 'created_by_user_id' => $user->id, + ]); + + $this->assertInstanceOf(Trip::class, $trip); + $this->assertEquals('Paris Adventure', $trip->name); + $this->assertEquals('A wonderful trip to Paris', $trip->description); + $this->assertEquals($user->id, $trip->created_by_user_id); + $this->assertNotNull($trip->id); + $this->assertNotNull($trip->created_at); + $this->assertNotNull($trip->updated_at); + } + + /** + * Test trip fillable attributes. + */ + public function test_trip_fillable_attributes() + { + $user = User::factory()->create(); + $tripData = [ + 'name' => 'Tokyo Trip', + 'description' => 'Exploring Japan', + 'start_date' => '2025-06-01', + 'end_date' => '2025-06-15', + 'created_by_user_id' => $user->id, + ]; + + $trip = Trip::create($tripData); + + $this->assertEquals('Tokyo Trip', $trip->name); + $this->assertEquals('Exploring Japan', $trip->description); + $this->assertEquals('2025-06-01', $trip->start_date->format('Y-m-d')); + $this->assertEquals('2025-06-15', $trip->end_date->format('Y-m-d')); + $this->assertEquals($user->id, $trip->created_by_user_id); + } + + /** + * Test trip date casting. + */ + public function test_trip_date_casting() + { + $trip = Trip::factory()->create([ + 'start_date' => '2025-07-01', + 'end_date' => '2025-07-10', + ]); + + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $trip->start_date); + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $trip->end_date); + $this->assertEquals('2025-07-01', $trip->start_date->format('Y-m-d')); + $this->assertEquals('2025-07-10', $trip->end_date->format('Y-m-d')); + } + + /** + * Test trip can have null dates. + */ + public function test_trip_can_have_null_dates() + { + $trip = Trip::factory()->withoutDates()->create([ + 'name' => 'Flexible Trip', + ]); + + $this->assertNull($trip->start_date); + $this->assertNull($trip->end_date); + $this->assertEquals('Flexible Trip', $trip->name); + } + + /** + * Test trip belongs to user relationship. + */ + public function test_trip_belongs_to_user() + { + $user = User::factory()->create(['name' => 'John Doe']); + $trip = Trip::factory()->create([ + 'created_by_user_id' => $user->id, + ]); + + $this->assertInstanceOf(User::class, $trip->user); + $this->assertEquals($user->id, $trip->user->id); + $this->assertEquals('John Doe', $trip->user->name); + } + + /** + * Test trip factory creates valid trips. + */ + public function test_trip_factory_creates_valid_trips() + { + $trip = Trip::factory()->create(); + + $this->assertNotEmpty($trip->name); + $this->assertNotNull($trip->created_by_user_id); + $this->assertInstanceOf(User::class, $trip->user); + } + + /** + * Test trip factory upcoming state. + */ + public function test_trip_factory_upcoming_state() + { + $trip = Trip::factory()->upcoming()->create(); + + $this->assertNotNull($trip->start_date); + $this->assertNotNull($trip->end_date); + $this->assertTrue($trip->start_date->isFuture()); + $this->assertTrue($trip->end_date->isAfter($trip->start_date)); + } + + /** + * Test trip factory past state. + */ + public function test_trip_factory_past_state() + { + $trip = Trip::factory()->past()->create(); + + $this->assertNotNull($trip->start_date); + $this->assertNotNull($trip->end_date); + $this->assertTrue($trip->start_date->isPast()); + $this->assertTrue($trip->end_date->isPast()); + $this->assertTrue($trip->end_date->isAfter($trip->start_date)); + } + + /** + * Test trip model uses correct table. + */ + public function test_trip_uses_correct_table() + { + $trip = new Trip(); + + $this->assertEquals('trips', $trip->getTable()); + } + + /** + * Test trip model has correct primary key. + */ + public function test_trip_has_correct_primary_key() + { + $trip = new Trip(); + + $this->assertEquals('id', $trip->getKeyName()); + $this->assertTrue($trip->getIncrementing()); + } + + /** + * Test trip model uses timestamps. + */ + public function test_trip_uses_timestamps() + { + $trip = new Trip(); + + $this->assertTrue($trip->usesTimestamps()); + } + + /** + * Test trip can be created with minimal data. + */ + public function test_trip_can_be_created_with_minimal_data() + { + $user = User::factory()->create(); + $trip = Trip::create([ + 'name' => 'Minimal Trip', + 'created_by_user_id' => $user->id, + ]); + + $this->assertEquals('Minimal Trip', $trip->name); + $this->assertNull($trip->description); + $this->assertNull($trip->start_date); + $this->assertNull($trip->end_date); + $this->assertEquals($user->id, $trip->created_by_user_id); + } + + /** + * Test trip name is required. + */ + public function test_trip_name_is_required() + { + $user = User::factory()->create(); + + $this->expectException(\Illuminate\Database\QueryException::class); + + Trip::create([ + 'description' => 'A trip without a name', + 'created_by_user_id' => $user->id, + ]); + } + + /** + * Test trip requires a user. + */ + public function test_trip_requires_user() + { + $this->expectException(\Illuminate\Database\QueryException::class); + + Trip::create([ + 'name' => 'Orphaned Trip', + 'description' => 'A trip without a user', + ]); + } + + /** + * Test trip dates can be formatted. + */ + public function test_trip_dates_can_be_formatted() + { + $trip = Trip::factory()->create([ + 'start_date' => '2025-12-25', + 'end_date' => '2025-12-31', + ]); + + $this->assertEquals('2025-12-25', $trip->start_date->format('Y-m-d')); + $this->assertEquals('2025-12-31', $trip->end_date->format('Y-m-d')); + $this->assertEquals('December 25, 2025', $trip->start_date->format('F j, Y')); + $this->assertEquals('December 31, 2025', $trip->end_date->format('F j, Y')); + } + + /** + * Test trip can calculate duration. + */ + public function test_trip_can_calculate_duration() + { + $trip = Trip::factory()->create([ + 'start_date' => '2025-06-01', + 'end_date' => '2025-06-07', + ]); + + $duration = $trip->start_date->diffInDays($trip->end_date) + 1; // Include both start and end day + $this->assertEquals(7, $duration); + } +} \ No newline at end of file diff --git a/backend/tests/Unit/UserTest.php b/backend/tests/Unit/UserTest.php new file mode 100644 index 0000000..7ddb95e --- /dev/null +++ b/backend/tests/Unit/UserTest.php @@ -0,0 +1,224 @@ +create([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]); + + $this->assertInstanceOf(User::class, $user); + $this->assertEquals('John Doe', $user->name); + $this->assertEquals('john@example.com', $user->email); + $this->assertNotNull($user->id); + $this->assertNotNull($user->created_at); + $this->assertNotNull($user->updated_at); + } + + /** + * Test user fillable attributes. + */ + public function test_user_fillable_attributes() + { + $userData = [ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'password' => 'password123', + ]; + + $user = User::create($userData); + + $this->assertEquals('Jane Doe', $user->name); + $this->assertEquals('jane@example.com', $user->email); + $this->assertTrue(Hash::check('password123', $user->password)); + } + + /** + * Test user hidden attributes. + */ + public function test_user_hidden_attributes() + { + $user = User::factory()->create([ + 'password' => Hash::make('secret123'), + ]); + + $userArray = $user->toArray(); + + $this->assertArrayNotHasKey('password', $userArray); + $this->assertArrayNotHasKey('remember_token', $userArray); + } + + /** + * Test user casts. + */ + public function test_user_casts() + { + $user = User::factory()->create([ + 'email_verified_at' => now(), + ]); + + $this->assertInstanceOf(\Illuminate\Support\Carbon::class, $user->email_verified_at); + } + + /** + * Test password is automatically hashed. + */ + public function test_password_is_automatically_hashed() + { + $user = User::factory()->create([ + 'password' => 'plaintext-password', + ]); + + $this->assertNotEquals('plaintext-password', $user->password); + $this->assertTrue(Hash::check('plaintext-password', $user->password)); + } + + /** + * Test user has API tokens trait. + */ + public function test_user_can_create_api_tokens() + { + $user = User::factory()->create(); + + $token = $user->createToken('test-token'); + + $this->assertInstanceOf(PersonalAccessToken::class, $token->accessToken); + $this->assertIsString($token->plainTextToken); + $this->assertEquals('test-token', $token->accessToken->name); + } + + /** + * Test user can have multiple tokens. + */ + public function test_user_can_have_multiple_tokens() + { + $user = User::factory()->create(); + + $token1 = $user->createToken('token-1'); + $token2 = $user->createToken('token-2'); + + $this->assertCount(2, $user->tokens); + $this->assertNotEquals($token1->plainTextToken, $token2->plainTextToken); + } + + /** + * Test user can delete tokens. + */ + public function test_user_can_delete_tokens() + { + $user = User::factory()->create(); + $token = $user->createToken('test-token'); + + $this->assertCount(1, $user->tokens); + + $token->accessToken->delete(); + $user->refresh(); + + $this->assertCount(0, $user->tokens); + } + + /** + * Test user has trips relationship. + */ + public function test_user_has_trips_relationship() + { + $user = User::factory()->create(); + + // Create some trips for this user + Trip::factory()->count(3)->create([ + 'created_by_user_id' => $user->id, + ]); + + // Create a trip for another user + $otherUser = User::factory()->create(); + Trip::factory()->create([ + 'created_by_user_id' => $otherUser->id, + ]); + + $this->assertCount(3, $user->trips); + $this->assertInstanceOf(Trip::class, $user->trips->first()); + } + + /** + * Test user factory creates valid users. + */ + public function test_user_factory_creates_valid_users() + { + $user = User::factory()->create(); + + $this->assertNotEmpty($user->name); + $this->assertNotEmpty($user->email); + $this->assertNotEmpty($user->password); + $this->assertNotNull($user->email_verified_at); + $this->assertTrue(filter_var($user->email, FILTER_VALIDATE_EMAIL) !== false); + } + + /** + * Test user factory can create unverified users. + */ + public function test_user_factory_can_create_unverified_users() + { + $user = User::factory()->unverified()->create(); + + $this->assertNull($user->email_verified_at); + } + + /** + * Test user email must be unique. + */ + public function test_user_email_must_be_unique() + { + User::factory()->create(['email' => 'test@example.com']); + + $this->expectException(\Illuminate\Database\QueryException::class); + + User::factory()->create(['email' => 'test@example.com']); + } + + /** + * Test user model uses correct table. + */ + public function test_user_uses_correct_table() + { + $user = new User(); + + $this->assertEquals('users', $user->getTable()); + } + + /** + * Test user model has correct primary key. + */ + public function test_user_has_correct_primary_key() + { + $user = new User(); + + $this->assertEquals('id', $user->getKeyName()); + $this->assertTrue($user->getIncrementing()); + } + + /** + * Test user model uses timestamps. + */ + public function test_user_uses_timestamps() + { + $user = new User(); + + $this->assertTrue($user->usesTimestamps()); + } +} \ No newline at end of file diff --git a/bin/phpstan b/bin/phpstan new file mode 100755 index 0000000..187078a --- /dev/null +++ b/bin/phpstan @@ -0,0 +1,51 @@ +#!/bin/bash + +# PHPStan runner script for Docker environment +# Usage: ./bin/phpstan [options] + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Get the directory of this script +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" + +# Change to project root +cd "$PROJECT_ROOT" + +# Check if docker compose is running +if ! docker compose -f docker-compose.dev.yml ps --services --filter "status=running" | grep -q "backend"; then + echo -e "${RED}Error: Backend container is not running${NC}" + echo -e "${YELLOW}Starting containers...${NC}" + docker compose -f docker-compose.dev.yml up -d + + # Wait a moment for containers to be ready + sleep 3 +fi + +# Run PHPStan with default memory limit if not specified +echo -e "${GREEN}Running PHPStan analysis...${NC}" + +# Pass all arguments to phpstan, with default memory limit +if [[ "$*" == *"--memory-limit"* ]]; then + # User specified memory limit, use their arguments + docker compose -f docker-compose.dev.yml exec backend vendor/bin/phpstan analyse "$@" +else + # Add default memory limit + docker compose -f docker-compose.dev.yml exec backend vendor/bin/phpstan analyse --memory-limit=256M "$@" +fi + +# Capture exit code +EXIT_CODE=$? + +# Display result +if [ $EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}✓ PHPStan analysis completed successfully${NC}" +else + echo -e "${RED}✗ PHPStan found issues${NC}" +fi + +exit $EXIT_CODE \ No newline at end of file diff --git a/bin/phpunit b/bin/phpunit new file mode 100755 index 0000000..a021856 --- /dev/null +++ b/bin/phpunit @@ -0,0 +1,55 @@ +#!/bin/bash + +# PHPUnit test runner script for Docker environment +# Usage: ./bin/phpunit [options] + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Get the directory of this script +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" + +# Change to project root +cd "$PROJECT_ROOT" + +# Check if backend container is running +if ! podman-compose -f docker-compose.dev.yml ps | grep "trip-planner-backend-dev" | grep -q "Up"; then + echo -e "${RED}Error: Backend container is not running${NC}" + echo -e "${YELLOW}Please start the containers first with: podman-compose -f docker-compose.dev.yml up -d${NC}" + exit 1 +fi + +# Run PHPUnit tests +echo -e "${GREEN}Running PHPUnit tests...${NC}" + +# If no arguments provided, run all tests +if [ $# -eq 0 ]; then + echo -e "${BLUE}Running all tests...${NC}" + podman-compose -f docker-compose.dev.yml exec backend php -d memory_limit=512M artisan test +else + # Pass all arguments to phpunit + echo -e "${BLUE}Running tests with options: $*${NC}" + podman-compose -f docker-compose.dev.yml exec backend php -d memory_limit=512M artisan test "$@" +fi + +# Capture exit code +EXIT_CODE=$? + +# Display result +if [ $EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}✓ All tests passed successfully${NC}" +else + echo -e "${RED}✗ Some tests failed${NC}" +fi + +echo -e "${YELLOW}Tip: You can also run specific tests:${NC}" +echo -e " ${BLUE}./bin/phpunit --filter=PlannableItemTest${NC}" +echo -e " ${BLUE}./bin/phpunit tests/Feature/PlannableItemTest.php${NC}" +echo -e " ${BLUE}./bin/phpunit --coverage-html=coverage${NC}" + +exit $EXIT_CODE \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index edda3f3..9478523 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,6 +13,7 @@ services: - node_modules:/app/node_modules environment: - NODE_ENV=development + privileged: true networks: - trip-planner-network @@ -31,6 +32,7 @@ services: depends_on: - database - redis + privileged: true networks: - trip-planner-network @@ -45,7 +47,8 @@ services: MYSQL_USER: ${DB_USERNAME:-trip_user} MYSQL_PASSWORD: ${DB_PASSWORD:-secret} volumes: - - db-data:/var/lib/mysql + - ./docker/data/mysql-data:/var/lib/mysql:Z + privileged: true networks: - trip-planner-network @@ -56,6 +59,7 @@ services: - "6379:6379" volumes: - redis-data:/data + privileged: true networks: - trip-planner-network @@ -68,6 +72,7 @@ services: networks: - trip-planner-network + networks: trip-planner-network: driver: bridge @@ -76,4 +81,4 @@ volumes: db-data: redis-data: node_modules: - vendor: \ No newline at end of file + vendor: diff --git a/docker/backend/Dockerfile.dev b/docker/backend/Dockerfile.dev index ce59d40..0974091 100644 --- a/docker/backend/Dockerfile.dev +++ b/docker/backend/Dockerfile.dev @@ -11,33 +11,34 @@ RUN apk add --no-cache \ unzip \ nodejs \ npm \ - shadow + shadow \ + linux-headers \ + $PHPIZE_DEPS # Install PHP extensions RUN docker-php-ext-install pdo_mysql mbstring exif pcntl bcmath gd +# Install Xdebug for code coverage +RUN pecl install xdebug && \ + docker-php-ext-enable xdebug + +# Configure Xdebug for coverage (not debugging) +RUN echo "xdebug.mode=coverage" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \ + echo "xdebug.start_with_request=no" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini + # Install Composer COPY --from=composer:latest /usr/bin/composer /usr/bin/composer WORKDIR /var/www/html -# Create developer user with UID 1000 (same as host user) -RUN adduser -u 1000 -s /bin/sh -D developer +# Create storage and bootstrap/cache directories with proper permissions +RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache && \ + chmod -R 777 storage bootstrap/cache -# Create storage and bootstrap/cache directories -RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache - -# Change ownership to developer user -RUN chown -R developer:developer /var/www/html - -# Set proper permissions for Laravel directories -RUN chmod -R 775 storage bootstrap/cache - -# Switch to developer user -USER developer +# Run as root to avoid permission issues with volume mounts # Expose port 8000 for artisan serve 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/docker/frontend/Dockerfile.dev b/docker/frontend/Dockerfile.dev index 4f242bc..c3a9e47 100644 --- a/docker/frontend/Dockerfile.dev +++ b/docker/frontend/Dockerfile.dev @@ -5,11 +5,7 @@ RUN npm install -g vite WORKDIR /app -# Change ownership of /app to node user (UID 1000) -RUN chown -R node:node /app - -# Switch to node user (UID 1000, same as host user) -USER node +# Run as root to avoid permission issues with volume mounts # Expose Vite dev server port EXPOSE 5173 diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..9c4b96e --- /dev/null +++ b/frontend/.env @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:8000 +VITE_API_BASE_URL=http://localhost:8000/api \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index c20fbd3..467839b 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - frontend + TripPlanner
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d1365ea..f7c639a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,11 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@heroicons/react": "^2.2.0", + "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.3" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -901,6 +904,15 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1481,6 +1493,23 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1543,6 +1572,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1611,6 +1653,18 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1625,6 +1679,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1672,6 +1735,29 @@ "dev": true, "license": "MIT" }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.224", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.224.tgz", @@ -1679,6 +1765,51 @@ "dev": true, "license": "ISC" }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -2012,6 +2143,42 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2027,6 +2194,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2037,6 +2213,43 @@ "node": ">=6.9.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2063,6 +2276,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2073,6 +2298,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2264,6 +2528,36 @@ "yallist": "^3.0.2" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2459,6 +2753,12 @@ "node": ">= 0.8.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2500,6 +2800,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.9.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz", + "integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.9.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz", + "integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==", + "license": "MIT", + "dependencies": { + "react-router": "7.9.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -2568,6 +2906,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 82deee1..fdb911e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,8 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@heroicons/react": "^2.2.0", + "axios": "^1.12.2", "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "react-router-dom": "^7.9.3" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/frontend/src/App.css b/frontend/src/App.css index b9d355d..4b38420 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,42 +1,958 @@ -#root { - max-width: 1280px; +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&display=swap'); +@import './styles/variables.css'; + +:root { + /* Color Palette from Coolors.co */ + --color-navy: #03071e; + --color-dark-red: #370617; + --color-burgundy: #6a040f; + --color-crimson: #9d0208; + --color-red: #d00000; + --color-red-orange: #dc2f02; + --color-orange: #e85d04; + --color-orange-yellow: #f48c06; + --color-yellow-orange: #faa307; + --color-yellow: #ffba08; + + /* Semantic colors */ + --primary-color: var(--color-orange); + --primary-hover: var(--color-red-orange); + --secondary-color: var(--color-burgundy); + --accent-color: var(--color-yellow-orange); + --danger-color: var(--color-crimson); + --danger-hover: var(--color-red); + --text-primary: var(--color-navy); + --text-secondary: var(--color-dark-red); + --text-muted: #666; + + /* Background colors */ + --bg-primary: #faf8f5; + --bg-secondary: #f5f1eb; + --bg-light: #fdf9f4; + --bg-card: #fbf7f2; + --bg-gradient: linear-gradient(135deg, #faf8f5 0%, #f5f1eb 100%); + + /* Typography */ + --font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + --font-secondary: 'Playfair Display', Georgia, 'Times New Roman', serif; + + /* Border radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + background: var(--bg-gradient); + min-height: 100vh; + font-family: var(--font-primary); + line-height: 1.6; +} + +.App { + min-height: 100vh; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 0; + width: 100vw; + box-sizing: border-box; +} + +.App-header { + text-align: center; + margin-bottom: 2rem; +} + +.registration-form, +.login-form { + background: #f8f9fa; + padding: 2rem; + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-md); + width: 100%; + max-width: 400px; + margin: 0 auto; +} + +.registration-form h2, +.login-form h2 { + text-align: center; + margin-bottom: 1.5rem; + color: #333; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: var(--color-dark-red); + background: rgba(255, 244, 230, 0.3); + padding: 0.25rem 0.5rem; + border-radius: var(--border-radius-sm); + display: inline-block; +} + +.form-group input { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--primary-color); + border-radius: var(--border-radius-md); + font-size: 1rem; + box-sizing: border-box; + background: #fff4e6; + color: #000; +} + +.form-group input:focus { + outline: none; + border-color: var(--color-red-orange); + box-shadow: 0 0 0 2px rgba(220, 47, 2, 0.25); +} + +.form-group input.error { + border-color: #dc3545; +} + +.error-message { + display: block; + color: #dc3545; + font-size: 0.875rem; + margin-top: 0.25rem; +} + +.alert { + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border-radius: var(--border-radius-sm); +} + +.alert-success { + color: #155724; + background-color: #d4edda; + border: 1px solid #c3e6cb; +} + +.alert-error { + color: #721c24; + background-color: #f8d7da; + border: 1px solid #f5c6cb; +} + +button[type="submit"] { + width: 100%; + padding: 0.75rem; + background-color: var(--primary-color); + color: white; + border: none; + border-radius: var(--border-radius-sm); + font-size: 1rem; + cursor: pointer; + transition: background-color var(--transition-normal); +} + +button[type="submit"]:hover:not(:disabled) { + background-color: var(--primary-hover); +} + +button[type="submit"]:disabled { + background-color: var(--text-muted); + cursor: not-allowed; +} + +/* Auth Guard Styles */ +.auth-container { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; +} + +.auth-content { + width: 100%; + max-width: 400px; + display: flex; + flex-direction: column; + align-items: center; +} + +.auth-toggle { + display: flex; + margin-bottom: 2rem; + border-radius: var(--border-radius-md); + overflow: hidden; + border: 1px solid #ddd; +} + +.auth-toggle button { + flex: 1; + padding: 0.75rem; + background: #f8f9fa; + border: none; + cursor: pointer; + transition: all var(--transition-normal); +} + +.auth-toggle button.active { + background: var(--primary-color); + color: white; +} + +.auth-toggle button:hover:not(.active) { + background: #e9ecef; +} + +.auth-switch { + text-align: center; + margin-top: 1rem; +} + +.link-button { + background: none; + border: none; + color: var(--primary-color); + cursor: pointer; + text-decoration: underline; + padding: 0; + font-size: inherit; +} + +.link-button:hover { + color: var(--primary-hover); +} + +/* Dashboard Styles */ +.dashboard { + width: 1200px; + max-width: 1200px; margin: 0 auto; padding: 2rem; + position: relative; +} + +.dashboard::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 200px; + background: linear-gradient(135deg, var(--color-yellow) 0%, var(--color-orange) 50%, var(--color-red-orange) 100%); + opacity: 0.03; + border-radius: 0 0 50px 50px; + z-index: -1; +} + +.dashboard-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 1px solid #eee; +} + +.dashboard-title { + display: flex; + align-items: center; +} + +.dashboard-header h1 { + margin: 0; + color: var(--text-primary); + background: linear-gradient(135deg, var(--color-yellow) 0%, var(--color-orange) 25%, var(--color-red-orange) 50%, var(--color-crimson) 75%, var(--color-dark-red) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-weight: 700; + font-size: 2rem; + font-family: var(--font-secondary); +} + +/* User menu styles */ +.user-dropdown { + position: relative; +} + +.user-menu-trigger { + background: var(--bg-card); + border: 1px solid var(--primary-color); + font-size: 0.875rem; + cursor: pointer; + padding: 0.5rem 0.75rem; + border-radius: 20px; + transition: all var(--transition-normal); + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-primary); + font-weight: 500; +} + +.user-menu-trigger:hover { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + color: white; + border-color: var(--primary-color); +} + +.user-menu-trigger:focus { + outline: 2px solid var(--primary-color); + outline-offset: 2px; +} + +.dropdown-arrow { + font-size: 0.7rem; + transition: transform 0.2s; +} + +.user-dropdown[data-open="true"] .dropdown-arrow { + transform: rotate(180deg); +} + +.user-dropdown-menu { + position: absolute; + top: 100%; + right: 0; + background: var(--bg-card); + border: 1px solid rgba(228, 93, 4, 0.2); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-lg); + z-index: 1000; + min-width: 120px; + margin-top: 0.5rem; +} + +.user-info { + display: flex; + align-items: center; + gap: 1rem; +} + +/* Trips section header */ +.trips-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0; +} + +.create-trip-btn-small { + padding: 0.5rem 1rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.875rem; + transition: all var(--transition-normal); + margin-top: 0.2rem; +} + +.create-trip-btn-small:hover { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + transform: translateY(-1px); +} + +.welcome-section { + text-align: center; + margin-bottom: 3rem; +} + +.welcome-section h2 { + margin-bottom: 0.5rem; + color: #333; +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; + margin-top: 2rem; +} + +.feature-card { + background: #f8f9fa; + padding: 2rem; + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-md); text-align: center; } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); +.feature-card h3 { + margin-bottom: 1rem; + color: #007bff; } -@keyframes logo-spin { +.feature-card p { + color: #666; + line-height: 1.6; +} + +/* Loading Styles */ +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} + +.loading-spinner { + font-size: 1.2rem; + color: #666; +} + +.unauthorized-container { + text-align: center; + padding: 3rem; + background: #f8f9fa; + border-radius: var(--border-radius-md); + margin: 2rem 0; +} + +.unauthorized-container h2 { + color: #dc3545; + margin-bottom: 1rem; +} + +.unauthorized-container p { + color: #666; +} + +/* Trip Styles */ +.trips-section { + margin-top: 4rem; + position: relative; +} + +.trips-section::before { + content: ''; + position: absolute; + top: -3.5rem; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(90deg, transparent 0%, var(--color-yellow) 20%, var(--color-orange) 40%, var(--color-red-orange) 60%, var(--color-crimson) 80%, transparent 100%); + opacity: 0.6; +} + +.trips-section-title { + background: linear-gradient(135deg, #6a040f 0%, #370617 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 1.5rem; + font-size: 2rem; + font-weight: 700; + font-family: var(--font-secondary); +} + +.trips-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-top: 0; +} + +.trip-card { + background: var(--bg-card); + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease; + position: relative; + border: 1px solid rgba(228, 93, 4, 0.1); + overflow: hidden; +} + +.trip-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--color-orange) 0%, var(--color-yellow-orange) 50%, var(--color-yellow) 100%); +} + +.trip-card:hover { + box-shadow: 0 8px 30px rgba(228, 93, 4, 0.15); + transform: translateY(-4px) scale(1.02); +} + +.add-trip-card { + background: var(--bg-card); + border: 4px dashed var(--primary-color); + border-radius: 12px; + padding: 1.5rem; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; + opacity: 0.8; +} + +.add-trip-card:hover { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + border-color: var(--primary-hover); + transform: translateY(-2px); + opacity: 1; +} + +.add-trip-card:hover .add-trip-content { + color: white; +} + +.add-trip-card:hover .add-trip-icon { + color: white; +} + +.add-trip-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + color: var(--primary-color); + transition: color 0.3s ease; +} + +.add-trip-icon { + width: 4rem; + height: 4rem; + color: var(--primary-color); +} + +.add-trip-text { + font-size: 1.3rem; + font-weight: 600; +} + +.trip-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.trip-card-title { + margin: 0; + color: var(--primary-color); + font-size: 1.25rem; + line-height: 1.3; + flex: 1; + margin-right: 1rem; + position: relative; +} + +.trip-card-title::before { + content: '🗺️'; + font-size: 0.9rem; + margin-right: 0.5rem; + opacity: 0.7; +} + +.trip-card-menu { + position: relative; +} + +.trip-menu-trigger { + background: none; + border: none; + font-size: 1.25rem; + cursor: pointer; + padding: 0.25rem; + color: #666; + border-radius: var(--border-radius-sm); + transition: background-color var(--transition-normal); +} + +.trip-menu-trigger:hover { + background: #e9ecef; +} + +.trip-dropdown { + position: absolute; + top: 100%; + right: 0; + background: white; + border: 1px solid #ddd; + border-radius: var(--border-radius-sm); + box-shadow: var(--shadow-lg); + z-index: 1000; + min-width: 120px; +} + +.dropdown-item { + display: block; + width: 100%; + padding: 0.5rem 1rem; + border: none; + background: none; + text-align: left; + cursor: pointer; + transition: background-color var(--transition-normal); + color: #333; +} + +.dropdown-item:hover { + background: #f8f9fa; +} + +.dropdown-item-danger { + color: var(--danger-color); +} + +.dropdown-item-danger:hover { + background: #f8d7da; + color: var(--danger-hover); +} + +.trip-card-description { + color: #666; + margin: 0 0 1rem 0; + line-height: 1.5; +} + +.trip-card-dates { + margin-bottom: 1rem; +} + +.trip-date-range { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.trip-dates { + font-weight: 500; + color: var(--text-primary); +} + +.trip-duration { + font-size: 0.875rem; + color: #666; +} + +.trip-dates-placeholder { + color: #999; + font-style: italic; +} + +.trip-card-footer { + border-top: 1px solid #e9ecef; + padding-top: 0.75rem; + margin-top: 1rem; +} + +.trip-created { + font-size: 0.875rem; + color: #999; +} + +.empty-state { + text-align: center; + padding: 3rem 2rem; + background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-light) 100%); + border-radius: 16px; + margin-top: 2rem; + border: 2px dashed rgba(228, 93, 4, 0.2); + position: relative; +} + +.empty-state::before { + content: '✈️'; + font-size: 3rem; + display: block; + margin-bottom: 1rem; + opacity: 0.6; +} + +.empty-state h3 { + color: #666; + margin-bottom: 0.5rem; +} + +.empty-state p { + color: #999; + margin: 0; +} + +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(3, 7, 30, 0.8); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + animation: modalOverlayFadeIn 0.2s ease-out; +} + +@keyframes modalOverlayFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes modalSlideIn { from { - transform: rotate(0deg); + opacity: 0; + transform: translateY(-20px) scale(0.95); } to { - transform: rotate(360deg); + opacity: 1; + transform: translateY(0) scale(1); } } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; +.modal-content { + background: var(--bg-card); + border-radius: var(--border-radius-md); + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow: hidden; + box-shadow: + 0 25px 80px rgba(3, 7, 30, 0.4), + 0 0 0 1px rgba(228, 93, 4, 0.1); + animation: modalSlideIn 0.3s ease-out; + position: relative; +} + +.modal-content::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(90deg, var(--color-orange) 0%, var(--color-red-orange) 50%, var(--color-crimson) 100%); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 2rem 2rem 1rem; + background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-light) 100%); +} + +.modal-header h2 { + margin: 0; + color: var(--text-primary); + font-size: 1.5rem; + font-weight: 700; + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.modal-close { + background: var(--bg-secondary); + border: 1px solid rgba(228, 93, 4, 0.2); + font-size: 1.25rem; + cursor: pointer; + color: var(--primary-color); + padding: 0; + width: 2.5rem; + height: 2.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s ease; + font-weight: 300; +} + +.modal-close:hover { + background: var(--primary-color); + color: white; + transform: scale(1.1); +} + +.modal-body { + padding: 0 2rem; + max-height: calc(90vh - 200px); + overflow-y: auto; +} + +.trip-form { + padding: 1.5rem 0; +} + +.trip-form .form-group { + margin-bottom: 1.5rem; +} + +.trip-form textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--primary-color); + border-radius: var(--border-radius-md); + font-size: 1rem; + font-family: inherit; + resize: vertical; + min-height: 80px; + box-sizing: border-box; + background: #fff4e6; + color: #000; +} + +.trip-form textarea:focus { + outline: none; + border-color: var(--color-red-orange); + box-shadow: 0 0 0 2px rgba(220, 47, 2, 0.25); +} + +.date-format-hint { + font-size: 0.875rem; + color: #666; + font-weight: normal; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + padding: 1.5rem; + border-top: 1px solid rgba(228, 93, 4, 0.1); + background: var(--bg-secondary); + border-radius: 0 0 16px 16px; +} + +.btn-primary { + padding: 0.75rem 1.5rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: var(--border-radius-md); + cursor: pointer; + font-weight: 500; + transition: background-color var(--transition-normal); +} + +.btn-primary:hover:not(:disabled) { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--color-red-orange) 100%); +} + +.btn-primary:disabled { + background: var(--text-muted); + cursor: not-allowed; +} + +.btn-secondary { + padding: 0.75rem 1.5rem; + background: transparent; + color: var(--secondary-color); + border: 2px solid var(--secondary-color); + border-radius: var(--border-radius-md); + cursor: pointer; + font-weight: 500; + transition: all var(--transition-normal); +} + +.btn-secondary:hover:not(:disabled) { + background: linear-gradient(135deg, var(--secondary-color) 0%, var(--color-burgundy) 100%); + border-color: var(--color-burgundy); + color: white; + transform: translateY(-1px); +} + +.btn-secondary:disabled { + background: var(--text-muted); + cursor: not-allowed; +} + +.btn-danger { + padding: 0.75rem 1.5rem; + background: var(--danger-color); + color: white; + border: none; + border-radius: var(--border-radius-md); + cursor: pointer; + font-weight: 500; + transition: background-color var(--transition-normal); +} + +.btn-danger:hover:not(:disabled) { + background: var(--danger-hover); +} + +.btn-danger:disabled { + background: var(--text-muted); + cursor: not-allowed; +} + +.confirm-dialog { + max-width: 400px; +} + +.confirm-dialog-body { + padding: 1.5rem; +} + +.confirm-dialog-body p { + margin: 0; + color: var(--text-primary); + line-height: 1.5; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .dashboard { + padding: 1rem; + } + + .dashboard-header { + flex-direction: column; + gap: 1rem; + align-items: stretch; + } + + .dashboard-header-actions { + justify-content: space-between; + } + + .trips-grid { + grid-template-columns: 1fr; + } + + .form-row { + grid-template-columns: 1fr; + } + + .modal-content { + margin: 1rem; + max-width: none; + } + + .modal-actions { + flex-direction: column; } } - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f67355a..9ce837a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,34 +1,28 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { AuthProvider } from './contexts/AuthContext' +import { ToastProvider } from './components/common/ToastContainer' +import AuthGuard from './components/auth/AuthGuard' +import Dashboard from './components/Dashboard' +import TripDetail from './components/TripDetail' import './App.css' function App() { - const [count, setCount] = useState(0) - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.jsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- + + + +
+ + + } /> + } /> + } /> + + +
+
+
+
) } diff --git a/frontend/src/components/BaseModal.jsx b/frontend/src/components/BaseModal.jsx new file mode 100644 index 0000000..589efe4 --- /dev/null +++ b/frontend/src/components/BaseModal.jsx @@ -0,0 +1,48 @@ +const BaseModal = ({ + isOpen, + onClose, + title, + children, + actions, + maxWidth = "500px", + className = "" +}) => { + if (!isOpen) return null; + + const handleOverlayClick = (e) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
e.stopPropagation()} + > + {title && ( +
+

{title}

+ +
+ )} + +
+ {children} +
+ + {actions && ( +
+ {actions} +
+ )} +
+
+ ); +}; + +export default BaseModal; \ No newline at end of file diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..7488504 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.jsx @@ -0,0 +1,60 @@ +import BaseModal from './BaseModal'; + +const ConfirmDialog = ({ + isOpen, + onClose, + onConfirm, + title = "Confirm Action", + message = "Are you sure you want to proceed?", + confirmText = "Confirm", + cancelText = "Cancel", + isLoading = false, + variant = "danger" // "danger" or "primary" +}) => { + const handleConfirm = async () => { + try { + await onConfirm(); + onClose(); + } catch (error) { + console.error('Confirm action failed:', error); + } + }; + + const actions = ( + <> + + + + ); + + return ( + +
+

{message}

+
+
+ ); +}; + +export default ConfirmDialog; \ No newline at end of file diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx new file mode 100644 index 0000000..a939c7d --- /dev/null +++ b/frontend/src/components/Dashboard.jsx @@ -0,0 +1,166 @@ +import { useState, useEffect, useRef } from 'react'; +import { useAuth } from '../contexts/AuthContext'; +import api from '../utils/api'; +import TripList from './TripList'; +import TripModal from './TripModal'; +import ConfirmDialog from './ConfirmDialog'; + +const Dashboard = () => { + const { user, logout } = useAuth(); + const [trips, setTrips] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showTripModal, setShowTripModal] = useState(false); + const [selectedTrip, setSelectedTrip] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [tripToDelete, setTripToDelete] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showUserDropdown, setShowUserDropdown] = useState(false); + const userDropdownRef = useRef(null); + + useEffect(() => { + fetchTrips(); + }, []); + + useEffect(() => { + const handleClickOutside = (event) => { + if (userDropdownRef.current && !userDropdownRef.current.contains(event.target)) { + setShowUserDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const fetchTrips = async () => { + try { + setIsLoading(true); + const response = await api.get('/trips'); + setTrips(response.data.data); + } catch (error) { + console.error('Error fetching trips:', error); + } finally { + setIsLoading(false); + } + }; + + const handleLogout = async () => { + await logout(); + }; + + const handleCreateTrip = () => { + setSelectedTrip(null); + setShowTripModal(true); + }; + + const handleEditTrip = (trip) => { + setSelectedTrip(trip); + setShowTripModal(true); + }; + + const handleDeleteTrip = (trip) => { + setTripToDelete(trip); + setShowDeleteConfirm(true); + }; + + const handleTripSubmit = async (tripData) => { + setIsSubmitting(true); + try { + if (selectedTrip) { + const response = await api.put(`/trips/${selectedTrip.id}`, tripData); + setTrips(trips.map(trip => + trip.id === selectedTrip.id ? response.data.data : trip + )); + } else { + const response = await api.post('/trips', tripData); + setTrips([response.data.data, ...trips]); + } + setShowTripModal(false); + setSelectedTrip(null); + } catch (error) { + console.error('Error saving trip:', error); + throw error; + } finally { + setIsSubmitting(false); + } + }; + + const confirmDeleteTrip = async () => { + try { + await api.delete(`/trips/${tripToDelete.id}`); + setTrips(trips.filter(trip => trip.id !== tripToDelete.id)); + setShowDeleteConfirm(false); + setTripToDelete(null); + } catch (error) { + console.error('Error deleting trip:', error); + throw error; + } + }; + + return ( +
+
+
+

TripPlanner

+
+
+ Welcome back! +
+ + {showUserDropdown && ( +
+ +
+ )} +
+
+
+ +
+ +
+ + { + setShowTripModal(false); + setSelectedTrip(null); + }} + onSubmit={handleTripSubmit} + trip={selectedTrip} + isLoading={isSubmitting} + /> + + { + setShowDeleteConfirm(false); + setTripToDelete(null); + }} + onConfirm={confirmDeleteTrip} + title="Delete Trip" + message={`Are you sure you want to delete "${tripToDelete?.name}"? This action cannot be undone.`} + confirmText="Delete" + variant="danger" + /> +
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx new file mode 100644 index 0000000..0b55853 --- /dev/null +++ b/frontend/src/components/LoginForm.jsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import api from '../utils/api'; + +const LoginForm = ({ onLoginSuccess }) => { + const [formData, setFormData] = useState({ + email: '', + password: '' + }); + + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prevState => ({ + ...prevState, + [name]: value + })); + + if (errors[name]) { + setErrors(prevErrors => ({ + ...prevErrors, + [name]: '' + })); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + setErrors({}); + + try { + const response = await api.post('/login', formData); + + if (response.data.success) { + localStorage.setItem('token', response.data.data.access_token); + localStorage.setItem('user', JSON.stringify(response.data.data.user)); + + if (onLoginSuccess) { + onLoginSuccess(response.data.data); + } + } + } catch (error) { + if (error.response && error.response.status === 422) { + // Validation errors - check both .data and .errors structure + const validationErrors = error.response.data.errors || error.response.data.data || {}; + + // If it's credential error, show as general message + if (validationErrors.email && validationErrors.email[0] === 'The provided credentials are incorrect.') { + setErrors({ general: 'Invalid email or password. Please try again.' }); + } else { + setErrors(validationErrors); + } + } else if (error.response && error.response.status === 401) { + // Unauthorized - wrong credentials + setErrors({ general: 'Invalid email or password. Please try again.' }); + } else if (error.response && error.response.data.message) { + // Other server errors + setErrors({ general: error.response.data.message }); + } else if (error.request) { + // Network error + setErrors({ general: 'Unable to connect to server. Please check your connection.' }); + } else { + // Unknown error + setErrors({ general: 'Unknown error occurred. Please try again.' }); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Login

+ + {errors.general && ( +
+ {errors.general} +
+ )} + +
+
+ + + {errors.email && {errors.email[0]}} +
+ +
+ + + {errors.password && {errors.password[0]}} +
+ + +
+
+ ); +}; + +export default LoginForm; \ No newline at end of file diff --git a/frontend/src/components/RegistrationForm.jsx b/frontend/src/components/RegistrationForm.jsx new file mode 100644 index 0000000..35ba987 --- /dev/null +++ b/frontend/src/components/RegistrationForm.jsx @@ -0,0 +1,148 @@ +import { useState } from 'react'; +import api from '../utils/api'; + +const RegistrationForm = ({ onRegistrationSuccess }) => { + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + password_confirmation: '' + }); + + const [errors, setErrors] = useState({}); + const [isLoading, setIsLoading] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prevState => ({ + ...prevState, + [name]: value + })); + + // Clear specific field error when user starts typing + if (errors[name]) { + setErrors(prevErrors => ({ + ...prevErrors, + [name]: '' + })); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + setErrors({}); + setSuccessMessage(''); + + try { + const response = await api.post('/register', formData); + + if (response.data.success) { + setSuccessMessage('Registration successful! You are now logged in.'); + setFormData({ + name: '', + email: '', + password: '', + password_confirmation: '' + }); + + if (onRegistrationSuccess) { + onRegistrationSuccess(response.data.data); + } + } + } catch (error) { + if (error.response && error.response.status === 422) { + setErrors(error.response.data.data || {}); + } else { + setErrors({ general: 'Registration failed. Please try again.' }); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+

Register

+ + {successMessage && ( +
+ {successMessage} +
+ )} + + {errors.general && ( +
+ {errors.general} +
+ )} + +
+
+ + + {errors.name && {errors.name[0]}} +
+ +
+ + + {errors.email && {errors.email[0]}} +
+ +
+ + + {errors.password && {errors.password[0]}} +
+ +
+ + + {errors.password_confirmation && {errors.password_confirmation[0]}} +
+ + +
+
+ ); +}; + +export default RegistrationForm; \ No newline at end of file diff --git a/frontend/src/components/TripCard.jsx b/frontend/src/components/TripCard.jsx new file mode 100644 index 0000000..164ba0c --- /dev/null +++ b/frontend/src/components/TripCard.jsx @@ -0,0 +1,108 @@ +import { useState, useRef, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { formatDateShort, getDuration } from '../utils/dateFormatter'; + +const TripCard = ({ trip, onEdit, onDelete }) => { + const navigate = useNavigate(); + const [showDropdown, setShowDropdown] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setShowDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + // Removed - using shared utility + + const handleEdit = () => { + setShowDropdown(false); + onEdit(trip); + }; + + const handleDelete = () => { + setShowDropdown(false); + onDelete(trip); + }; + + // Removed - using shared utility + + const handleCardClick = (e) => { + // Don't navigate if clicking on menu buttons + if (e.target.closest('.trip-card-menu')) { + return; + } + navigate(`/trip/${trip.id}`); + }; + + return ( +
+
+

{trip.name}

+
+ + {showDropdown && ( +
+ + +
+ )} +
+
+ + {trip.description && ( +

{trip.description}

+ )} + +
+ {trip.start_date && trip.end_date ? ( +
+ + {formatDateShort(trip.start_date)} - {formatDateShort(trip.end_date)} + + + {getDuration(trip.start_date, trip.end_date)} + +
+ ) : trip.start_date ? ( + + Starts: {formatDateShort(trip.start_date)} + + ) : trip.end_date ? ( + + Ends: {formatDateShort(trip.end_date)} + + ) : ( + + Dates not set + + )} +
+ +
+ + Created {formatDateShort(trip.created_at)} + +
+
+ ); +}; + +export default TripCard; \ No newline at end of file diff --git a/frontend/src/components/TripDetail.css b/frontend/src/components/TripDetail.css new file mode 100644 index 0000000..27ef97c --- /dev/null +++ b/frontend/src/components/TripDetail.css @@ -0,0 +1,198 @@ +.trip-detail { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--color-bg-secondary); +} + +/* Header */ +.trip-detail-header { + background: var(--color-bg-primary); + border-bottom: 1px solid var(--color-border); + padding: var(--spacing-md) var(--spacing-xl) var(--spacing-lg); + box-shadow: var(--shadow-sm); +} + +.header-nav { + margin-bottom: 1rem; +} + +.btn-back { + color: var(--color-text-secondary); + text-decoration: none; + font-size: 0.9rem; + display: inline-flex; + align-items: center; + gap: var(--spacing-xs); + transition: color var(--transition-normal); +} + +.btn-back:hover { + color: var(--color-text-primary); +} + +.header-content h1 { + margin: 0 0 0.5rem 0; + font-size: 2rem; + color: var(--color-text-primary); +} + +.trip-description { + color: var(--color-text-secondary); + margin: 0 0 1rem 0; + font-size: 1.1rem; +} + +.trip-dates { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: 0.95rem; +} + +.date-label { + color: var(--color-text-muted); + font-weight: 500; +} + +.date-value { + color: var(--color-text-primary); +} + +.date-separator { + color: var(--color-text-light); + margin: 0 0.5rem; +} + +/* Content Layout */ +.trip-detail-content { + flex: 1; + 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-left: 1px solid var(--color-border); + overflow-y: auto; + order: 2; +} + +/* Calendar Placeholder */ +.calendar-placeholder { + background: var(--color-bg-primary); + border-radius: var(--border-radius-lg); + padding: var(--spacing-xl); + text-align: center; + box-shadow: var(--shadow-sm); +} + +.calendar-placeholder h2 { + color: var(--color-text-primary); + margin: 0 0 1rem 0; +} + +.calendar-placeholder p { + color: var(--color-text-muted); +} + +/* Loading State */ +.trip-detail-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + gap: var(--spacing-md); +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid #f3f3f3; + border-top: 3px solid #333; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Error State */ +.trip-detail-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; + text-align: center; + gap: var(--spacing-md); +} + +.trip-detail-error h2 { + color: var(--color-danger); + margin: 0; +} + +.trip-detail-error p { + color: var(--color-text-secondary); + margin: 0; +} + +.trip-detail-error .btn-back { + margin-top: 1rem; + padding: var(--spacing-sm) var(--spacing-md); + background: #333; + color: white; + text-decoration: none; + border-radius: var(--border-radius-sm); + transition: background var(--transition-normal); +} + +.trip-detail-error .btn-back:hover { + background: #555; +} + +/* Responsive */ +@media (max-width: 768px) { + .trip-detail-content { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + height: auto; + } + + .trip-detail-main { + order: 2; + 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 { + padding: var(--spacing-md); + } + + .header-content h1 { + font-size: 1.5rem; + } +} \ No newline at end of file diff --git a/frontend/src/components/TripDetail.jsx b/frontend/src/components/TripDetail.jsx new file mode 100644 index 0000000..85b376b --- /dev/null +++ b/frontend/src/components/TripDetail.jsx @@ -0,0 +1,121 @@ +import { useState, useEffect, useMemo } from 'react'; +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(() => { + const loadTrip = async () => { + try { + const tripData = await fetchTrip(id); + setTrip(tripData); + } catch (err) { + console.error('Error loading trip:', err); + } + }; + + 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; + + return ( +
+ Start: + {formatDate(trip.start_date)} + + End: + {formatDate(trip.end_date)} +
+ ); + }, [trip]); + + if (loading) { + return ( +
+
+

Loading trip details...

+
+ ); + } + + if (error) { + return ( +
+

Error

+

{error}

+ Back to Dashboard +
+ ); + } + + if (!trip) { + return null; + } + + return ( +
+
+
+ ← Back to Dashboard +
+
+

{trip.name}

+ {trip.description && ( +

{trip.description}

+ )} + {tripDatesDisplay} +
+
+ +
+
+ setPlannableItems(items)} /> +
+
+ { + // Optional: refresh plannable items if needed + }} + /> +
+
+
+ ); +}; + +export default TripDetail; \ No newline at end of file diff --git a/frontend/src/components/TripList.jsx b/frontend/src/components/TripList.jsx new file mode 100644 index 0000000..b75df17 --- /dev/null +++ b/frontend/src/components/TripList.jsx @@ -0,0 +1,54 @@ +import TripCard from './TripCard'; +import { GlobeEuropeAfricaIcon } from '@heroicons/react/24/outline'; + +const TripList = ({ trips, isLoading, onEdit, onDelete, onCreateTrip }) => { + if (isLoading) { + return ( +
+
+ Loading trips... +
+
+ ); + } + + if (!trips || trips.length === 0) { + return ( +
+

Your Trips

+
+
+
+ + Create New Trip +
+
+
+
+ ); + } + + return ( +
+

Your Trips

+
+ {trips.map((trip) => ( + + ))} +
+
+ + Create New Trip +
+
+
+
+ ); +}; + +export default TripList; \ No newline at end of file diff --git a/frontend/src/components/TripModal.jsx b/frontend/src/components/TripModal.jsx new file mode 100644 index 0000000..541bf36 --- /dev/null +++ b/frontend/src/components/TripModal.jsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from 'react'; +import BaseModal from './BaseModal'; + +const TripModal = ({ isOpen, onClose, onSubmit, trip = null, isLoading = false }) => { + const [formData, setFormData] = useState({ + name: '', + description: '', + start_date: '', + end_date: '' + }); + const [errors, setErrors] = useState({}); + + useEffect(() => { + if (trip) { + setFormData({ + name: trip.name || '', + description: trip.description || '', + start_date: trip.start_date ? trip.start_date.split('T')[0] : '', + end_date: trip.end_date ? trip.end_date.split('T')[0] : '' + }); + } else { + setFormData({ + name: '', + description: '', + start_date: '', + end_date: '' + }); + } + setErrors({}); + }, [trip, isOpen]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + + if (errors[name]) { + setErrors(prev => ({ + ...prev, + [name]: '' + })); + } + }; + + const validateForm = () => { + const newErrors = {}; + + if (!formData.name.trim()) { + newErrors.name = 'Trip name is required'; + } + + if (formData.start_date && formData.end_date && formData.start_date > formData.end_date) { + newErrors.end_date = 'End date must be after start date'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + try { + await onSubmit(formData); + onClose(); + } catch (error) { + if (error.response?.data?.errors) { + setErrors(error.response.data.errors); + } + } + }; + + const handleClose = () => { + setFormData({ + name: '', + description: '', + start_date: '', + end_date: '' + }); + setErrors({}); + onClose(); + }; + + const actions = ( + <> + + + + ); + + return ( + +
+
+ + + {errors.name && {errors.name}} +
+ +
+ +