diff --git a/api/app/Http/Controllers/Api/V1/EventController.php b/api/app/Http/Controllers/Api/V1/EventController.php index e047a9d..c830774 100644 --- a/api/app/Http/Controllers/Api/V1/EventController.php +++ b/api/app/Http/Controllers/Api/V1/EventController.php @@ -63,7 +63,7 @@ final class EventController extends Controller } if (!isset($data['event_type'])) { - $data['event_type'] = empty($data['parent_event_id']) ? 'event' : 'event'; + $data['event_type'] = 'event'; } $event = $organisation->events()->create($data); @@ -80,6 +80,39 @@ final class EventController extends Controller return $this->success(new EventResource($event->fresh())); } + public function destroy(Organisation $organisation, Event $event): JsonResponse + { + Gate::authorize('delete', [$event, $organisation]); + + $event->delete(); + + return $this->success(null, 'Event deleted'); + } + + public function transition(Request $request, Organisation $organisation, Event $event): JsonResponse + { + Gate::authorize('update', [$event, $organisation]); + + $request->validate(['status' => 'required|string']); + $newStatus = $request->status; + + $result = $event->canTransitionToWithPrerequisites($newStatus); + + if (! empty($result['errors'])) { + return response()->json([ + 'message' => 'Status transition not possible.', + 'errors' => $result['errors'], + 'current_status' => $event->status, + 'requested_status' => $newStatus, + 'allowed_transitions' => Event::STATUS_TRANSITIONS[$event->status] ?? [], + ], 422); + } + + $event->transitionTo($newStatus); + + return $this->success(new EventResource($event->fresh())); + } + public function children(Organisation $organisation, Event $event): AnonymousResourceCollection { Gate::authorize('view', [$event, $organisation]); diff --git a/api/app/Http/Controllers/Api/V1/FestivalSectionController.php b/api/app/Http/Controllers/Api/V1/FestivalSectionController.php index 1896df8..8927e80 100644 --- a/api/app/Http/Controllers/Api/V1/FestivalSectionController.php +++ b/api/app/Http/Controllers/Api/V1/FestivalSectionController.php @@ -23,6 +23,17 @@ final class FestivalSectionController extends Controller $sections = $event->festivalSections()->ordered()->get(); + // For sub-events, also include cross_event sections from the parent festival + if ($event->isSubEvent()) { + $parentCrossEventSections = $event->parent + ->festivalSections() + ->where('type', 'cross_event') + ->ordered() + ->get(); + + $sections = $parentCrossEventSections->merge($sections)->sortBy('sort_order')->values(); + } + return FestivalSectionResource::collection($sections); } @@ -30,9 +41,39 @@ final class FestivalSectionController extends Controller { Gate::authorize('create', [FestivalSection::class, $event]); - $section = $event->festivalSections()->create($request->validated()); + $data = $request->validated(); + $redirectedToParent = false; - return $this->created(new FestivalSectionResource($section)); + if (($data['type'] ?? 'standard') === 'cross_event') { + if ($event->isFlatEvent()) { + return $this->error( + 'Overkoepelende secties kunnen alleen worden aangemaakt bij festivals met programmaonderdelen.', + 422, + ); + } + + if ($event->isSubEvent()) { + $event = $event->parent; + Gate::authorize('create', [FestivalSection::class, $event]); + $redirectedToParent = true; + } + } + + $section = $event->festivalSections()->create($data); + + $response = $this->created(new FestivalSectionResource($section)); + + if ($redirectedToParent) { + $original = $response->getData(true); + $original['meta'] = [ + 'redirected_to_parent' => true, + 'parent_event_name' => $event->name, + ]; + + return response()->json($original, 201); + } + + return $response; } public function update(UpdateFestivalSectionRequest $request, Event $event, FestivalSection $section): JsonResponse @@ -57,10 +98,10 @@ final class FestivalSectionController extends Controller { Gate::authorize('reorder', [FestivalSection::class, $event]); - foreach ($request->validated('sections') as $item) { + foreach ($request->validated('sections') as $index => $id) { $event->festivalSections() - ->where('id', $item['id']) - ->update(['sort_order' => $item['sort_order']]); + ->where('id', $id) + ->update(['sort_order' => $index]); } $sections = $event->festivalSections()->ordered()->get(); diff --git a/api/app/Http/Controllers/Api/V1/ShiftController.php b/api/app/Http/Controllers/Api/V1/ShiftController.php index 4a89156..b1b4e96 100644 --- a/api/app/Http/Controllers/Api/V1/ShiftController.php +++ b/api/app/Http/Controllers/Api/V1/ShiftController.php @@ -26,6 +26,7 @@ final class ShiftController extends Controller $shifts = $section->shifts() ->with(['timeSlot', 'location']) + ->withCount(['shiftAssignments as filled_slots' => fn ($q) => $q->where('status', 'approved')]) ->get(); return ShiftResource::collection($shifts); @@ -84,13 +85,11 @@ final class ShiftController extends Controller } } - $autoApprove = $section->crew_auto_accepts; - $assignment = $shift->shiftAssignments()->create([ 'person_id' => $personId, 'time_slot_id' => $shift->time_slot_id, - 'status' => $autoApprove ? 'approved' : 'approved', - 'auto_approved' => $autoApprove, + 'status' => 'approved', + 'auto_approved' => false, 'assigned_by' => $request->user()->id, 'assigned_at' => now(), 'approved_at' => now(), diff --git a/api/app/Http/Requests/Api/V1/ReorderFestivalSectionsRequest.php b/api/app/Http/Requests/Api/V1/ReorderFestivalSectionsRequest.php index f997a61..ea33bf4 100644 --- a/api/app/Http/Requests/Api/V1/ReorderFestivalSectionsRequest.php +++ b/api/app/Http/Requests/Api/V1/ReorderFestivalSectionsRequest.php @@ -17,9 +17,8 @@ final class ReorderFestivalSectionsRequest extends FormRequest public function rules(): array { return [ - 'sections' => ['required', 'array'], - 'sections.*.id' => ['required', 'ulid'], - 'sections.*.sort_order' => ['required', 'integer', 'min:0'], + 'sections' => ['required', 'array', 'min:1'], + 'sections.*' => ['required', 'ulid'], ]; } } diff --git a/api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php b/api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php index 063d63c..82c1f65 100644 --- a/api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreFestivalSectionRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; +use App\Models\Event; use Illuminate\Foundation\Http\FormRequest; final class StoreFestivalSectionRequest extends FormRequest @@ -18,10 +19,26 @@ final class StoreFestivalSectionRequest extends FormRequest { return [ 'name' => ['required', 'string', 'max:255'], + 'category' => ['nullable', 'string', 'max:50'], + 'icon' => ['nullable', 'string', 'max:50'], 'sort_order' => ['nullable', 'integer', 'min:0'], 'type' => ['nullable', 'in:standard,cross_event'], + 'crew_need' => ['nullable', 'integer', 'min:0'], 'crew_auto_accepts' => ['nullable', 'boolean'], + 'crew_invited_to_events' => ['nullable', 'boolean'], + 'added_to_timeline' => ['nullable', 'boolean'], 'responder_self_checkin' => ['nullable', 'boolean'], + 'crew_accreditation_level' => ['nullable', 'string', 'max:50'], + 'public_form_accreditation_level' => ['nullable', 'string', 'max:50'], + 'timed_accreditations' => ['nullable', 'boolean'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'type.in' => 'Type moet standard of cross_event zijn.', ]; } } diff --git a/api/app/Http/Requests/Api/V1/UpdateEventRequest.php b/api/app/Http/Requests/Api/V1/UpdateEventRequest.php index d37fa5b..7742f38 100644 --- a/api/app/Http/Requests/Api/V1/UpdateEventRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateEventRequest.php @@ -17,12 +17,12 @@ final class UpdateEventRequest extends FormRequest public function rules(): array { return [ + // Status changes must go through POST /events/{event}/transition 'name' => ['sometimes', 'string', 'max:255'], 'slug' => ['sometimes', 'string', 'max:255', 'regex:/^[a-z0-9-]+$/'], 'start_date' => ['sometimes', 'date'], 'end_date' => ['sometimes', 'date', 'after_or_equal:start_date'], 'timezone' => ['sometimes', 'string', 'max:50'], - 'status' => ['sometimes', 'string', 'in:draft,published,registration_open,buildup,showday,teardown,closed'], 'parent_event_id' => ['nullable', 'ulid', 'exists:events,id'], 'event_type' => ['sometimes', 'in:event,festival,series'], 'event_type_label' => ['nullable', 'string', 'max:50'], diff --git a/api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php b/api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php index 8c5c267..ca1453f 100644 --- a/api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateFestivalSectionRequest.php @@ -18,6 +18,8 @@ final class UpdateFestivalSectionRequest extends FormRequest { return [ 'name' => ['sometimes', 'string', 'max:255'], + 'category' => ['sometimes', 'nullable', 'string', 'max:50'], + 'icon' => ['sometimes', 'nullable', 'string', 'max:50'], 'sort_order' => ['sometimes', 'integer', 'min:0'], 'type' => ['sometimes', 'in:standard,cross_event'], 'crew_auto_accepts' => ['sometimes', 'boolean'], diff --git a/api/app/Http/Resources/Api/V1/EventResource.php b/api/app/Http/Resources/Api/V1/EventResource.php index 3a506cc..d5c27cf 100644 --- a/api/app/Http/Resources/Api/V1/EventResource.php +++ b/api/app/Http/Resources/Api/V1/EventResource.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Resources\Api\V1; +use App\Models\Event; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -21,6 +22,7 @@ final class EventResource extends JsonResource 'end_date' => $this->end_date->toDateString(), 'timezone' => $this->timezone, 'status' => $this->status, + 'allowed_transitions' => Event::STATUS_TRANSITIONS[$this->status] ?? [], 'event_type' => $this->event_type, 'event_type_label' => $this->event_type_label, 'sub_event_label' => $this->sub_event_label, diff --git a/api/app/Http/Resources/Api/V1/FestivalSectionResource.php b/api/app/Http/Resources/Api/V1/FestivalSectionResource.php index c2abe50..7277d70 100644 --- a/api/app/Http/Resources/Api/V1/FestivalSectionResource.php +++ b/api/app/Http/Resources/Api/V1/FestivalSectionResource.php @@ -15,13 +15,18 @@ final class FestivalSectionResource extends JsonResource 'id' => $this->id, 'event_id' => $this->event_id, 'name' => $this->name, + 'category' => $this->category, + 'icon' => $this->icon, 'type' => $this->type, 'sort_order' => $this->sort_order, 'crew_need' => $this->crew_need, 'crew_auto_accepts' => $this->crew_auto_accepts, - 'responder_self_checkin' => $this->responder_self_checkin, + 'crew_invited_to_events' => $this->crew_invited_to_events, 'added_to_timeline' => $this->added_to_timeline, + 'responder_self_checkin' => $this->responder_self_checkin, 'crew_accreditation_level' => $this->crew_accreditation_level, + 'public_form_accreditation_level' => $this->public_form_accreditation_level, + 'timed_accreditations' => $this->timed_accreditations, 'created_at' => $this->created_at->toIso8601String(), 'shifts_count' => $this->whenCounted('shifts'), ]; diff --git a/api/app/Http/Resources/Api/V1/ShiftResource.php b/api/app/Http/Resources/Api/V1/ShiftResource.php index 1426bfe..378e16d 100644 --- a/api/app/Http/Resources/Api/V1/ShiftResource.php +++ b/api/app/Http/Resources/Api/V1/ShiftResource.php @@ -6,6 +6,7 @@ namespace App\Http\Resources\Api\V1; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Carbon; final class ShiftResource extends JsonResource { @@ -26,15 +27,18 @@ final class ShiftResource extends JsonResource 'slots_total' => $this->slots_total, 'slots_open_for_claiming' => $this->slots_open_for_claiming, 'is_lead_role' => $this->is_lead_role, - 'report_time' => $this->report_time, - 'actual_start_time' => $this->actual_start_time, - 'actual_end_time' => $this->actual_end_time, + 'report_time' => $this->report_time ? Carbon::parse($this->report_time)->format('H:i') : null, + 'actual_start_time' => $this->actual_start_time ? Carbon::parse($this->actual_start_time)->format('H:i') : null, + 'actual_end_time' => $this->actual_end_time ? Carbon::parse($this->actual_end_time)->format('H:i') : null, + 'end_date' => $this->end_date?->toDateString(), 'allow_overlap' => $this->allow_overlap, + 'events_during_shift' => $this->events_during_shift, + 'assigned_crew_id' => $this->assigned_crew_id, 'status' => $this->status, 'filled_slots' => $this->filled_slots, 'fill_rate' => $this->fill_rate, - 'effective_start_time' => $this->effective_start_time, - 'effective_end_time' => $this->effective_end_time, + 'effective_start_time' => $this->effective_start_time ? Carbon::parse($this->effective_start_time)->format('H:i') : null, + 'effective_end_time' => $this->effective_end_time ? Carbon::parse($this->effective_end_time)->format('H:i') : null, 'created_at' => $this->created_at->toIso8601String(), 'time_slot' => new TimeSlotResource($this->whenLoaded('timeSlot')), 'location' => new LocationResource($this->whenLoaded('location')), diff --git a/api/app/Models/Event.php b/api/app/Models/Event.php index c18feb9..649e442 100644 --- a/api/app/Models/Event.php +++ b/api/app/Models/Event.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Collection; final class Event extends Model { @@ -19,6 +20,34 @@ final class Event extends Model use HasUlids; use SoftDeletes; + /** @var array> Allowed status transitions */ + public const STATUS_TRANSITIONS = [ + 'draft' => ['published'], + 'published' => ['registration_open', 'draft'], + 'registration_open' => ['buildup', 'published'], + 'buildup' => ['showday'], + 'showday' => ['teardown'], + 'teardown' => ['closed'], + 'closed' => [], + ]; + + /** + * Statuses that cascade from a festival parent to its children. + * When the parent reaches one of these, children in an earlier status follow. + */ + private const CASCADE_STATUSES = ['showday', 'teardown', 'closed']; + + /** Ordered list used to determine "earlier" status. */ + private const STATUS_ORDER = [ + 'draft' => 0, + 'published' => 1, + 'registration_open' => 2, + 'buildup' => 3, + 'showday' => 4, + 'teardown' => 5, + 'closed' => 6, + ]; + protected $fillable = [ 'organisation_id', 'parent_event_id', @@ -101,6 +130,80 @@ final class Event extends Model ->orderBy('name'); } + // ----- Status State Machine ----- + + public function canTransitionTo(string $newStatus): bool + { + $allowed = self::STATUS_TRANSITIONS[$this->status] ?? []; + + return in_array($newStatus, $allowed, true); + } + + /** @return list Missing prerequisites (empty = OK) */ + public function getTransitionPrerequisites(string $newStatus): array + { + $missing = []; + + switch ($newStatus) { + case 'published': + if (! $this->name || ! $this->start_date || ! $this->end_date) { + $missing[] = 'Event must have a name, start date, and end date.'; + } + break; + case 'registration_open': + if ($this->timeSlots()->count() === 0) { + $missing[] = 'At least one time slot must exist before opening registration.'; + } + if ($this->festivalSections()->count() === 0) { + $missing[] = 'At least one section must exist before opening registration.'; + } + break; + } + + return $missing; + } + + /** @return array{errors: list} */ + public function canTransitionToWithPrerequisites(string $newStatus): array + { + if (! $this->canTransitionTo($newStatus)) { + return ['errors' => ["Status transition from '{$this->status}' to '{$newStatus}' is not allowed."]]; + } + + return ['errors' => $this->getTransitionPrerequisites($newStatus)]; + } + + public function transitionTo(string $newStatus): void + { + if (! $this->canTransitionTo($newStatus)) { + throw new \InvalidArgumentException( + "Cannot transition from '{$this->status}' to '{$newStatus}'." + ); + } + + $this->update(['status' => $newStatus]); + + if ($this->isFestival() && in_array($newStatus, self::CASCADE_STATUSES, true)) { + $this->cascadeStatusToChildren($newStatus); + } + } + + /** + * Cascade a status to all children that are in an earlier lifecycle stage. + */ + private function cascadeStatusToChildren(string $newStatus): void + { + $targetOrder = self::STATUS_ORDER[$newStatus]; + + $earlierStatuses = array_keys( + array_filter(self::STATUS_ORDER, fn (int $order) => $order < $targetOrder) + ); + + $this->children() + ->whereIn('status', $earlierStatuses) + ->update(['status' => $newStatus]); + } + // ----- Scopes ----- public function scopeTopLevel(Builder $query): Builder @@ -165,4 +268,45 @@ final class Event extends Model { return $query->whereIn('status', ['showday', 'buildup', 'teardown']); } + + /** + * Eager-load the event's own time slots plus, for sub-events, + * time slots from parent cross_event sections. + */ + public function scopeWithOperationalContext(Builder $query): Builder + { + return $query->with(['timeSlots', 'festivalSections', 'parent.timeSlots', 'parent.festivalSections']); + } + + /** + * Get all time slots relevant for shift planning: + * - Flat event: own time slots + * - Festival parent: own time slots + all children's time slots + * - Sub-event: own time slots + parent's time slots + */ + public function getAllRelevantTimeSlots(): Collection + { + $ownSlots = $this->timeSlots()->orderBy('date')->orderBy('start_time')->get(); + + if ($this->isFestival()) { + $childIds = $this->children()->pluck('id'); + $childSlots = TimeSlot::whereIn('event_id', $childIds) + ->orderBy('date') + ->orderBy('start_time') + ->get(); + + return $ownSlots->merge($childSlots)->sortBy(['date', 'start_time'])->values(); + } + + if ($this->isSubEvent() && $this->parent_event_id) { + $parentSlots = TimeSlot::where('event_id', $this->parent_event_id) + ->orderBy('date') + ->orderBy('start_time') + ->get(); + + return $ownSlots->merge($parentSlots)->sortBy(['date', 'start_time'])->values(); + } + + return $ownSlots; + } } diff --git a/api/app/Models/FestivalSection.php b/api/app/Models/FestivalSection.php index 616f674..851e121 100644 --- a/api/app/Models/FestivalSection.php +++ b/api/app/Models/FestivalSection.php @@ -21,6 +21,8 @@ final class FestivalSection extends Model protected $fillable = [ 'event_id', 'name', + 'category', + 'icon', 'type', 'sort_order', 'crew_need', diff --git a/api/app/Models/Organisation.php b/api/app/Models/Organisation.php index 42916d8..375b60f 100644 --- a/api/app/Models/Organisation.php +++ b/api/app/Models/Organisation.php @@ -57,4 +57,9 @@ final class Organisation extends Model { return $this->hasMany(Company::class); } + + public function personTags(): HasMany + { + return $this->hasMany(PersonTag::class); + } } diff --git a/api/app/Models/Shift.php b/api/app/Models/Shift.php index edca084..c135705 100644 --- a/api/app/Models/Shift.php +++ b/api/app/Models/Shift.php @@ -45,6 +45,7 @@ final class Shift extends Model return [ 'is_lead_role' => 'boolean', 'allow_overlap' => 'boolean', + 'end_date' => 'date', 'events_during_shift' => 'array', 'slots_total' => 'integer', 'slots_open_for_claiming' => 'integer', @@ -93,7 +94,13 @@ final class Shift extends Model protected function filledSlots(): Attribute { - return Attribute::get(fn () => $this->shiftAssignments()->where('status', 'approved')->count()); + return Attribute::get(function () { + if (array_key_exists('filled_slots', $this->attributes)) { + return (int) $this->attributes['filled_slots']; + } + + return $this->shiftAssignments()->where('status', 'approved')->count(); + }); } protected function fillRate(): Attribute diff --git a/api/app/Policies/EventPolicy.php b/api/app/Policies/EventPolicy.php index edaf7ca..7c9bc1b 100644 --- a/api/app/Policies/EventPolicy.php +++ b/api/app/Policies/EventPolicy.php @@ -64,4 +64,31 @@ final class EventPolicy ->wherePivot('role', 'event_manager') ->exists(); } + + public function delete(User $user, Event $event, ?Organisation $organisation = null): bool + { + if ($organisation && $event->organisation_id !== $organisation->id) { + return false; + } + + if ($user->hasRole('super_admin')) { + return true; + } + + // org_admin at organisation level + $isOrgAdmin = $event->organisation->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'org_admin') + ->exists(); + + if ($isOrgAdmin) { + return true; + } + + // event_manager at event level + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') + ->exists(); + } } diff --git a/api/database/factories/ShiftAssignmentFactory.php b/api/database/factories/ShiftAssignmentFactory.php new file mode 100644 index 0000000..7bc3d84 --- /dev/null +++ b/api/database/factories/ShiftAssignmentFactory.php @@ -0,0 +1,44 @@ + */ +final class ShiftAssignmentFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'shift_id' => Shift::factory(), + 'person_id' => Person::factory(), + 'time_slot_id' => TimeSlot::factory(), + 'status' => 'pending_approval', + 'auto_approved' => false, + ]; + } + + public function approved(): static + { + return $this->state(fn () => [ + 'status' => 'approved', + 'approved_at' => now(), + ]); + } + + public function autoApproved(): static + { + return $this->state(fn () => [ + 'status' => 'approved', + 'auto_approved' => true, + 'approved_at' => now(), + ]); + } +} diff --git a/api/routes/api.php b/api/routes/api.php index 869c8fe..512f1f7 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -15,8 +15,13 @@ use App\Http\Controllers\Api\V1\MeController; use App\Http\Controllers\Api\V1\MemberController; use App\Http\Controllers\Api\V1\OrganisationController; use App\Http\Controllers\Api\V1\PersonController; +use App\Http\Controllers\Api\V1\PersonTagController; use App\Http\Controllers\Api\V1\ShiftController; use App\Http\Controllers\Api\V1\TimeSlotController; +use App\Http\Controllers\Api\V1\UserOrganisationTagController; +use App\Models\FestivalSection; +use App\Models\Organisation; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; /* @@ -54,15 +59,40 @@ Route::middleware('auth:sanctum')->group(function () { // Events (nested under organisations) Route::apiResource('organisations.events', EventController::class) - ->only(['index', 'show', 'store', 'update']); + ->only(['index', 'show', 'store', 'update', 'destroy']); Route::get('organisations/{organisation}/events/{event}/children', [EventController::class, 'children']); + Route::post('organisations/{organisation}/events/{event}/transition', [EventController::class, 'transition']); // Organisation-scoped resources Route::prefix('organisations/{organisation}')->group(function () { Route::apiResource('crowd-types', CrowdTypeController::class) ->except(['show']); - Route::apiResource('companies', CompanyController::class) + Route::apiResource('companies', CompanyController::class); + + // Section categories (autocomplete) + Route::get('section-categories', function (Organisation $organisation) { + Gate::authorize('view', $organisation); + + $categories = FestivalSection::query() + ->whereIn('event_id', $organisation->events()->select('id')) + ->whereNotNull('category') + ->distinct() + ->orderBy('category') + ->pluck('category'); + + return response()->json(['data' => $categories]); + }); + + // Person tags (organisation settings) + Route::apiResource('person-tags', PersonTagController::class) ->except(['show']); + Route::get('person-tag-categories', [PersonTagController::class, 'categories']); + + // User tag assignments + Route::get('users/{user}/tags', [UserOrganisationTagController::class, 'index']); + Route::post('users/{user}/tags', [UserOrganisationTagController::class, 'store']); + Route::put('users/{user}/tags/sync', [UserOrganisationTagController::class, 'sync']); + Route::delete('users/{user}/tags/{userOrganisationTag}', [UserOrganisationTagController::class, 'destroy']); // Invitations & Members Route::post('invite', [InvitationController::class, 'invite']); diff --git a/api/tests/Feature/Event/EventTest.php b/api/tests/Feature/Event/EventTest.php index 17342db..0af876e 100644 --- a/api/tests/Feature/Event/EventTest.php +++ b/api/tests/Feature/Event/EventTest.php @@ -284,4 +284,122 @@ class EventTest extends TestCase $response->assertUnauthorized(); } + + // --- DESTROY --- + + public function test_org_admin_can_soft_delete_event(): void + { + $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}"); + + $response->assertOk(); + $this->assertSoftDeleted('events', ['id' => $event->id]); + } + + public function test_org_admin_can_soft_delete_sub_event(): void + { + $festival = Event::factory()->festival()->create([ + 'organisation_id' => $this->organisation->id, + ]); + $subEvent = Event::factory()->subEvent($festival)->create(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$subEvent->id}"); + + $response->assertOk(); + $this->assertSoftDeleted('events', ['id' => $subEvent->id]); + $this->assertNotSoftDeleted('events', ['id' => $festival->id]); + } + + public function test_event_manager_can_delete_event(): void + { + $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + $eventManager = User::factory()->create(); + $this->organisation->users()->attach($eventManager, ['role' => 'org_member']); + $event->users()->attach($eventManager, ['role' => 'event_manager']); + + Sanctum::actingAs($eventManager); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}"); + + $response->assertOk(); + $this->assertSoftDeleted('events', ['id' => $event->id]); + } + + public function test_org_member_cannot_delete_event(): void + { + $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + + Sanctum::actingAs($this->orgMember); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}"); + + $response->assertForbidden(); + } + + public function test_outsider_cannot_delete_event(): void + { + $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + + Sanctum::actingAs($this->outsider); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}"); + + $response->assertForbidden(); + } + + public function test_unauthenticated_user_cannot_delete_event(): void + { + $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}"); + + $response->assertUnauthorized(); + } + + public function test_delete_event_from_other_org_is_blocked(): void + { + $otherOrg = Organisation::factory()->create(); + $event = Event::factory()->create(['organisation_id' => $otherOrg->id]); + + Sanctum::actingAs($this->admin); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}"); + + $response->assertForbidden(); + } + + public function test_soft_deleted_event_not_in_index(): void + { + $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + $event->delete(); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events"); + + $response->assertOk(); + $this->assertCount(0, $response->json('data')); + } + + public function test_soft_delete_does_not_cascade_to_related_data(): void + { + $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + $section = $event->festivalSections()->create([ + 'name' => 'Stage A', + 'sort_order' => 1, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->deleteJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}"); + + $response->assertOk(); + $this->assertSoftDeleted('events', ['id' => $event->id]); + $this->assertDatabaseHas('festival_sections', ['id' => $section->id, 'deleted_at' => null]); + } } diff --git a/api/tests/Feature/Event/FestivalEventTest.php b/api/tests/Feature/Event/FestivalEventTest.php index d7fa5e5..a18cff3 100644 --- a/api/tests/Feature/Event/FestivalEventTest.php +++ b/api/tests/Feature/Event/FestivalEventTest.php @@ -4,8 +4,13 @@ declare(strict_types=1); namespace Tests\Feature\Event; +use App\Models\CrowdType; use App\Models\Event; +use App\Models\FestivalSection; use App\Models\Organisation; +use App\Models\Person; +use App\Models\Shift; +use App\Models\TimeSlot; use App\Models\User; use Database\Seeders\RoleSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -18,6 +23,9 @@ class FestivalEventTest extends TestCase private User $orgAdmin; private Organisation $organisation; + private Event $festival; + private Event $subEvent; + private CrowdType $crowdType; protected function setUp(): void { @@ -28,207 +36,548 @@ class FestivalEventTest extends TestCase $this->orgAdmin = User::factory()->create(); $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); - } - // --- INDEX: top-level only --- - - public function test_index_shows_only_top_level_events(): void - { - $festival = Event::factory()->festival()->create([ + $this->festival = Event::factory()->festival()->create([ 'organisation_id' => $this->organisation->id, ]); - Event::factory()->subEvent($festival)->create(); - Event::factory()->create(['organisation_id' => $this->organisation->id]); + + $this->subEvent = Event::factory()->subEvent($this->festival)->create(); + + $this->crowdType = CrowdType::factory()->systemType('VOLUNTEER')->create([ + 'organisation_id' => $this->organisation->id, + ]); + } + + // --- Festival-level time slots --- + + public function test_can_create_time_slot_on_festival_parent(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->festival->id}/time-slots", [ + 'name' => 'Opbouw Vrijdag', + 'person_type' => 'CREW', + 'date' => '2026-07-10', + 'start_time' => '08:00', + 'end_time' => '18:00', + 'duration_hours' => 10, + ]); + + $response->assertCreated() + ->assertJson(['data' => [ + 'name' => 'Opbouw Vrijdag', + 'person_type' => 'CREW', + ]]); + + $this->assertDatabaseHas('time_slots', [ + 'event_id' => $this->festival->id, + 'name' => 'Opbouw Vrijdag', + ]); + } + + // --- Festival-level sections --- + + public function test_can_create_section_on_festival_parent(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->festival->id}/sections", [ + 'name' => 'Terreinploeg', + 'sort_order' => 1, + 'type' => 'standard', + ]); + + $response->assertCreated() + ->assertJson(['data' => [ + 'name' => 'Terreinploeg', + 'type' => 'standard', + ]]); + + $this->assertDatabaseHas('festival_sections', [ + 'event_id' => $this->festival->id, + 'name' => 'Terreinploeg', + ]); + } + + // --- Festival-level shifts --- + + public function test_can_create_shift_on_festival_level_section(): void + { + $section = FestivalSection::factory()->create([ + 'event_id' => $this->festival->id, + ]); + $timeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->festival->id, + ]); Sanctum::actingAs($this->orgAdmin); - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events"); + $response = $this->postJson("/api/v1/events/{$this->festival->id}/sections/{$section->id}/shifts", [ + 'time_slot_id' => $timeSlot->id, + 'title' => 'Terreinmedewerker', + 'slots_total' => 8, + 'slots_open_for_claiming' => 4, + ]); + + $response->assertCreated() + ->assertJson(['data' => [ + 'title' => 'Terreinmedewerker', + 'slots_total' => 8, + ]]); + + $this->assertDatabaseHas('shifts', [ + 'festival_section_id' => $section->id, + 'title' => 'Terreinmedewerker', + ]); + } + + // --- Cross-event sections --- + + public function test_cross_event_section_appears_in_sub_event_sections(): void + { + // Create a cross_event section on the festival parent + FestivalSection::factory()->crossEvent()->create([ + 'event_id' => $this->festival->id, + 'name' => 'EHBO', + 'sort_order' => 0, + ]); + + // Create a standard section on the sub-event + FestivalSection::factory()->create([ + 'event_id' => $this->subEvent->id, + 'name' => 'Bar', + 'sort_order' => 1, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->subEvent->id}/sections"); $response->assertOk(); - $this->assertCount(2, $response->json('data')); - $ids = collect($response->json('data'))->pluck('parent_event_id'); - $this->assertTrue($ids->every(fn ($v) => $v === null)); + $sectionNames = collect($response->json('data'))->pluck('name')->all(); + + // cross_event section from parent should be included + $this->assertContains('EHBO', $sectionNames); + // sub-event's own section should be included + $this->assertContains('Bar', $sectionNames); } - // --- INDEX: include_children --- + // --- Festival time slots stay separate --- - public function test_index_with_include_children_shows_nested_children(): void + public function test_festival_level_time_slots_not_included_in_sub_event_time_slots(): void { - $festival = Event::factory()->festival()->create([ - 'organisation_id' => $this->organisation->id, + // Create a time slot on the festival parent (operational) + TimeSlot::factory()->create([ + 'event_id' => $this->festival->id, + 'name' => 'Opbouw', + ]); + + // Create a time slot on the sub-event + TimeSlot::factory()->create([ + 'event_id' => $this->subEvent->id, + 'name' => 'Zaterdag Avond', ]); - Event::factory()->subEvent($festival)->count(2)->create(); Sanctum::actingAs($this->orgAdmin); - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events?include_children=true"); + $response = $this->getJson("/api/v1/events/{$this->subEvent->id}/time-slots"); $response->assertOk(); - $this->assertCount(1, $response->json('data')); - $this->assertCount(2, $response->json('data.0.children')); + + $slotNames = collect($response->json('data'))->pluck('name')->all(); + + // Sub-event's own time slot should be there + $this->assertContains('Zaterdag Avond', $slotNames); + // Festival-level operational time slot should NOT be there + $this->assertNotContains('Opbouw', $slotNames); } - // --- INDEX: type filter --- + // --- Persons at festival level --- - public function test_index_type_filter_works(): void + public function test_persons_on_festival_level(): void { - Event::factory()->festival()->create([ - 'organisation_id' => $this->organisation->id, + // Create a person on the festival parent + Person::factory()->create([ + 'event_id' => $this->festival->id, + 'crowd_type_id' => $this->crowdType->id, + 'name' => 'Jan Festivalmedewerker', + ]); + + // Create a person on the sub-event + Person::factory()->create([ + 'event_id' => $this->subEvent->id, + 'crowd_type_id' => $this->crowdType->id, + 'name' => 'Piet Dagvrijwilliger', ]); - Event::factory()->create(['organisation_id' => $this->organisation->id]); Sanctum::actingAs($this->orgAdmin); - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events?type=festival"); + // Query persons on festival level — should only return festival-level persons + $response = $this->getJson("/api/v1/events/{$this->festival->id}/persons"); $response->assertOk(); - $this->assertCount(1, $response->json('data')); - $this->assertEquals('festival', $response->json('data.0.event_type')); + + $personNames = collect($response->json('data'))->pluck('name')->all(); + $this->assertContains('Jan Festivalmedewerker', $personNames); + $this->assertNotContains('Piet Dagvrijwilliger', $personNames); } - // --- STORE: sub-event --- + // --- Cross-event section auto-redirect --- - public function test_store_with_parent_event_id_creates_sub_event(): void + public function test_create_cross_event_section_on_sub_event_redirects_to_parent(): void { - $festival = Event::factory()->festival()->create([ - 'organisation_id' => $this->organisation->id, - ]); - Sanctum::actingAs($this->orgAdmin); - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ - 'name' => 'Dag 1', - 'slug' => 'dag-1', - 'start_date' => '2026-07-01', - 'end_date' => '2026-07-01', - 'parent_event_id' => $festival->id, + $response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections", [ + 'name' => 'EHBO', + 'type' => 'cross_event', ]); $response->assertCreated(); - $this->assertEquals($festival->id, $response->json('data.parent_event_id')); - $this->assertTrue($response->json('data.is_sub_event')); + + // Section should be created on the parent festival, not on the sub-event + $this->assertDatabaseHas('festival_sections', [ + 'event_id' => $this->festival->id, + 'name' => 'EHBO', + 'type' => 'cross_event', + ]); + $this->assertDatabaseMissing('festival_sections', [ + 'event_id' => $this->subEvent->id, + 'name' => 'EHBO', + ]); + + // Response should include redirect meta + $response->assertJsonPath('meta.redirected_to_parent', true); + $response->assertJsonPath('meta.parent_event_name', $this->festival->name); } - // --- STORE: sub-event cross-org → 422 --- - - public function test_store_sub_event_of_other_org_returns_422(): void + public function test_create_cross_event_section_on_festival_parent_works_normally(): void { - $otherOrg = Organisation::factory()->create(); - $otherEvent = Event::factory()->festival()->create([ - 'organisation_id' => $otherOrg->id, + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->festival->id}/sections", [ + 'name' => 'Security', + 'type' => 'cross_event', + ]); + + $response->assertCreated(); + + $this->assertDatabaseHas('festival_sections', [ + 'event_id' => $this->festival->id, + 'name' => 'Security', + 'type' => 'cross_event', + ]); + + // No redirect meta on direct creation + $response->assertJsonMissing(['redirected_to_parent' => true]); + } + + public function test_create_cross_event_section_on_flat_event_returns_422(): void + { + $flatEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, ]); Sanctum::actingAs($this->orgAdmin); - $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ - 'name' => 'Cross-org sub', - 'slug' => 'cross-org-sub', - 'start_date' => '2026-07-01', - 'end_date' => '2026-07-01', - 'parent_event_id' => $otherEvent->id, + $response = $this->postJson("/api/v1/events/{$flatEvent->id}/sections", [ + 'name' => 'EHBO', + 'type' => 'cross_event', + ]); + + $response->assertUnprocessable(); + } + + public function test_cross_event_section_created_via_sub_event_appears_in_all_siblings(): void + { + $subEventB = Event::factory()->subEvent($this->festival)->create(); + + Sanctum::actingAs($this->orgAdmin); + + // Create cross_event via sub-event A → redirects to parent + $this->postJson("/api/v1/events/{$this->subEvent->id}/sections", [ + 'name' => 'Verkeersregelaars', + 'type' => 'cross_event', + ])->assertCreated(); + + // Should appear in sub-event B's section list + $response = $this->getJson("/api/v1/events/{$subEventB->id}/sections"); + + $response->assertOk(); + $sectionNames = collect($response->json('data'))->pluck('name')->all(); + $this->assertContains('Verkeersregelaars', $sectionNames); + } + + public function test_create_standard_section_on_sub_event_stays_on_sub_event(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections", [ + 'name' => 'Bar Schirmbar', + 'type' => 'standard', + ]); + + $response->assertCreated(); + + // Should stay on the sub-event + $this->assertDatabaseHas('festival_sections', [ + 'event_id' => $this->subEvent->id, + 'name' => 'Bar Schirmbar', + 'type' => 'standard', + ]); + } + + // --- Model helper: getAllRelevantTimeSlots --- + + public function test_get_all_relevant_time_slots_for_festival(): void + { + TimeSlot::factory()->create([ + 'event_id' => $this->festival->id, + 'name' => 'Opbouw', + ]); + + TimeSlot::factory()->create([ + 'event_id' => $this->subEvent->id, + 'name' => 'Zaterdag Avond', + ]); + + $allSlots = $this->festival->getAllRelevantTimeSlots(); + + $slotNames = $allSlots->pluck('name')->all(); + $this->assertContains('Opbouw', $slotNames); + $this->assertContains('Zaterdag Avond', $slotNames); + } + + public function test_get_all_relevant_time_slots_for_sub_event(): void + { + TimeSlot::factory()->create([ + 'event_id' => $this->festival->id, + 'name' => 'Opbouw', + ]); + + TimeSlot::factory()->create([ + 'event_id' => $this->subEvent->id, + 'name' => 'Zaterdag Avond', + ]); + + $allSlots = $this->subEvent->getAllRelevantTimeSlots(); + + $slotNames = $allSlots->pluck('name')->all(); + $this->assertContains('Opbouw', $slotNames); + $this->assertContains('Zaterdag Avond', $slotNames); + } + + public function test_get_all_relevant_time_slots_for_flat_event(): void + { + $flatEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + TimeSlot::factory()->create([ + 'event_id' => $flatEvent->id, + 'name' => 'Avond', + ]); + + $allSlots = $flatEvent->getAllRelevantTimeSlots(); + + $this->assertCount(1, $allSlots); + $this->assertEquals('Avond', $allSlots->first()->name); + } + + // --- include_parent time slots for sub-events --- + + public function test_sub_event_time_slots_include_parent_festival_time_slots(): void + { + TimeSlot::factory()->create([ + 'event_id' => $this->festival->id, + 'name' => 'Opbouw', + ]); + + TimeSlot::factory()->create([ + 'event_id' => $this->subEvent->id, + 'name' => 'Zaterdag Avond', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/events/{$this->subEvent->id}/time-slots?include_parent=true"); + + $response->assertOk(); + + $slots = collect($response->json('data')); + $slotNames = $slots->pluck('name')->all(); + + $this->assertContains('Zaterdag Avond', $slotNames); + $this->assertContains('Opbouw', $slotNames); + + // Verify source markers + $subEventSlot = $slots->firstWhere('name', 'Zaterdag Avond'); + $festivalSlot = $slots->firstWhere('name', 'Opbouw'); + $this->assertEquals('sub_event', $subEventSlot['source']); + $this->assertEquals('festival', $festivalSlot['source']); + + // Verify event_name is present + $this->assertEquals($this->festival->name, $festivalSlot['event_name']); + $this->assertEquals($this->subEvent->name, $subEventSlot['event_name']); + } + + public function test_festival_time_slots_do_not_include_sub_event_time_slots(): void + { + TimeSlot::factory()->create([ + 'event_id' => $this->festival->id, + 'name' => 'Opbouw', + ]); + + TimeSlot::factory()->create([ + 'event_id' => $this->subEvent->id, + 'name' => 'Zaterdag Avond', + ]); + + Sanctum::actingAs($this->orgAdmin); + + // Even with include_parent, festival parent should only return its own TS + $response = $this->getJson("/api/v1/events/{$this->festival->id}/time-slots?include_parent=true"); + + $response->assertOk(); + + $slotNames = collect($response->json('data'))->pluck('name')->all(); + $this->assertContains('Opbouw', $slotNames); + $this->assertNotContains('Zaterdag Avond', $slotNames); + } + + public function test_create_shift_on_local_section_with_festival_time_slot(): void + { + $section = FestivalSection::factory()->create([ + 'event_id' => $this->subEvent->id, + ]); + + // Time slot belongs to the parent festival + $festivalTimeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->festival->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section->id}/shifts", [ + 'time_slot_id' => $festivalTimeSlot->id, + 'title' => 'Opbouwshift', + 'slots_total' => 4, + 'slots_open_for_claiming' => 0, + ]); + + $response->assertCreated(); + + $this->assertDatabaseHas('shifts', [ + 'festival_section_id' => $section->id, + 'time_slot_id' => $festivalTimeSlot->id, + 'title' => 'Opbouwshift', + ]); + } + + public function test_create_shift_on_local_section_with_other_event_time_slot_returns_422(): void + { + $section = FestivalSection::factory()->create([ + 'event_id' => $this->subEvent->id, + ]); + + // Time slot from a completely unrelated event + $otherOrg = Organisation::factory()->create(); + $otherEvent = Event::factory()->create([ + 'organisation_id' => $otherOrg->id, + ]); + $otherTimeSlot = TimeSlot::factory()->create([ + 'event_id' => $otherEvent->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section->id}/shifts", [ + 'time_slot_id' => $otherTimeSlot->id, + 'title' => 'Illegale shift', + 'slots_total' => 1, + 'slots_open_for_claiming' => 0, + ]); + + $response->assertUnprocessable(); + } + + public function test_flat_event_time_slots_unchanged(): void + { + $flatEvent = Event::factory()->create([ + 'organisation_id' => $this->organisation->id, + ]); + + TimeSlot::factory()->create([ + 'event_id' => $flatEvent->id, + 'name' => 'Avond', + ]); + + Sanctum::actingAs($this->orgAdmin); + + // include_parent has no effect on flat events + $response = $this->getJson("/api/v1/events/{$flatEvent->id}/time-slots?include_parent=true"); + + $response->assertOk(); + + $slots = collect($response->json('data')); + $this->assertCount(1, $slots); + $this->assertEquals('Avond', $slots->first()['name']); + + // No source marker on flat events + $this->assertNull($slots->first()['source']); + } + + public function test_conflict_detection_across_event_levels(): void + { + // Section on sub-event + $section = FestivalSection::factory()->create([ + 'event_id' => $this->subEvent->id, + ]); + + // Time slot on festival parent + $festivalTimeSlot = TimeSlot::factory()->create([ + 'event_id' => $this->festival->id, + ]); + + // Create a shift on the sub-event section using festival time slot + $shift = Shift::factory()->create([ + 'festival_section_id' => $section->id, + 'time_slot_id' => $festivalTimeSlot->id, + 'slots_total' => 5, + 'allow_overlap' => false, + ]); + + // Create a person + $person = Person::factory()->create([ + 'event_id' => $this->subEvent->id, + 'crowd_type_id' => $this->crowdType->id, + ]); + + Sanctum::actingAs($this->orgAdmin); + + // Assign person to the shift + $this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section->id}/shifts/{$shift->id}/assign", [ + 'person_id' => $person->id, + ])->assertCreated(); + + // Create another section and shift with the same festival time slot + $section2 = FestivalSection::factory()->create([ + 'event_id' => $this->subEvent->id, + ]); + + $shift2 = Shift::factory()->create([ + 'festival_section_id' => $section2->id, + 'time_slot_id' => $festivalTimeSlot->id, + 'slots_total' => 5, + 'allow_overlap' => false, + ]); + + // Assigning the same person to same time_slot should fail + $response = $this->postJson("/api/v1/events/{$this->subEvent->id}/sections/{$section2->id}/shifts/{$shift2->id}/assign", [ + 'person_id' => $person->id, ]); $response->assertStatus(422); } - - // --- CHILDREN endpoint --- - - public function test_children_endpoint_returns_sub_events(): void - { - $festival = Event::factory()->festival()->create([ - 'organisation_id' => $this->organisation->id, - ]); - Event::factory()->subEvent($festival)->count(3)->create(); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}/children"); - - $response->assertOk(); - $this->assertCount(3, $response->json('data')); - } - - public function test_children_of_flat_event_returns_empty_list(): void - { - $event = Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}/children"); - - $response->assertOk(); - $this->assertCount(0, $response->json('data')); - } - - // --- SHOW: festival with children --- - - public function test_show_festival_contains_children(): void - { - $festival = Event::factory()->festival()->create([ - 'organisation_id' => $this->organisation->id, - ]); - Event::factory()->subEvent($festival)->count(2)->create(); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$festival->id}"); - - $response->assertOk(); - $this->assertCount(2, $response->json('data.children')); - $this->assertEquals(2, $response->json('data.children_count')); - } - - // --- SHOW: sub-event with parent --- - - public function test_show_sub_event_contains_parent(): void - { - $festival = Event::factory()->festival()->create([ - 'organisation_id' => $this->organisation->id, - ]); - $subEvent = Event::factory()->subEvent($festival)->create(); - - Sanctum::actingAs($this->orgAdmin); - - $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$subEvent->id}"); - - $response->assertOk(); - $this->assertEquals($festival->id, $response->json('data.parent.id')); - } - - // --- Helper methods --- - - public function test_is_festival_helper(): void - { - $festival = Event::factory()->festival()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - $this->assertTrue($festival->isFestival()); - $this->assertFalse($festival->isSubEvent()); - } - - public function test_is_sub_event_helper(): void - { - $festival = Event::factory()->festival()->create([ - 'organisation_id' => $this->organisation->id, - ]); - $subEvent = Event::factory()->subEvent($festival)->create(); - - $this->assertTrue($subEvent->isSubEvent()); - $this->assertFalse($subEvent->isFestival()); - } - - public function test_is_flat_event_helper(): void - { - $event = Event::factory()->create([ - 'organisation_id' => $this->organisation->id, - ]); - - $this->assertTrue($event->isFlatEvent()); - $this->assertFalse($event->isFestival()); - $this->assertFalse($event->isSubEvent()); - } } diff --git a/api/tests/Feature/FestivalSection/FestivalSectionTest.php b/api/tests/Feature/FestivalSection/FestivalSectionTest.php index b3089ad..4a6336a 100644 --- a/api/tests/Feature/FestivalSection/FestivalSectionTest.php +++ b/api/tests/Feature/FestivalSection/FestivalSectionTest.php @@ -134,6 +134,96 @@ class FestivalSectionTest extends TestCase $this->assertSoftDeleted('festival_sections', ['id' => $section->id]); } + public function test_update_cross_org_returns_403(): void + { + $section = FestivalSection::factory()->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->outsider); + + $response = $this->putJson("/api/v1/events/{$this->event->id}/sections/{$section->id}", [ + 'name' => 'Hacked', + ]); + + $response->assertForbidden(); + } + + public function test_destroy_cross_org_returns_403(): void + { + $section = FestivalSection::factory()->create(['event_id' => $this->event->id]); + + Sanctum::actingAs($this->outsider); + + $response = $this->deleteJson("/api/v1/events/{$this->event->id}/sections/{$section->id}"); + + $response->assertForbidden(); + } + + public function test_store_section_with_category_and_icon(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/sections", [ + 'name' => 'Tapkraan', + 'category' => 'Bar', + 'icon' => 'tabler-beer', + 'sort_order' => 0, + ]); + + $response->assertCreated() + ->assertJson(['data' => [ + 'name' => 'Tapkraan', + 'category' => 'Bar', + 'icon' => 'tabler-beer', + ]]); + + $this->assertDatabaseHas('festival_sections', [ + 'event_id' => $this->event->id, + 'name' => 'Tapkraan', + 'category' => 'Bar', + 'icon' => 'tabler-beer', + ]); + } + + public function test_section_categories_endpoint(): void + { + FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'category' => 'Bar', + ]); + FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'category' => 'Podium', + ]); + // Duplicate category should not appear twice + FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'category' => 'Bar', + ]); + // Null category should not appear + FestivalSection::factory()->create([ + 'event_id' => $this->event->id, + 'category' => null, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/section-categories"); + + $response->assertOk() + ->assertJson(['data' => ['Bar', 'Podium']]); + + $this->assertCount(2, $response->json('data')); + } + + public function test_section_categories_cross_org_returns_403(): void + { + Sanctum::actingAs($this->outsider); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/section-categories"); + + $response->assertForbidden(); + } + public function test_reorder_updates_sort_order(): void { $sectionA = FestivalSection::factory()->create([ @@ -148,20 +238,17 @@ class FestivalSectionTest extends TestCase Sanctum::actingAs($this->orgAdmin); $response = $this->postJson("/api/v1/events/{$this->event->id}/sections/reorder", [ - 'sections' => [ - ['id' => $sectionA->id, 'sort_order' => 2], - ['id' => $sectionB->id, 'sort_order' => 1], - ], + 'sections' => [$sectionB->id, $sectionA->id], ]); $response->assertOk(); $this->assertDatabaseHas('festival_sections', [ - 'id' => $sectionA->id, - 'sort_order' => 2, + 'id' => $sectionB->id, + 'sort_order' => 0, ]); $this->assertDatabaseHas('festival_sections', [ - 'id' => $sectionB->id, + 'id' => $sectionA->id, 'sort_order' => 1, ]); } diff --git a/api/tests/Feature/Shift/ShiftTest.php b/api/tests/Feature/Shift/ShiftTest.php index ebcb137..61c6b3c 100644 --- a/api/tests/Feature/Shift/ShiftTest.php +++ b/api/tests/Feature/Shift/ShiftTest.php @@ -125,6 +125,50 @@ class ShiftTest extends TestCase $this->assertSoftDeleted('shifts', ['id' => $shift->id]); } + public function test_store_missing_time_slot_id_returns_422(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts", [ + 'title' => 'Tapper', + 'slots_total' => 4, + 'slots_open_for_claiming' => 0, + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors('time_slot_id'); + } + + public function test_update_cross_org_returns_403(): void + { + $shift = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->outsider); + + $response = $this->putJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}", [ + 'title' => 'Hacked', + ]); + + $response->assertForbidden(); + } + + public function test_destroy_cross_org_returns_403(): void + { + $shift = Shift::factory()->create([ + 'festival_section_id' => $this->section->id, + 'time_slot_id' => $this->timeSlot->id, + ]); + + Sanctum::actingAs($this->outsider); + + $response = $this->deleteJson("/api/v1/events/{$this->event->id}/sections/{$this->section->id}/shifts/{$shift->id}"); + + $response->assertForbidden(); + } + public function test_assign_creates_shift_assignment(): void { $shift = Shift::factory()->create([ diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index 42ec467..4dae24b 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -28,6 +28,7 @@ declare module 'vue' { CardStatisticsHorizontal: typeof import('./src/@core/components/cards/CardStatisticsHorizontal.vue')['default'] CardStatisticsVertical: typeof import('./src/@core/components/cards/CardStatisticsVertical.vue')['default'] CardStatisticsVerticalSimple: typeof import('./src/@core/components/CardStatisticsVerticalSimple.vue')['default'] + CompanyDialog: typeof import('./src/components/organisation/CompanyDialog.vue')['default'] ConfirmDialog: typeof import('./src/components/dialogs/ConfirmDialog.vue')['default'] CreateAppDialog: typeof import('./src/components/dialogs/CreateAppDialog.vue')['default'] CreateEventDialog: typeof import('./src/components/events/CreateEventDialog.vue')['default'] @@ -36,6 +37,7 @@ declare module 'vue' { CreateShiftDialog: typeof import('./src/components/sections/CreateShiftDialog.vue')['default'] CreateSubEventDialog: typeof import('./src/components/events/CreateSubEventDialog.vue')['default'] CreateTimeSlotDialog: typeof import('./src/components/sections/CreateTimeSlotDialog.vue')['default'] + CrowdTypesManager: typeof import('./src/components/organisations/CrowdTypesManager.vue')['default'] CustomCheckboxes: typeof import('./src/@core/components/app-form-elements/CustomCheckboxes.vue')['default'] CustomCheckboxesWithIcon: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithIcon.vue')['default'] CustomCheckboxesWithImage: typeof import('./src/@core/components/app-form-elements/CustomCheckboxesWithImage.vue')['default'] @@ -43,12 +45,14 @@ declare module 'vue' { CustomRadios: typeof import('./src/@core/components/app-form-elements/CustomRadios.vue')['default'] CustomRadiosWithIcon: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithIcon.vue')['default'] CustomRadiosWithImage: typeof import('./src/@core/components/app-form-elements/CustomRadiosWithImage.vue')['default'] + DeleteSubEventDialog: typeof import('./src/components/events/DeleteSubEventDialog.vue')['default'] DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default'] DropZone: typeof import('./src/@core/components/DropZone.vue')['default'] EditEventDialog: typeof import('./src/components/events/EditEventDialog.vue')['default'] EditMemberRoleDialog: typeof import('./src/components/members/EditMemberRoleDialog.vue')['default'] EditOrganisationDialog: typeof import('./src/components/organisations/EditOrganisationDialog.vue')['default'] EditPersonDialog: typeof import('./src/components/persons/EditPersonDialog.vue')['default'] + EditSectionDialog: typeof import('./src/components/sections/EditSectionDialog.vue')['default'] EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default'] ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default'] EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default'] @@ -63,6 +67,7 @@ declare module 'vue' { RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] ScrollToTop: typeof import('./src/@core/components/ScrollToTop.vue')['default'] + SectionsShiftsPanel: typeof import('./src/components/sections/SectionsShiftsPanel.vue')['default'] ShareProjectDialog: typeof import('./src/components/dialogs/ShareProjectDialog.vue')['default'] Shortcuts: typeof import('./src/@core/components/Shortcuts.vue')['default'] TablePagination: typeof import('./src/@core/components/TablePagination.vue')['default'] diff --git a/apps/app/package.json b/apps/app/package.json index a4f5e98..ca1d8f4 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -17,7 +17,6 @@ "@casl/ability": "6.7.3", "@casl/vue": "2.2.2", "@floating-ui/dom": "1.6.8", - "@formkit/drag-and-drop": "0.1.6", "@sindresorhus/is": "7.1.0", "@tanstack/vue-query": "^5.95.2", "@tiptap/extension-highlight": "^2.27.1", @@ -55,6 +54,7 @@ "vue-router": "4.5.1", "vue3-apexcharts": "1.5.3", "vue3-perfect-scrollbar": "2.0.0", + "vuedraggable": "^4.1.0", "vuetify": "3.10.8", "webfontloader": "1.6.28", "zod": "3" diff --git a/apps/app/pnpm-lock.yaml b/apps/app/pnpm-lock.yaml index 3f1dec4..2cf8a51 100644 --- a/apps/app/pnpm-lock.yaml +++ b/apps/app/pnpm-lock.yaml @@ -21,9 +21,6 @@ importers: '@floating-ui/dom': specifier: 1.6.8 version: 1.6.8 - '@formkit/drag-and-drop': - specifier: 0.1.6 - version: 0.1.6 '@sindresorhus/is': specifier: 7.1.0 version: 7.1.0 @@ -135,6 +132,9 @@ importers: vue3-perfect-scrollbar: specifier: 2.0.0 version: 2.0.0(vue@3.5.22(typescript@5.9.3)) + vuedraggable: + specifier: ^4.1.0 + version: 4.1.0(vue@3.5.22(typescript@5.9.3)) vuetify: specifier: 3.10.8 version: 3.10.8(typescript@5.9.3)(vite-plugin-vuetify@2.1.2)(vue@3.5.22(typescript@5.9.3)) @@ -745,9 +745,6 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - '@formkit/drag-and-drop@0.1.6': - resolution: {integrity: sha512-wZyxvk7WTbQ12q8ZGvLoYner1ktBOUf+lCblJT3P0LyqpjGCKTfQMKJtwToKQzJgTbhvow4LBu+yP92Mux321w==} - '@fullcalendar/core@6.1.19': resolution: {integrity: sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==} @@ -4091,6 +4088,9 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + sortablejs@1.14.0: + resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4741,6 +4741,11 @@ packages: typescript: optional: true + vuedraggable@4.1.0: + resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==} + peerDependencies: + vue: ^3.0.1 + vuetify@3.10.8: resolution: {integrity: sha512-TV1bx8mUjOPbhmEsamm38/CBcVe5DHYepOZGE6aQJ2uxvg96B4k+QHgIJcD5uKVfKmxKkJRtHdEXyq6JP9wBtg==} peerDependencies: @@ -5338,8 +5343,6 @@ snapshots: '@floating-ui/utils@0.2.10': {} - '@formkit/drag-and-drop@0.1.6': {} - '@fullcalendar/core@6.1.19': dependencies: preact: 10.12.1 @@ -9149,6 +9152,8 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + sortablejs@1.14.0: {} + source-map-js@1.2.1: {} source-map@0.6.1: @@ -9933,6 +9938,11 @@ snapshots: optionalDependencies: typescript: 5.9.3 + vuedraggable@4.1.0(vue@3.5.22(typescript@5.9.3)): + dependencies: + sortablejs: 1.14.0 + vue: 3.5.22(typescript@5.9.3) + vuetify@3.10.8(typescript@5.9.3)(vite-plugin-vuetify@2.1.2)(vue@3.5.22(typescript@5.9.3)): dependencies: vue: 3.5.22(typescript@5.9.3) diff --git a/apps/app/src/components/events/CreateEventDialog.vue b/apps/app/src/components/events/CreateEventDialog.vue index facf2dc..223aad0 100644 --- a/apps/app/src/components/events/CreateEventDialog.vue +++ b/apps/app/src/components/events/CreateEventDialog.vue @@ -41,6 +41,7 @@ const eventTypeOptions: { title: string; value: EventTypeEnum }[] = [ const subEventLabelOptions = [ 'Dag', + 'Evenement', 'Programmaonderdeel', 'Editie', 'Locatie', @@ -134,11 +135,11 @@ function onSubmit() { @after-leave="resetForm" > - - + + @@ -158,6 +160,7 @@ function onSubmit() { :error-messages="errors.slug" hint="Wordt gebruikt in de URL" persistent-hint + autocomplete="one-time-code" /> @@ -203,6 +206,7 @@ function onSubmit() { :error-messages="errors.sub_event_label" hint="Kies uit de lijst of typ een eigen naam" persistent-hint + autocomplete="one-time-code" /> @@ -214,6 +218,7 @@ function onSubmit() { :error-messages="errors.event_type_label" maxlength="50" counter + autocomplete="one-time-code" /> @@ -250,24 +255,24 @@ function onSubmit() { /> - - - - - - Annuleren - - - Aanmaken - - + + + + + Annuleren + + + Aanmaken + + + diff --git a/apps/app/src/components/events/CreateSubEventDialog.vue b/apps/app/src/components/events/CreateSubEventDialog.vue index 2e90a09..80e1af8 100644 --- a/apps/app/src/components/events/CreateSubEventDialog.vue +++ b/apps/app/src/components/events/CreateSubEventDialog.vue @@ -12,6 +12,7 @@ const props = defineProps<{ const modelValue = defineModel({ required: true }) const orgIdRef = computed(() => props.orgId) +const parentEventIdRef = computed(() => props.parentEvent.id) const subEventLabel = computed(() => props.parentEvent.sub_event_label ?? 'Programmaonderdeel', @@ -29,7 +30,7 @@ const errors = ref>({}) const refVForm = ref() const showSuccess = ref(false) -const { mutate: createSubEvent, isPending } = useCreateSubEvent(orgIdRef) +const { mutate: createSubEvent, isPending } = useCreateSubEvent(orgIdRef, parentEventIdRef) const statusOptions: { title: string; value: EventStatus }[] = [ { title: 'Draft', value: 'draft' }, @@ -72,7 +73,6 @@ function onSubmit() { onSuccess: () => { modelValue.value = false showSuccess.value = true - resetForm() }, onError: (err: any) => { const data = err.response?.data @@ -97,15 +97,15 @@ function onSubmit() { @after-leave="resetForm" > - -
- Onderdeel van: {{ parentEvent.name }} -
+ + +
+ Onderdeel van: {{ parentEvent.name }} +
- @@ -159,24 +160,24 @@ function onSubmit() { /> - -
- - - - Annuleren - - - Toevoegen - - +
+ + + + Annuleren + + + Toevoegen + + +
diff --git a/apps/app/src/components/events/DeleteSubEventDialog.vue b/apps/app/src/components/events/DeleteSubEventDialog.vue new file mode 100644 index 0000000..7f49de8 --- /dev/null +++ b/apps/app/src/components/events/DeleteSubEventDialog.vue @@ -0,0 +1,65 @@ + + + diff --git a/apps/app/src/components/events/EditEventDialog.vue b/apps/app/src/components/events/EditEventDialog.vue index 0936461..5a6d1bd 100644 --- a/apps/app/src/components/events/EditEventDialog.vue +++ b/apps/app/src/components/events/EditEventDialog.vue @@ -21,6 +21,7 @@ const form = ref({ end_date: '', timezone: '', status: '' as EventStatus, + sub_event_label: '' as string | null, }) const errors = ref>({}) @@ -29,6 +30,19 @@ const showSuccess = ref(false) const { mutate: updateEvent, isPending } = useUpdateEvent(orgIdRef, eventIdRef) +const isFestivalOrSeries = computed(() => + props.event.event_type === 'festival' || props.event.event_type === 'series', +) + +const subEventLabelOptions = [ + 'Dag', + 'Evenement', + 'Programmaonderdeel', + 'Editie', + 'Locatie', + 'Ronde', +] + const timezoneOptions = [ { title: 'Europe/Amsterdam', value: 'Europe/Amsterdam' }, { title: 'Europe/London', value: 'Europe/London' }, @@ -62,6 +76,7 @@ watch(() => props.event, (ev) => { end_date: ev.end_date, timezone: ev.timezone, status: ev.status, + sub_event_label: ev.sub_event_label ?? '', } }, { immediate: true }) @@ -71,7 +86,14 @@ function onSubmit() { errors.value = {} - updateEvent(form.value, { + const payload = { + ...form.value, + sub_event_label: isFestivalOrSeries.value && form.value.sub_event_label + ? form.value.sub_event_label + : null, + } + + updateEvent(payload, { onSuccess: () => { modelValue.value = false showSuccess.value = true @@ -98,11 +120,11 @@ function onSubmit() { max-width="550" > - - + + @@ -119,6 +142,21 @@ function onSubmit() { label="Slug" :rules="[requiredValidator]" :error-messages="errors.slug" + autocomplete="one-time-code" + /> + + + - - - - - - Annuleren - - - Opslaan - - + + + + + Annuleren + + + Opslaan + + + diff --git a/apps/app/src/components/events/EventTabsNav.vue b/apps/app/src/components/events/EventTabsNav.vue index 0589877..9516fd7 100644 --- a/apps/app/src/components/events/EventTabsNav.vue +++ b/apps/app/src/components/events/EventTabsNav.vue @@ -1,16 +1,11 @@ + + + + diff --git a/apps/app/src/composables/api/useEvents.ts b/apps/app/src/composables/api/useEvents.ts index a2a9e33..def6cab 100644 --- a/apps/app/src/composables/api/useEvents.ts +++ b/apps/app/src/composables/api/useEvents.ts @@ -81,7 +81,7 @@ export function useCreateEvent(orgId: Ref) { }) } -export function useCreateSubEvent(orgId: Ref) { +export function useCreateSubEvent(orgId: Ref, parentEventId: Ref) { const queryClient = useQueryClient() return useMutation({ @@ -94,6 +94,21 @@ export function useCreateSubEvent(orgId: Ref) { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['events', orgId.value] }) + queryClient.invalidateQueries({ queryKey: ['event-children', parentEventId.value] }) + }, + }) +} + +export function useDeleteEvent(orgId: Ref, parentEventId: Ref) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (eventId: string) => { + await apiClient.delete(`/organisations/${orgId.value}/events/${eventId}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['events', orgId.value] }) + queryClient.invalidateQueries({ queryKey: ['event-children', parentEventId.value] }) }, }) } diff --git a/apps/app/src/composables/api/useSections.ts b/apps/app/src/composables/api/useSections.ts index e7942cf..792be2f 100644 --- a/apps/app/src/composables/api/useSections.ts +++ b/apps/app/src/composables/api/useSections.ts @@ -13,6 +13,20 @@ interface PaginatedResponse { data: T[] } +export function useSectionCategories(orgId: Ref) { + return useQuery({ + queryKey: ['section-categories', orgId], + queryFn: async () => { + const { data } = await apiClient.get<{ data: string[] }>( + `/organisations/${orgId.value}/section-categories`, + ) + + return data.data + }, + enabled: () => !!orgId.value, + }) +} + export function useSectionList(eventId: Ref) { return useQuery({ queryKey: ['sections', eventId], @@ -27,17 +41,27 @@ export function useSectionList(eventId: Ref) { }) } +export interface CreateSectionResult { + section: FestivalSection + redirectedToParent: boolean + parentEventName?: string +} + export function useCreateSection(eventId: Ref) { const queryClient = useQueryClient() return useMutation({ - mutationFn: async (payload: CreateSectionPayload) => { - const { data } = await apiClient.post>( + mutationFn: async (payload: CreateSectionPayload): Promise => { + const { data } = await apiClient.post & { meta?: { redirected_to_parent?: boolean; parent_event_name?: string } }>( `/events/${eventId.value}/sections`, payload, ) - return data.data + return { + section: data.data, + redirectedToParent: data.meta?.redirected_to_parent ?? false, + parentEventName: data.meta?.parent_event_name, + } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] }) @@ -58,7 +82,8 @@ export function useUpdateSection(eventId: Ref) { return data.data }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] }) + // Invalidate all section lists — a cross_event section update affects multiple events + queryClient.invalidateQueries({ queryKey: ['sections'] }) }, }) } @@ -78,15 +103,32 @@ export function useDeleteSection(eventId: Ref) { export function useReorderSections(eventId: Ref) { const queryClient = useQueryClient() + let previousSections: FestivalSection[] | undefined return useMutation({ mutationFn: async (orderedIds: string[]) => { await apiClient.post(`/events/${eventId.value}/sections/reorder`, { - ordered_ids: orderedIds, + sections: orderedIds, }) }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['sections', eventId.value] }) + onMutate: async (orderedIds) => { + await queryClient.cancelQueries({ queryKey: ['sections', eventId.value] }) + previousSections = queryClient.getQueryData(['sections', eventId.value]) + + // Optimistically update query cache so watch doesn't snap back + if (previousSections) { + const byId = new Map(previousSections.map(s => [s.id, s])) + const reordered = orderedIds + .map(id => byId.get(id)) + .filter((s): s is FestivalSection => !!s) + queryClient.setQueryData(['sections', eventId.value], reordered) + } }, + onError: () => { + if (previousSections) { + queryClient.setQueryData(['sections', eventId.value], previousSections) + } + }, + // No onSuccess invalidation — query cache and v-model are already in sync }) } diff --git a/apps/app/src/pages/events/[id]/index.vue b/apps/app/src/pages/events/[id]/index.vue index 33df9d9..019fafd 100644 --- a/apps/app/src/pages/events/[id]/index.vue +++ b/apps/app/src/pages/events/[id]/index.vue @@ -1,7 +1,7 @@ diff --git a/apps/app/src/pages/events/[id]/programmaonderdelen/index.vue b/apps/app/src/pages/events/[id]/programmaonderdelen/index.vue new file mode 100644 index 0000000..40cfe18 --- /dev/null +++ b/apps/app/src/pages/events/[id]/programmaonderdelen/index.vue @@ -0,0 +1,180 @@ + + + diff --git a/apps/app/src/pages/events/[id]/sections/index.vue b/apps/app/src/pages/events/[id]/sections/index.vue index f31cccb..f5b27c7 100644 --- a/apps/app/src/pages/events/[id]/sections/index.vue +++ b/apps/app/src/pages/events/[id]/sections/index.vue @@ -1,13 +1,8 @@ diff --git a/apps/app/src/pages/events/index.vue b/apps/app/src/pages/events/index.vue index d54eedf..3f5fa71 100644 --- a/apps/app/src/pages/events/index.vue +++ b/apps/app/src/pages/events/index.vue @@ -1,5 +1,6 @@