feat(portal): auth persistence, shift visibility, profile page, and UI polish
- Fix session persistence: add loading state to App.vue, hydrate portal store in router guards so page refresh preserves auth + event context - Fix shift visibility for festivals: query child event time slots so shifts on sub-events appear in the portal - Add profile page with editable personal info and password change - Add backend endpoints: PUT /portal/profile and PUT /portal/password - Fix registration form: make first_name/last_name editable for logged-in users - Restyle login page: remove Vuexy illustration, center form with Crewli branding - Improve dashboard StatusCard with action cards, icons, and upcoming shift count - Enhance shift cards with status border colors and availability progress bars Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,10 +31,12 @@ final class PortalShiftController extends Controller
|
||||
return $this->forbidden('Je moet eerst goedgekeurd zijn om diensten te claimen.');
|
||||
}
|
||||
|
||||
$eventIds = $this->resolveEventIds($event);
|
||||
|
||||
$shifts = Shift::query()
|
||||
->where('status', 'open')
|
||||
->where('slots_open_for_claiming', '>', 0)
|
||||
->whereHas('timeSlot', fn ($q) => $q->where('event_id', $event->id)->where('person_type', 'VOLUNTEER'))
|
||||
->whereHas('timeSlot', fn ($q) => $q->whereIn('event_id', $eventIds)->where('person_type', 'VOLUNTEER'))
|
||||
->with(['festivalSection', 'timeSlot', 'location'])
|
||||
->withCount([
|
||||
'shiftAssignments as active_assignments_count' => fn ($q) => $q->whereNotIn('status', [
|
||||
@@ -113,8 +115,10 @@ final class PortalShiftController extends Controller
|
||||
{
|
||||
$person = $this->resolvePerson($event);
|
||||
|
||||
$eventIds = $this->resolveEventIds($event);
|
||||
|
||||
$assignments = ShiftAssignment::where('person_id', $person->id)
|
||||
->whereHas('shift.timeSlot', fn ($q) => $q->where('event_id', $event->id))
|
||||
->whereHas('shift.timeSlot', fn ($q) => $q->whereIn('event_id', $eventIds))
|
||||
->with(['shift.festivalSection', 'shift.timeSlot', 'shift.location'])
|
||||
->get();
|
||||
|
||||
@@ -239,6 +243,25 @@ final class PortalShiftController extends Controller
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all event IDs relevant for shift queries.
|
||||
* For festivals: includes parent + all child event IDs.
|
||||
* For flat/sub-events: just the event's own ID.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveEventIds(Event $event): array
|
||||
{
|
||||
$ids = [$event->id];
|
||||
|
||||
if ($event->isFestival()) {
|
||||
$childIds = $event->children()->pluck('id')->all();
|
||||
$ids = array_merge($ids, $childIds);
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
private function mapClaimErrorMessage(string $message): string
|
||||
{
|
||||
if (str_contains($message, 'niet open')) {
|
||||
|
||||
@@ -12,6 +12,10 @@ use App\Models\Event;
|
||||
use App\Models\Person;
|
||||
use App\Models\TimeSlot;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
final class PortalMeController extends Controller
|
||||
{
|
||||
@@ -77,4 +81,56 @@ final class PortalMeController extends Controller
|
||||
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
public function updateProfile(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'event_id' => ['required', 'ulid'],
|
||||
'first_name' => ['sometimes', 'string', 'max:255'],
|
||||
'last_name' => ['sometimes', 'string', 'max:255'],
|
||||
'phone' => ['sometimes', 'nullable', 'string', 'max:50'],
|
||||
'date_of_birth' => ['sometimes', 'nullable', 'date', 'before:today'],
|
||||
'remarks' => ['sometimes', 'nullable', 'string', 'max:5000'],
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
$event = Event::findOrFail($validated['event_id']);
|
||||
|
||||
if ($event->isSubEvent()) {
|
||||
$event = $event->parent;
|
||||
}
|
||||
|
||||
$person = Person::where('user_id', $user->id)
|
||||
->where('event_id', $event->id)
|
||||
->firstOrFail();
|
||||
|
||||
// Update user record (name fields)
|
||||
$userFields = Arr::only($validated, ['first_name', 'last_name']);
|
||||
if (!empty($userFields)) {
|
||||
$user->update($userFields);
|
||||
}
|
||||
|
||||
// Update person record (phone, date_of_birth, remarks)
|
||||
$personFields = Arr::only($validated, ['first_name', 'last_name', 'phone', 'date_of_birth', 'remarks']);
|
||||
if (!empty($personFields)) {
|
||||
$person->update($personFields);
|
||||
}
|
||||
|
||||
return $this->success(['message' => 'Profiel bijgewerkt.']);
|
||||
}
|
||||
|
||||
public function updatePassword(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'current_password' => ['required', 'string', 'current_password'],
|
||||
'password' => ['required', 'string', Password::min(8), 'confirmed'],
|
||||
]);
|
||||
|
||||
$request->user()->update([
|
||||
'password' => Hash::make($validated['password']),
|
||||
]);
|
||||
|
||||
return $this->success(['message' => 'Wachtwoord gewijzigd.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,8 @@ Route::middleware('auth:sanctum')->group(function () {
|
||||
|
||||
// Portal (authenticated)
|
||||
Route::get('portal/me', [PortalMeController::class, 'index']);
|
||||
Route::put('portal/profile', [PortalMeController::class, 'updateProfile']);
|
||||
Route::put('portal/password', [PortalMeController::class, 'updatePassword']);
|
||||
Route::get('portal/events/{event}/available-shifts', [PortalShiftController::class, 'availableShifts']);
|
||||
Route::get('portal/events/{event}/my-shifts', [PortalShiftController::class, 'myShifts']);
|
||||
Route::post('portal/events/{event}/shifts/{shift}/claim', [PortalShiftController::class, 'claim']);
|
||||
|
||||
196
api/tests/Feature/Api/V1/Portal/PortalProfileTest.php
Normal file
196
api/tests/Feature/Api/V1/Portal/PortalProfileTest.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\V1\Portal;
|
||||
|
||||
use App\Models\CrowdType;
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Person;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PortalProfileTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $volunteer;
|
||||
private Organisation $organisation;
|
||||
private Event $event;
|
||||
private Person $person;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
|
||||
$this->organisation = Organisation::factory()->create();
|
||||
$this->volunteer = User::factory()->create([
|
||||
'first_name' => 'Jan',
|
||||
'last_name' => 'Jansen',
|
||||
'password' => Hash::make('old-password'),
|
||||
]);
|
||||
$this->organisation->users()->attach($this->volunteer, ['role' => 'org_member']);
|
||||
|
||||
$this->event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
$crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
$this->person = Person::factory()->approved()->create([
|
||||
'event_id' => $this->event->id,
|
||||
'crowd_type_id' => $crowdType->id,
|
||||
'user_id' => $this->volunteer->id,
|
||||
'first_name' => 'Jan',
|
||||
'last_name' => 'Jansen',
|
||||
'phone' => '0612345678',
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Profile update
|
||||
// =========================================================================
|
||||
|
||||
public function test_update_profile_updates_user_and_person(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/profile', [
|
||||
'event_id' => $this->event->id,
|
||||
'first_name' => 'Piet',
|
||||
'last_name' => 'Pietersen',
|
||||
'phone' => '0687654321',
|
||||
'date_of_birth' => '1990-05-15',
|
||||
'remarks' => 'Vegetarisch',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.message', 'Profiel bijgewerkt.');
|
||||
|
||||
$this->volunteer->refresh();
|
||||
$this->assertEquals('Piet', $this->volunteer->first_name);
|
||||
$this->assertEquals('Pietersen', $this->volunteer->last_name);
|
||||
|
||||
$this->person->refresh();
|
||||
$this->assertEquals('Piet', $this->person->first_name);
|
||||
$this->assertEquals('Pietersen', $this->person->last_name);
|
||||
$this->assertEquals('0687654321', $this->person->phone);
|
||||
$this->assertEquals('1990-05-15', $this->person->date_of_birth->toDateString());
|
||||
$this->assertEquals('Vegetarisch', $this->person->remarks);
|
||||
}
|
||||
|
||||
public function test_update_profile_partial_update(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/profile', [
|
||||
'event_id' => $this->event->id,
|
||||
'phone' => '0699999999',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$this->person->refresh();
|
||||
$this->assertEquals('0699999999', $this->person->phone);
|
||||
$this->assertEquals('Jan', $this->person->first_name); // unchanged
|
||||
}
|
||||
|
||||
public function test_update_profile_requires_event_id(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/profile', [
|
||||
'first_name' => 'Piet',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
}
|
||||
|
||||
public function test_update_profile_unauthenticated(): void
|
||||
{
|
||||
$response = $this->putJson('/api/v1/portal/profile', [
|
||||
'event_id' => $this->event->id,
|
||||
'first_name' => 'Piet',
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Password update
|
||||
// =========================================================================
|
||||
|
||||
public function test_update_password(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/password', [
|
||||
'current_password' => 'old-password',
|
||||
'password' => 'new-secure-password',
|
||||
'password_confirmation' => 'new-secure-password',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('data.message', 'Wachtwoord gewijzigd.');
|
||||
|
||||
$this->volunteer->refresh();
|
||||
$this->assertTrue(Hash::check('new-secure-password', $this->volunteer->password));
|
||||
}
|
||||
|
||||
public function test_update_password_wrong_current(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/password', [
|
||||
'current_password' => 'wrong-password',
|
||||
'password' => 'new-secure-password',
|
||||
'password_confirmation' => 'new-secure-password',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['current_password']);
|
||||
}
|
||||
|
||||
public function test_update_password_mismatch(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/password', [
|
||||
'current_password' => 'old-password',
|
||||
'password' => 'new-secure-password',
|
||||
'password_confirmation' => 'different-password',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['password']);
|
||||
}
|
||||
|
||||
public function test_update_password_too_short(): void
|
||||
{
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->putJson('/api/v1/portal/password', [
|
||||
'current_password' => 'old-password',
|
||||
'password' => 'short',
|
||||
'password_confirmation' => 'short',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['password']);
|
||||
}
|
||||
|
||||
public function test_update_password_unauthenticated(): void
|
||||
{
|
||||
$response = $this->putJson('/api/v1/portal/password', [
|
||||
'current_password' => 'old-password',
|
||||
'password' => 'new-secure-password',
|
||||
'password_confirmation' => 'new-secure-password',
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,89 @@ class PortalShiftClaimingTest extends TestCase
|
||||
$this->assertEquals('Open Shift', $allShifts->first()['title']);
|
||||
}
|
||||
|
||||
public function test_available_shifts_includes_sub_event_shifts_for_festival(): void
|
||||
{
|
||||
// Create a festival parent with a sub-event
|
||||
$festival = Event::factory()->festival()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
$subEvent = Event::factory()->subEvent($festival)->create();
|
||||
|
||||
// Person is registered at the festival level
|
||||
$person = Person::factory()->approved()->create([
|
||||
'event_id' => $festival->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
'user_id' => $this->volunteer->id,
|
||||
]);
|
||||
|
||||
// Shift lives on the sub-event
|
||||
$subSection = FestivalSection::factory()->create(['event_id' => $subEvent->id]);
|
||||
$subSlot = TimeSlot::factory()->create([
|
||||
'event_id' => $subEvent->id,
|
||||
'person_type' => 'VOLUNTEER',
|
||||
'date' => now()->addMonth(),
|
||||
]);
|
||||
$shift = Shift::factory()->open()->create([
|
||||
'festival_section_id' => $subSection->id,
|
||||
'time_slot_id' => $subSlot->id,
|
||||
'slots_total' => 4,
|
||||
'slots_open_for_claiming' => 3,
|
||||
'title' => 'Sub-event Shift',
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->getJson("/api/v1/portal/events/{$festival->id}/available-shifts");
|
||||
|
||||
$response->assertOk();
|
||||
$allShifts = collect($response->json('data'))
|
||||
->flatMap(fn ($day) => collect($day['time_slots']))
|
||||
->flatMap(fn ($ts) => $ts['shifts']);
|
||||
|
||||
$this->assertCount(1, $allShifts);
|
||||
$this->assertEquals('Sub-event Shift', $allShifts->first()['title']);
|
||||
}
|
||||
|
||||
public function test_my_shifts_includes_sub_event_assignments_for_festival(): void
|
||||
{
|
||||
$festival = Event::factory()->festival()->create([
|
||||
'organisation_id' => $this->organisation->id,
|
||||
]);
|
||||
$subEvent = Event::factory()->subEvent($festival)->create();
|
||||
|
||||
$person = Person::factory()->approved()->create([
|
||||
'event_id' => $festival->id,
|
||||
'crowd_type_id' => $this->crowdType->id,
|
||||
'user_id' => $this->volunteer->id,
|
||||
]);
|
||||
|
||||
$subSection = FestivalSection::factory()->create(['event_id' => $subEvent->id]);
|
||||
$subSlot = TimeSlot::factory()->create([
|
||||
'event_id' => $subEvent->id,
|
||||
'person_type' => 'VOLUNTEER',
|
||||
'date' => now()->addMonth(),
|
||||
]);
|
||||
$shift = Shift::factory()->open()->create([
|
||||
'festival_section_id' => $subSection->id,
|
||||
'time_slot_id' => $subSlot->id,
|
||||
'slots_total' => 4,
|
||||
'slots_open_for_claiming' => 3,
|
||||
]);
|
||||
|
||||
ShiftAssignment::factory()->approved()->create([
|
||||
'shift_id' => $shift->id,
|
||||
'person_id' => $person->id,
|
||||
'time_slot_id' => $subSlot->id,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->volunteer);
|
||||
|
||||
$response = $this->getJson("/api/v1/portal/events/{$festival->id}/my-shifts");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(1, $response->json('data.upcoming'));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// My shifts
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user