refactor: align codebase with EventCrew domain and trim legacy band stack
- Update API: events, users, policies, routes, resources, migrations - Remove deprecated models/resources (customers, setlists, invitations, etc.) - Refresh admin app and docs; remove apps/band Made-with: Cursor
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
APP_NAME="Band Management"
|
||||
APP_NAME="Event Crew"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
@@ -20,8 +20,8 @@ LOG_LEVEL=debug
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=band_management
|
||||
DB_USERNAME=band_management
|
||||
DB_DATABASE=event_crew
|
||||
DB_USERNAME=event_crew
|
||||
DB_PASSWORD=secret
|
||||
|
||||
SESSION_DRIVER=database
|
||||
@@ -47,7 +47,7 @@ MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="noreply@bandmanagement.nl"
|
||||
MAIL_FROM_ADDRESS="noreply@eventcrew.nl"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# CORS - Frontend SPAs
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum EventStatus: string
|
||||
{
|
||||
case Draft = 'draft';
|
||||
case Pending = 'pending';
|
||||
case Confirmed = 'confirmed';
|
||||
case Completed = 'completed';
|
||||
case Cancelled = 'cancelled';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'Draft',
|
||||
self::Pending => 'Pending Confirmation',
|
||||
self::Confirmed => 'Confirmed',
|
||||
self::Completed => 'Completed',
|
||||
self::Cancelled => 'Cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Draft => 'secondary',
|
||||
self::Pending => 'warning',
|
||||
self::Confirmed => 'success',
|
||||
self::Completed => 'info',
|
||||
self::Cancelled => 'error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum EventVisibility: string
|
||||
{
|
||||
case Private = 'private';
|
||||
case Members = 'members';
|
||||
case Public = 'public';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Private => 'Private',
|
||||
self::Members => 'Members Only',
|
||||
self::Public => 'Public',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum RsvpStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Available = 'available';
|
||||
case Unavailable = 'unavailable';
|
||||
case Tentative = 'tentative';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Pending',
|
||||
self::Available => 'Available',
|
||||
self::Unavailable => 'Unavailable',
|
||||
self::Tentative => 'Tentative',
|
||||
};
|
||||
}
|
||||
|
||||
public function color(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'secondary',
|
||||
self::Available => 'success',
|
||||
self::Unavailable => 'error',
|
||||
self::Tentative => 'warning',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\LoginRequest;
|
||||
use App\Http\Requests\Api\V1\RegisterRequest;
|
||||
use App\Http\Resources\Api\V1\UserResource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
final class AuthController extends Controller
|
||||
{
|
||||
public function login(LoginRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->only('email', 'password');
|
||||
|
||||
if (!Auth::attempt($credentials)) {
|
||||
return $this->unauthorized('Invalid credentials');
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
if (!$user->isActive()) {
|
||||
Auth::logout();
|
||||
return $this->forbidden('Your account is inactive');
|
||||
}
|
||||
|
||||
$token = $user->createToken('auth-token')->plainTextToken;
|
||||
|
||||
return $this->success([
|
||||
'user' => new UserResource($user),
|
||||
'token' => $token,
|
||||
], 'Login successful');
|
||||
}
|
||||
|
||||
public function register(RegisterRequest $request): JsonResponse
|
||||
{
|
||||
$user = User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => Hash::make($request->password),
|
||||
'type' => 'member',
|
||||
'role' => 'member',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$token = $user->createToken('auth-token')->plainTextToken;
|
||||
|
||||
return $this->created([
|
||||
'user' => new UserResource($user),
|
||||
'token' => $token,
|
||||
], 'Registration successful');
|
||||
}
|
||||
|
||||
public function user(Request $request): JsonResponse
|
||||
{
|
||||
return $this->success(
|
||||
new UserResource($request->user())
|
||||
);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return $this->success(null, 'Logged out successfully');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,151 +4,55 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\RsvpStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\InviteToEventRequest;
|
||||
use App\Http\Requests\Api\V1\RsvpEventRequest;
|
||||
use App\Http\Requests\Api\V1\StoreEventRequest;
|
||||
use App\Http\Requests\Api\V1\UpdateEventRequest;
|
||||
use App\Http\Resources\Api\V1\EventCollection;
|
||||
use App\Http\Resources\Api\V1\EventInvitationResource;
|
||||
use App\Http\Resources\Api\V1\EventResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventInvitation;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
final class EventController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of events.
|
||||
*/
|
||||
public function index(): EventCollection
|
||||
public function index(Organisation $organisation): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('viewAny', Event::class);
|
||||
Gate::authorize('viewAny', [Event::class, $organisation]);
|
||||
|
||||
$events = Event::query()
|
||||
->with(['location', 'customer'])
|
||||
->latest('event_date')
|
||||
$events = $organisation->events()
|
||||
->latest('start_date')
|
||||
->paginate();
|
||||
|
||||
return new EventCollection($events);
|
||||
return EventResource::collection($events);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created event.
|
||||
*/
|
||||
public function store(StoreEventRequest $request): JsonResponse
|
||||
{
|
||||
Gate::authorize('create', Event::class);
|
||||
|
||||
$data = $request->validated();
|
||||
$data['created_by'] = auth()->id();
|
||||
$data['currency'] = $data['currency'] ?? 'EUR';
|
||||
|
||||
$event = Event::create($data);
|
||||
|
||||
return $this->created(
|
||||
new EventResource($event->load(['location', 'customer'])),
|
||||
'Event created successfully'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified event.
|
||||
*/
|
||||
public function show(Event $event): EventResource
|
||||
public function show(Organisation $organisation, Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('view', $event);
|
||||
|
||||
return new EventResource(
|
||||
$event->load(['location', 'customer', 'setlist.items.musicNumber', 'invitations.user', 'creator'])
|
||||
);
|
||||
abort_unless($event->organisation_id === $organisation->id, 404);
|
||||
|
||||
return $this->success(new EventResource($event->load('organisation')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified event.
|
||||
*/
|
||||
public function update(UpdateEventRequest $request, Event $event): JsonResponse
|
||||
public function store(StoreEventRequest $request, Organisation $organisation): JsonResponse
|
||||
{
|
||||
Gate::authorize('create', [Event::class, $organisation]);
|
||||
|
||||
$event = $organisation->events()->create($request->validated());
|
||||
|
||||
return $this->created(new EventResource($event));
|
||||
}
|
||||
|
||||
public function update(UpdateEventRequest $request, Organisation $organisation, Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('update', $event);
|
||||
|
||||
abort_unless($event->organisation_id === $organisation->id, 404);
|
||||
|
||||
$event->update($request->validated());
|
||||
|
||||
return $this->success(
|
||||
new EventResource($event->load(['location', 'customer'])),
|
||||
'Event updated successfully'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified event.
|
||||
*/
|
||||
public function destroy(Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('delete', $event);
|
||||
|
||||
$event->delete();
|
||||
|
||||
return $this->success(null, 'Event deleted successfully');
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite members to an event.
|
||||
*/
|
||||
public function invite(InviteToEventRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('invite', $event);
|
||||
|
||||
$userIds = $request->validated()['user_ids'];
|
||||
$invitedCount = 0;
|
||||
|
||||
foreach ($userIds as $userId) {
|
||||
// Skip if already invited
|
||||
if ($event->invitations()->where('user_id', $userId)->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$event->invitations()->create([
|
||||
'user_id' => $userId,
|
||||
'rsvp_status' => RsvpStatus::Pending,
|
||||
'invited_at' => now(),
|
||||
]);
|
||||
|
||||
$invitedCount++;
|
||||
}
|
||||
|
||||
return $this->success(
|
||||
EventInvitationResource::collection(
|
||||
$event->invitations()->with('user')->get()
|
||||
),
|
||||
"{$invitedCount} member(s) invited successfully"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to an event invitation (RSVP).
|
||||
*/
|
||||
public function rsvp(RsvpEventRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
Gate::authorize('rsvp', $event);
|
||||
|
||||
$invitation = EventInvitation::where('event_id', $event->id)
|
||||
->where('user_id', auth()->id())
|
||||
->firstOrFail();
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
$invitation->update([
|
||||
'rsvp_status' => $data['status'],
|
||||
'rsvp_note' => $data['note'] ?? null,
|
||||
'rsvp_responded_at' => now(),
|
||||
]);
|
||||
|
||||
return $this->success(
|
||||
new EventInvitationResource($invitation->load('user')),
|
||||
'RSVP updated successfully'
|
||||
);
|
||||
return $this->success(new EventResource($event->fresh()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
api/app/Http/Controllers/Api/V1/LoginController.php
Normal file
29
api/app/Http/Controllers/Api/V1/LoginController.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\LoginRequest;
|
||||
use App\Http\Resources\Api\V1\UserResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
final class LoginController extends Controller
|
||||
{
|
||||
public function __invoke(LoginRequest $request): JsonResponse
|
||||
{
|
||||
if (!Auth::attempt($request->only('email', 'password'))) {
|
||||
return $this->unauthorized('Invalid credentials');
|
||||
}
|
||||
|
||||
$user = Auth::user()->load(['organisations', 'roles']);
|
||||
$token = $user->createToken('auth-token')->plainTextToken;
|
||||
|
||||
return $this->success([
|
||||
'user' => new UserResource($user),
|
||||
'token' => $token,
|
||||
], 'Login successful');
|
||||
}
|
||||
}
|
||||
19
api/app/Http/Controllers/Api/V1/LogoutController.php
Normal file
19
api/app/Http/Controllers/Api/V1/LogoutController.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class LogoutController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return $this->success(null, 'Logged out successfully');
|
||||
}
|
||||
}
|
||||
20
api/app/Http/Controllers/Api/V1/MeController.php
Normal file
20
api/app/Http/Controllers/Api/V1/MeController.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Api\V1\UserResource;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class MeController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user()->load(['organisations', 'events']);
|
||||
|
||||
return $this->success(new UserResource($user));
|
||||
}
|
||||
}
|
||||
55
api/app/Http/Controllers/Api/V1/OrganisationController.php
Normal file
55
api/app/Http/Controllers/Api/V1/OrganisationController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\StoreOrganisationRequest;
|
||||
use App\Http\Requests\Api\V1\UpdateOrganisationRequest;
|
||||
use App\Http\Resources\Api\V1\OrganisationResource;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
final class OrganisationController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
Gate::authorize('viewAny', Organisation::class);
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
$organisations = $user->hasRole('super_admin')
|
||||
? Organisation::query()->paginate()
|
||||
: $user->organisations()->paginate();
|
||||
|
||||
return OrganisationResource::collection($organisations);
|
||||
}
|
||||
|
||||
public function show(Organisation $organisation): JsonResponse
|
||||
{
|
||||
Gate::authorize('view', $organisation);
|
||||
|
||||
return $this->success(new OrganisationResource($organisation));
|
||||
}
|
||||
|
||||
public function store(StoreOrganisationRequest $request): JsonResponse
|
||||
{
|
||||
Gate::authorize('create', Organisation::class);
|
||||
|
||||
$organisation = Organisation::create($request->validated());
|
||||
|
||||
return $this->created(new OrganisationResource($organisation));
|
||||
}
|
||||
|
||||
public function update(UpdateOrganisationRequest $request, Organisation $organisation): JsonResponse
|
||||
{
|
||||
Gate::authorize('update', $organisation);
|
||||
|
||||
$organisation->update($request->validated());
|
||||
|
||||
return $this->success(new OrganisationResource($organisation->fresh()));
|
||||
}
|
||||
}
|
||||
20
api/app/Http/Middleware/PortalTokenMiddleware.php
Normal file
20
api/app/Http/Middleware/PortalTokenMiddleware.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PortalTokenMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class InviteToEventRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'user_ids' => ['required', 'array', 'min:1'],
|
||||
'user_ids.*' => ['required', 'ulid', 'exists:users,id'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'user_ids.required' => 'Please select at least one member to invite.',
|
||||
'user_ids.*.exists' => 'One or more selected members do not exist.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
final class RegisterRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'string', 'confirmed', Password::defaults()],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'name.required' => 'Name is required',
|
||||
'email.required' => 'Email is required',
|
||||
'email.email' => 'Please enter a valid email address',
|
||||
'email.unique' => 'This email is already registered',
|
||||
'password.required' => 'Password is required',
|
||||
'password.confirmed' => 'Password confirmation does not match',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\RsvpStatus;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class RsvpEventRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'status' => ['required', Rule::enum(RsvpStatus::class)],
|
||||
'note' => ['nullable', 'string', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'status.required' => 'Please select your availability status.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventVisibility;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class StoreEventRequest extends FormRequest
|
||||
{
|
||||
@@ -16,37 +13,16 @@ final class StoreEventRequest extends FormRequest
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:5000'],
|
||||
'location_id' => ['nullable', 'ulid', 'exists:locations,id'],
|
||||
'customer_id' => ['nullable', 'ulid', 'exists:customers,id'],
|
||||
'setlist_id' => ['nullable', 'ulid', 'exists:setlists,id'],
|
||||
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
||||
'start_time' => ['required', 'date_format:H:i'],
|
||||
'end_time' => ['nullable', 'date_format:H:i', 'after:start_time'],
|
||||
'load_in_time' => ['nullable', 'date_format:H:i'],
|
||||
'soundcheck_time' => ['nullable', 'date_format:H:i'],
|
||||
'fee' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
|
||||
'currency' => ['sometimes', 'string', 'size:3'],
|
||||
'status' => ['sometimes', Rule::enum(EventStatus::class)],
|
||||
'visibility' => ['sometimes', Rule::enum(EventVisibility::class)],
|
||||
'rsvp_deadline' => ['nullable', 'date', 'before:event_date'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'],
|
||||
'internal_notes' => ['nullable', 'string', 'max:5000'],
|
||||
'is_public_setlist' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'event_date.after_or_equal' => 'The event date must be today or a future date.',
|
||||
'end_time.after' => 'The end time must be after the start time.',
|
||||
'rsvp_deadline.before' => 'The RSVP deadline must be before the event date.',
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9-]+$/'],
|
||||
'start_date' => ['required', 'date'],
|
||||
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
|
||||
'timezone' => ['sometimes', 'string', 'max:50'],
|
||||
'status' => ['sometimes', 'string', 'in:draft,published,registration_open,buildup,showday,teardown,closed'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php
Normal file
26
api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
final class StoreOrganisationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'string', 'max:255', 'unique:organisations,slug', 'regex:/^[a-z0-9-]+$/'],
|
||||
'billing_status' => ['sometimes', 'string', 'in:active,trial,suspended'],
|
||||
'settings' => ['sometimes', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventVisibility;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class UpdateEventRequest extends FormRequest
|
||||
{
|
||||
@@ -16,35 +13,16 @@ final class UpdateEventRequest extends FormRequest
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'title' => ['sometimes', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string', 'max:5000'],
|
||||
'location_id' => ['nullable', 'ulid', 'exists:locations,id'],
|
||||
'customer_id' => ['nullable', 'ulid', 'exists:customers,id'],
|
||||
'setlist_id' => ['nullable', 'ulid', 'exists:setlists,id'],
|
||||
'event_date' => ['sometimes', 'date'],
|
||||
'start_time' => ['sometimes', 'date_format:H:i'],
|
||||
'end_time' => ['nullable', 'date_format:H:i'],
|
||||
'load_in_time' => ['nullable', 'date_format:H:i'],
|
||||
'soundcheck_time' => ['nullable', 'date_format:H:i'],
|
||||
'fee' => ['nullable', 'numeric', 'min:0', 'max:999999.99'],
|
||||
'currency' => ['sometimes', 'string', 'size:3'],
|
||||
'status' => ['sometimes', Rule::enum(EventStatus::class)],
|
||||
'visibility' => ['sometimes', Rule::enum(EventVisibility::class)],
|
||||
'rsvp_deadline' => ['nullable', 'date'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'],
|
||||
'internal_notes' => ['nullable', 'string', 'max:5000'],
|
||||
'is_public_setlist' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'end_time.after' => 'The end time must be after the start time.',
|
||||
'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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
30
api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php
Normal file
30
api/app/Http/Requests/Api/V1/UpdateOrganisationRequest.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class UpdateOrganisationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'slug' => [
|
||||
'sometimes', 'string', 'max:255', 'regex:/^[a-z0-9-]+$/',
|
||||
Rule::unique('organisations', 'slug')->ignore($this->route('organisation')),
|
||||
],
|
||||
'billing_status' => ['sometimes', 'string', 'in:active,trial,suspended'],
|
||||
'settings' => ['sometimes', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class CustomerResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'company_name' => $this->company_name,
|
||||
'type' => $this->type,
|
||||
'email' => $this->email,
|
||||
'phone' => $this->phone,
|
||||
'address' => $this->address,
|
||||
'city' => $this->city,
|
||||
'postal_code' => $this->postal_code,
|
||||
'country' => $this->country,
|
||||
'notes' => $this->notes,
|
||||
'is_portal_enabled' => $this->is_portal_enabled,
|
||||
'display_name' => $this->displayName(),
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
final class EventCollection extends ResourceCollection
|
||||
{
|
||||
public $collects = EventResource::class;
|
||||
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'data' => $this->collection,
|
||||
];
|
||||
}
|
||||
|
||||
public function with(Request $request): array
|
||||
{
|
||||
return [
|
||||
'success' => true,
|
||||
'meta' => [
|
||||
'pagination' => [
|
||||
'current_page' => $this->currentPage(),
|
||||
'per_page' => $this->perPage(),
|
||||
'total' => $this->total(),
|
||||
'last_page' => $this->lastPage(),
|
||||
'from' => $this->firstItem(),
|
||||
'to' => $this->lastItem(),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class EventInvitationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'event_id' => $this->event_id,
|
||||
'user_id' => $this->user_id,
|
||||
'rsvp_status' => $this->rsvp_status->value,
|
||||
'rsvp_status_label' => $this->rsvp_status->label(),
|
||||
'rsvp_status_color' => $this->rsvp_status->color(),
|
||||
'rsvp_note' => $this->rsvp_note,
|
||||
'rsvp_responded_at' => $this->rsvp_responded_at?->toIso8601String(),
|
||||
'invited_at' => $this->invited_at?->toIso8601String(),
|
||||
'user' => new UserResource($this->whenLoaded('user')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,35 +13,16 @@ final class EventResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'description' => $this->description,
|
||||
'event_date' => $this->event_date->toDateString(),
|
||||
'start_time' => $this->start_time?->format('H:i'),
|
||||
'end_time' => $this->end_time?->format('H:i'),
|
||||
'load_in_time' => $this->load_in_time?->format('H:i'),
|
||||
'soundcheck_time' => $this->soundcheck_time?->format('H:i'),
|
||||
'fee' => $this->fee,
|
||||
'currency' => $this->currency,
|
||||
'status' => $this->status->value,
|
||||
'status_label' => $this->status->label(),
|
||||
'status_color' => $this->status->color(),
|
||||
'visibility' => $this->visibility->value,
|
||||
'visibility_label' => $this->visibility->label(),
|
||||
'rsvp_deadline' => $this->rsvp_deadline?->toIso8601String(),
|
||||
'notes' => $this->notes,
|
||||
'internal_notes' => $this->when(
|
||||
$request->user()?->role === 'admin' || $request->user()?->role === 'booking_agent',
|
||||
$this->internal_notes
|
||||
),
|
||||
'is_public_setlist' => $this->is_public_setlist,
|
||||
'location' => new LocationResource($this->whenLoaded('location')),
|
||||
'customer' => new CustomerResource($this->whenLoaded('customer')),
|
||||
'setlist' => new SetlistResource($this->whenLoaded('setlist')),
|
||||
'invitations' => EventInvitationResource::collection($this->whenLoaded('invitations')),
|
||||
'creator' => new UserResource($this->whenLoaded('creator')),
|
||||
'organisation_id' => $this->organisation_id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'start_date' => $this->start_date->toDateString(),
|
||||
'end_date' => $this->end_date->toDateString(),
|
||||
'timezone' => $this->timezone,
|
||||
'status' => $this->status,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
'organisation' => new OrganisationResource($this->whenLoaded('organisation')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class LocationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'address' => $this->address,
|
||||
'city' => $this->city,
|
||||
'postal_code' => $this->postal_code,
|
||||
'country' => $this->country,
|
||||
'latitude' => $this->latitude,
|
||||
'longitude' => $this->longitude,
|
||||
'capacity' => $this->capacity,
|
||||
'contact_name' => $this->contact_name,
|
||||
'contact_email' => $this->contact_email,
|
||||
'contact_phone' => $this->contact_phone,
|
||||
'notes' => $this->notes,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class MusicAttachmentResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'music_number_id' => $this->music_number_id,
|
||||
'file_name' => $this->file_name,
|
||||
'original_name' => $this->original_name,
|
||||
'file_type' => $this->file_type,
|
||||
'file_size' => $this->file_size,
|
||||
'mime_type' => $this->mime_type,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class MusicNumberResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'title' => $this->title,
|
||||
'artist' => $this->artist,
|
||||
'genre' => $this->genre,
|
||||
'duration_seconds' => $this->duration_seconds,
|
||||
'key' => $this->key,
|
||||
'tempo_bpm' => $this->tempo_bpm,
|
||||
'time_signature' => $this->time_signature,
|
||||
'lyrics' => $this->lyrics,
|
||||
'notes' => $this->notes,
|
||||
'tags' => $this->tags,
|
||||
'play_count' => $this->play_count,
|
||||
'is_active' => $this->is_active,
|
||||
'attachments' => MusicAttachmentResource::collection($this->whenLoaded('attachments')),
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
24
api/app/Http/Resources/Api/V1/OrganisationResource.php
Normal file
24
api/app/Http/Resources/Api/V1/OrganisationResource.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class OrganisationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'billing_status' => $this->billing_status,
|
||||
'settings' => $this->settings,
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class SetlistItemResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'setlist_id' => $this->setlist_id,
|
||||
'music_number_id' => $this->music_number_id,
|
||||
'position' => $this->position,
|
||||
'set_number' => $this->set_number,
|
||||
'is_break' => $this->is_break,
|
||||
'break_duration_seconds' => $this->break_duration_seconds,
|
||||
'notes' => $this->notes,
|
||||
'music_number' => new MusicNumberResource($this->whenLoaded('musicNumber')),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Api\V1;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class SetlistResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'total_duration_seconds' => $this->total_duration_seconds,
|
||||
'formatted_duration' => $this->formattedDuration(),
|
||||
'is_template' => $this->is_template,
|
||||
'is_archived' => $this->is_archived,
|
||||
'items' => SetlistItemResource::collection($this->whenLoaded('items')),
|
||||
'creator' => new UserResource($this->whenLoaded('creator')),
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,17 +15,28 @@ final class UserResource extends JsonResource
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'phone' => $this->phone,
|
||||
'bio' => $this->bio,
|
||||
'instruments' => $this->instruments,
|
||||
'avatar' => $this->avatar_path ? asset('storage/' . $this->avatar_path) : null,
|
||||
'type' => $this->type,
|
||||
'role' => $this->role,
|
||||
'status' => $this->status,
|
||||
'roles' => $this->getRoleNames()->values()->all(),
|
||||
'timezone' => $this->timezone,
|
||||
'locale' => $this->locale,
|
||||
'avatar' => $this->avatar,
|
||||
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at->toIso8601String(),
|
||||
'updated_at' => $this->updated_at->toIso8601String(),
|
||||
'organisations' => $this->whenLoaded('organisations', fn () =>
|
||||
$this->organisations->map(fn ($org) => [
|
||||
'id' => $org->id,
|
||||
'name' => $org->name,
|
||||
'slug' => $org->slug,
|
||||
'role' => $org->pivot->role,
|
||||
])
|
||||
),
|
||||
'event_roles' => $this->whenLoaded('events', fn () =>
|
||||
$this->events->map(fn ($event) => [
|
||||
'event_id' => $event->id,
|
||||
'event_name' => $event->name,
|
||||
'role' => $event->pivot->role,
|
||||
])
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class Customer extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'company_name',
|
||||
'type',
|
||||
'email',
|
||||
'phone',
|
||||
'address',
|
||||
'city',
|
||||
'postal_code',
|
||||
'country',
|
||||
'notes',
|
||||
'is_portal_enabled',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_portal_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isCompany(): bool
|
||||
{
|
||||
return $this->type === 'company';
|
||||
}
|
||||
|
||||
public function displayName(): string
|
||||
{
|
||||
return $this->company_name ?? $this->name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,129 +4,52 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventVisibility;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
final class Event extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'description',
|
||||
'location_id',
|
||||
'customer_id',
|
||||
'setlist_id',
|
||||
'event_date',
|
||||
'start_time',
|
||||
'end_time',
|
||||
'load_in_time',
|
||||
'soundcheck_time',
|
||||
'fee',
|
||||
'currency',
|
||||
'organisation_id',
|
||||
'name',
|
||||
'slug',
|
||||
'start_date',
|
||||
'end_date',
|
||||
'timezone',
|
||||
'status',
|
||||
'visibility',
|
||||
'rsvp_deadline',
|
||||
'notes',
|
||||
'internal_notes',
|
||||
'is_public_setlist',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'event_date' => 'date',
|
||||
'start_time' => 'datetime:H:i',
|
||||
'end_time' => 'datetime:H:i',
|
||||
'load_in_time' => 'datetime:H:i',
|
||||
'soundcheck_time' => 'datetime:H:i',
|
||||
'fee' => 'decimal:2',
|
||||
'status' => EventStatus::class,
|
||||
'visibility' => EventVisibility::class,
|
||||
'rsvp_deadline' => 'datetime',
|
||||
'is_public_setlist' => 'boolean',
|
||||
'start_date' => 'date',
|
||||
'end_date' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function location(): BelongsTo
|
||||
public function organisation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Location::class);
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
|
||||
public function customer(): BelongsTo
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function setlist(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Setlist::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
return $this->belongsToMany(User::class, 'event_user_roles')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function invitations(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventInvitation::class);
|
||||
}
|
||||
|
||||
// Scopes
|
||||
|
||||
public function scopeUpcoming(Builder $query): Builder
|
||||
{
|
||||
return $query->where('event_date', '>=', now()->toDateString())
|
||||
->orderBy('event_date');
|
||||
}
|
||||
|
||||
public function scopePast(Builder $query): Builder
|
||||
{
|
||||
return $query->where('event_date', '<', now()->toDateString())
|
||||
->orderByDesc('event_date');
|
||||
}
|
||||
|
||||
public function scopeConfirmed(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', EventStatus::Confirmed);
|
||||
}
|
||||
|
||||
public function scopeForUser(Builder $query, User $user): Builder
|
||||
{
|
||||
return $query->whereHas('invitations', fn (Builder $q) => $q->where('user_id', $user->id));
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isUpcoming(): bool
|
||||
{
|
||||
return $this->event_date->isFuture() || $this->event_date->isToday();
|
||||
}
|
||||
|
||||
public function isPast(): bool
|
||||
{
|
||||
return $this->event_date->isPast() && !$this->event_date->isToday();
|
||||
}
|
||||
|
||||
public function isConfirmed(): bool
|
||||
{
|
||||
return $this->status === EventStatus::Confirmed;
|
||||
}
|
||||
|
||||
public function isCancelled(): bool
|
||||
{
|
||||
return $this->status === EventStatus::Cancelled;
|
||||
return $this->hasMany(UserInvitation::class);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\RsvpStatus;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class EventInvitation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'user_id',
|
||||
'rsvp_status',
|
||||
'rsvp_note',
|
||||
'rsvp_responded_at',
|
||||
'invited_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'rsvp_status' => RsvpStatus::class,
|
||||
'rsvp_responded_at' => 'datetime',
|
||||
'invited_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->rsvp_status === RsvpStatus::Pending;
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return $this->rsvp_status === RsvpStatus::Available;
|
||||
}
|
||||
|
||||
public function hasResponded(): bool
|
||||
{
|
||||
return $this->rsvp_responded_at !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class Location extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'address',
|
||||
'city',
|
||||
'postal_code',
|
||||
'country',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'capacity',
|
||||
'contact_name',
|
||||
'contact_email',
|
||||
'contact_phone',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'latitude' => 'decimal:7',
|
||||
'longitude' => 'decimal:7',
|
||||
'capacity' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class MusicAttachment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'music_number_id',
|
||||
'file_name',
|
||||
'original_name',
|
||||
'file_path',
|
||||
'file_type',
|
||||
'file_size',
|
||||
'mime_type',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'file_size' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function musicNumber(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MusicNumber::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class MusicNumber extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'title',
|
||||
'artist',
|
||||
'genre',
|
||||
'duration_seconds',
|
||||
'key',
|
||||
'tempo_bpm',
|
||||
'time_signature',
|
||||
'lyrics',
|
||||
'notes',
|
||||
'tags',
|
||||
'play_count',
|
||||
'is_active',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'duration_seconds' => 'integer',
|
||||
'tempo_bpm' => 'integer',
|
||||
'tags' => 'array',
|
||||
'play_count' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function attachments(): HasMany
|
||||
{
|
||||
return $this->hasMany(MusicAttachment::class);
|
||||
}
|
||||
|
||||
public function setlistItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(SetlistItem::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function formattedDuration(): string
|
||||
{
|
||||
if (!$this->duration_seconds) {
|
||||
return '0:00';
|
||||
}
|
||||
|
||||
$minutes = floor($this->duration_seconds / 60);
|
||||
$seconds = $this->duration_seconds % 60;
|
||||
|
||||
return sprintf('%d:%02d', $minutes, $seconds);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->is_active;
|
||||
}
|
||||
}
|
||||
|
||||
50
api/app/Models/Organisation.php
Normal file
50
api/app/Models/Organisation.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
final class Organisation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'billing_status',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'settings' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'organisation_user')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
public function invitations(): HasMany
|
||||
{
|
||||
return $this->hasMany(UserInvitation::class);
|
||||
}
|
||||
}
|
||||
21
api/app/Models/Scopes/OrganisationScope.php
Normal file
21
api/app/Models/Scopes/OrganisationScope.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Scopes;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Scope;
|
||||
|
||||
final class OrganisationScope implements Scope
|
||||
{
|
||||
public function __construct(
|
||||
private readonly string $organisationId,
|
||||
) {}
|
||||
|
||||
public function apply(Builder $builder, Model $model): void
|
||||
{
|
||||
$builder->where($model->getTable() . '.organisation_id', $this->organisationId);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
final class Setlist extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'description',
|
||||
'total_duration_seconds',
|
||||
'is_template',
|
||||
'is_archived',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'total_duration_seconds' => 'integer',
|
||||
'is_template' => 'boolean',
|
||||
'is_archived' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(SetlistItem::class)->orderBy('position');
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isTemplate(): bool
|
||||
{
|
||||
return $this->is_template;
|
||||
}
|
||||
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->is_archived;
|
||||
}
|
||||
|
||||
public function formattedDuration(): string
|
||||
{
|
||||
if (!$this->total_duration_seconds) {
|
||||
return '0:00';
|
||||
}
|
||||
|
||||
$minutes = floor($this->total_duration_seconds / 60);
|
||||
$seconds = $this->total_duration_seconds % 60;
|
||||
|
||||
return sprintf('%d:%02d', $minutes, $seconds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class SetlistItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'setlist_id',
|
||||
'music_number_id',
|
||||
'position',
|
||||
'set_number',
|
||||
'is_break',
|
||||
'break_duration_seconds',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'position' => 'integer',
|
||||
'set_number' => 'integer',
|
||||
'is_break' => 'boolean',
|
||||
'break_duration_seconds' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
// Relationships
|
||||
|
||||
public function setlist(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Setlist::class);
|
||||
}
|
||||
|
||||
public function musicNumber(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MusicNumber::class);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isBreak(): bool
|
||||
{
|
||||
return $this->is_break;
|
||||
}
|
||||
|
||||
public function formattedBreakDuration(): string
|
||||
{
|
||||
if (!$this->is_break || !$this->break_duration_seconds) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$minutes = floor($this->break_duration_seconds / 60);
|
||||
$seconds = $this->break_duration_seconds % 60;
|
||||
|
||||
return sprintf('%d:%02d', $minutes, $seconds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,28 +6,29 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
final class User extends Authenticatable
|
||||
{
|
||||
use HasApiTokens;
|
||||
use HasFactory;
|
||||
use HasRoles;
|
||||
use HasUlids;
|
||||
use Notifiable;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'phone',
|
||||
'bio',
|
||||
'instruments',
|
||||
'avatar_path',
|
||||
'type',
|
||||
'role',
|
||||
'status',
|
||||
'password',
|
||||
'timezone',
|
||||
'locale',
|
||||
'avatar',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -40,39 +41,20 @@ final class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'instruments' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
public function isAdmin(): bool
|
||||
public function organisations(): BelongsToMany
|
||||
{
|
||||
return $this->role === 'admin';
|
||||
return $this->belongsToMany(Organisation::class, 'organisation_user')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function isBookingAgent(): bool
|
||||
public function events(): BelongsToMany
|
||||
{
|
||||
return $this->role === 'booking_agent';
|
||||
}
|
||||
|
||||
public function isMusicManager(): bool
|
||||
{
|
||||
return $this->role === 'music_manager';
|
||||
}
|
||||
|
||||
public function isMember(): bool
|
||||
{
|
||||
return $this->type === 'member';
|
||||
}
|
||||
|
||||
public function isCustomer(): bool
|
||||
{
|
||||
return $this->type === 'customer';
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active';
|
||||
return $this->belongsToMany(Event::class, 'event_user_roles')
|
||||
->withPivot('role')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
||||
49
api/app/Models/UserInvitation.php
Normal file
49
api/app/Models/UserInvitation.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class UserInvitation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use HasUlids;
|
||||
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'invited_by_user_id',
|
||||
'organisation_id',
|
||||
'event_id',
|
||||
'role',
|
||||
'token',
|
||||
'status',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function invitedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by_user_id');
|
||||
}
|
||||
|
||||
public function organisation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organisation::class);
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
}
|
||||
@@ -5,79 +5,44 @@ declare(strict_types=1);
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
|
||||
final class EventPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any events.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
public function viewAny(User $user, Organisation $organisation): bool
|
||||
{
|
||||
return true;
|
||||
return $user->hasRole('super_admin')
|
||||
|| $organisation->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the event.
|
||||
*/
|
||||
public function view(User $user, Event $event): bool
|
||||
{
|
||||
// Admins and booking agents can view all events
|
||||
if ($this->isAdminOrBookingAgent($user)) {
|
||||
return $user->hasRole('super_admin')
|
||||
|| $event->organisation->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function create(User $user, Organisation $organisation): bool
|
||||
{
|
||||
if ($user->hasRole('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Members can view events they're invited to
|
||||
return $event->invitations()->where('user_id', $user->id)->exists();
|
||||
return $organisation->users()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivotIn('role', ['org_admin', 'org_member'])
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create events.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $this->isAdminOrBookingAgent($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the event.
|
||||
*/
|
||||
public function update(User $user, Event $event): bool
|
||||
{
|
||||
return $this->isAdminOrBookingAgent($user);
|
||||
}
|
||||
if ($user->hasRole('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the event.
|
||||
*/
|
||||
public function delete(User $user, Event $event): bool
|
||||
{
|
||||
return $user->role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can invite members to the event.
|
||||
*/
|
||||
public function invite(User $user, Event $event): bool
|
||||
{
|
||||
return $this->isAdminOrBookingAgent($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can RSVP to the event.
|
||||
*/
|
||||
public function rsvp(User $user, Event $event): bool
|
||||
{
|
||||
// User must be invited to RSVP
|
||||
return $event->invitations()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is an admin or booking agent.
|
||||
*/
|
||||
private function isAdminOrBookingAgent(User $user): bool
|
||||
{
|
||||
return in_array($user->role, ['admin', 'booking_agent'], true);
|
||||
return $event->organisation->users()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivotIn('role', ['org_admin', 'org_member'])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
40
api/app/Policies/OrganisationPolicy.php
Normal file
40
api/app/Policies/OrganisationPolicy.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
|
||||
final class OrganisationPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
// All authenticated users can list their organisations
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, Organisation $organisation): bool
|
||||
{
|
||||
return $user->hasRole('super_admin')
|
||||
|| $organisation->users()->where('user_id', $user->id)->exists();
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->hasRole('super_admin');
|
||||
}
|
||||
|
||||
public function update(User $user, Organisation $organisation): bool
|
||||
{
|
||||
if ($user->hasRole('super_admin')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $organisation->users()
|
||||
->where('user_id', $user->id)
|
||||
->wherePivot('role', 'org_admin')
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
// API uses token-based auth, no CSRF needed
|
||||
|
||||
$middleware->alias([
|
||||
'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
// Return JSON for all API exceptions
|
||||
|
||||
@@ -7,9 +7,14 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"endroid/qr-code": "^6.1",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"spatie/laravel-activitylog": "^5.0",
|
||||
"spatie/laravel-medialibrary": "^11.21",
|
||||
"spatie/laravel-permission": "^7.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
1396
api/composer.lock
generated
1396
api/composer.lock
generated
File diff suppressed because it is too large
Load Diff
73
api/config/activitylog.php
Normal file
73
api/config/activitylog.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use Spatie\Activitylog\Actions\CleanActivityLogAction;
|
||||
use Spatie\Activitylog\Actions\LogActivityAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
* If set to false, no activities will be saved to the database.
|
||||
*/
|
||||
'enabled' => env('ACTIVITYLOG_ENABLED', true),
|
||||
|
||||
/*
|
||||
* When the clean command is executed, all recording activities older than
|
||||
* the number of days specified here will be deleted.
|
||||
*/
|
||||
'clean_after_days' => 365,
|
||||
|
||||
/*
|
||||
* If no log name is passed to the activity() helper
|
||||
* we use this default log name.
|
||||
*/
|
||||
'default_log_name' => 'default',
|
||||
|
||||
/*
|
||||
* You can specify an auth driver here that gets user models.
|
||||
* If this is null we'll use the current Laravel auth driver.
|
||||
*/
|
||||
'default_auth_driver' => null,
|
||||
|
||||
/*
|
||||
* If set to true, the subject relationship on activities
|
||||
* will include soft deleted models.
|
||||
*/
|
||||
'include_soft_deleted_subjects' => false,
|
||||
|
||||
/*
|
||||
* This model will be used to log activity.
|
||||
* It should implement the Spatie\Activitylog\Contracts\Activity interface
|
||||
* and extend Illuminate\Database\Eloquent\Model.
|
||||
*/
|
||||
'activity_model' => Activity::class,
|
||||
|
||||
/*
|
||||
* These attributes will be excluded from logging for all models.
|
||||
* Model-specific exclusions via logExcept() are merged with these.
|
||||
*/
|
||||
'default_except_attributes' => [],
|
||||
|
||||
/*
|
||||
* When enabled, activities are buffered in memory and inserted in a
|
||||
* single bulk query after the response has been sent to the client.
|
||||
* This can significantly reduce the number of database queries when
|
||||
* many activities are logged during a single request.
|
||||
*
|
||||
* Only enable this if your application logs a high volume of activities
|
||||
* per request. Buffered activities will not have an ID until the
|
||||
* buffer is flushed.
|
||||
*/
|
||||
'buffer' => [
|
||||
'enabled' => env('ACTIVITYLOG_BUFFER_ENABLED', false),
|
||||
],
|
||||
|
||||
/*
|
||||
* These action classes can be overridden to customize how activities
|
||||
* are logged and cleaned. Your custom classes must extend the originals.
|
||||
*/
|
||||
'actions' => [
|
||||
'log_activity' => LogActivityAction::class,
|
||||
'clean_log' => CleanActivityLogAction::class,
|
||||
],
|
||||
];
|
||||
@@ -22,9 +22,9 @@ return [
|
||||
'allowed_methods' => ['*'],
|
||||
|
||||
'allowed_origins' => [
|
||||
'http://localhost:5173', // Admin SPA
|
||||
'http://localhost:5174', // Band SPA
|
||||
'http://localhost:5175', // Customer SPA
|
||||
env('FRONTEND_ADMIN_URL', 'http://localhost:5173'),
|
||||
env('FRONTEND_APP_URL', 'http://localhost:5174'),
|
||||
env('FRONTEND_PORTAL_URL', 'http://localhost:5175'),
|
||||
],
|
||||
|
||||
'allowed_origins_patterns' => [],
|
||||
|
||||
202
api/config/permission.php
Normal file
202
api/config/permission.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'models' => [
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your permissions. Of course, it
|
||||
* is often just the "Permission" model but you may use whatever you like.
|
||||
*
|
||||
* The model you want to use as a Permission model needs to implement the
|
||||
* `Spatie\Permission\Contracts\Permission` contract.
|
||||
*/
|
||||
|
||||
'permission' => Spatie\Permission\Models\Permission::class,
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your roles. Of course, it
|
||||
* is often just the "Role" model but you may use whatever you like.
|
||||
*
|
||||
* The model you want to use as a Role model needs to implement the
|
||||
* `Spatie\Permission\Contracts\Role` contract.
|
||||
*/
|
||||
|
||||
'role' => Spatie\Permission\Models\Role::class,
|
||||
|
||||
],
|
||||
|
||||
'table_names' => [
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your roles. We have chosen a basic
|
||||
* default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'roles' => 'roles',
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* table should be used to retrieve your permissions. We have chosen a basic
|
||||
* default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'permissions' => 'permissions',
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* table should be used to retrieve your models permissions. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'model_has_permissions' => 'model_has_permissions',
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your models roles. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'model_has_roles' => 'model_has_roles',
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your roles permissions. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'role_has_permissions' => 'role_has_permissions',
|
||||
],
|
||||
|
||||
'column_names' => [
|
||||
/*
|
||||
* Change this if you want to name the related pivots other than defaults
|
||||
*/
|
||||
'role_pivot_key' => null, // default 'role_id',
|
||||
'permission_pivot_key' => null, // default 'permission_id',
|
||||
|
||||
/*
|
||||
* Change this if you want to name the related model primary key other than
|
||||
* `model_id`.
|
||||
*
|
||||
* For example, this would be nice if your primary keys are all UUIDs. In
|
||||
* that case, name this `model_uuid`.
|
||||
*/
|
||||
|
||||
'model_morph_key' => 'model_id',
|
||||
|
||||
/*
|
||||
* Change this if you want to use the teams feature and your related model's
|
||||
* foreign key is other than `team_id`.
|
||||
*/
|
||||
|
||||
'team_foreign_key' => 'team_id',
|
||||
],
|
||||
|
||||
/*
|
||||
* When set to true, the method for checking permissions will be registered on the gate.
|
||||
* Set this to false if you want to implement custom logic for checking permissions.
|
||||
*/
|
||||
|
||||
'register_permission_check_method' => true,
|
||||
|
||||
/*
|
||||
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
||||
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
|
||||
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
|
||||
*/
|
||||
'register_octane_reset_listener' => false,
|
||||
|
||||
/*
|
||||
* Events will fire when a role or permission is assigned/unassigned:
|
||||
* \Spatie\Permission\Events\RoleAttachedEvent
|
||||
* \Spatie\Permission\Events\RoleDetachedEvent
|
||||
* \Spatie\Permission\Events\PermissionAttachedEvent
|
||||
* \Spatie\Permission\Events\PermissionDetachedEvent
|
||||
*
|
||||
* To enable, set to true, and then create listeners to watch these events.
|
||||
*/
|
||||
'events_enabled' => false,
|
||||
|
||||
/*
|
||||
* Teams Feature.
|
||||
* When set to true the package implements teams using the 'team_foreign_key'.
|
||||
* If you want the migrations to register the 'team_foreign_key', you must
|
||||
* set this to true before doing the migration.
|
||||
* If you already did the migration then you must make a new migration to also
|
||||
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
|
||||
* (view the latest version of this package's migration file)
|
||||
*/
|
||||
|
||||
'teams' => false,
|
||||
|
||||
/*
|
||||
* The class to use to resolve the permissions team id
|
||||
*/
|
||||
'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class,
|
||||
|
||||
/*
|
||||
* Passport Client Credentials Grant
|
||||
* When set to true the package will use Passports Client to check permissions
|
||||
*/
|
||||
|
||||
'use_passport_client_credentials' => false,
|
||||
|
||||
/*
|
||||
* When set to true, the required permission names are added to exception messages.
|
||||
* This could be considered an information leak in some contexts, so the default
|
||||
* setting is false here for optimum safety.
|
||||
*/
|
||||
|
||||
'display_permission_in_exception' => false,
|
||||
|
||||
/*
|
||||
* When set to true, the required role names are added to exception messages.
|
||||
* This could be considered an information leak in some contexts, so the default
|
||||
* setting is false here for optimum safety.
|
||||
*/
|
||||
|
||||
'display_role_in_exception' => false,
|
||||
|
||||
/*
|
||||
* By default wildcard permission lookups are disabled.
|
||||
* See documentation to understand supported syntax.
|
||||
*/
|
||||
|
||||
'enable_wildcard_permission' => false,
|
||||
|
||||
/*
|
||||
* The class to use for interpreting wildcard permissions.
|
||||
* If you need to modify delimiters, override the class and specify its name here.
|
||||
*/
|
||||
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
|
||||
|
||||
/* Cache-specific settings */
|
||||
|
||||
'cache' => [
|
||||
|
||||
/*
|
||||
* By default all permissions are cached for 24 hours to speed up performance.
|
||||
* When permissions or roles are updated the cache is flushed automatically.
|
||||
*/
|
||||
|
||||
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
|
||||
|
||||
/*
|
||||
* The cache key used to store all permissions.
|
||||
*/
|
||||
|
||||
'key' => 'spatie.permission.cache',
|
||||
|
||||
/*
|
||||
* You may optionally indicate a specific cache driver to use for permission and
|
||||
* role caching using any of the `store` drivers listed in the cache.php config
|
||||
* file. Using 'default' here means to use the `default` set in cache.php.
|
||||
*/
|
||||
|
||||
'store' => 'default',
|
||||
],
|
||||
];
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Customer;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Customer>
|
||||
*/
|
||||
final class CustomerFactory extends Factory
|
||||
{
|
||||
protected $model = Customer::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
$type = fake()->randomElement(['individual', 'company']);
|
||||
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'company_name' => $type === 'company' ? fake()->company() : null,
|
||||
'type' => $type,
|
||||
'email' => fake()->optional()->safeEmail(),
|
||||
'phone' => fake()->optional()->phoneNumber(),
|
||||
'address' => fake()->optional()->streetAddress(),
|
||||
'city' => fake()->optional()->city(),
|
||||
'postal_code' => fake()->optional()->postcode(),
|
||||
'country' => 'NL',
|
||||
'notes' => fake()->optional()->paragraph(),
|
||||
'is_portal_enabled' => fake()->boolean(20),
|
||||
];
|
||||
}
|
||||
|
||||
public function individual(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'type' => 'individual',
|
||||
'company_name' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function company(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'type' => 'company',
|
||||
'company_name' => fake()->company(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function withPortal(): static
|
||||
{
|
||||
return $this->state(fn () => ['is_portal_enabled' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,100 +4,32 @@ declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\EventStatus;
|
||||
use App\Enums\EventVisibility;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Event;
|
||||
use App\Models\Location;
|
||||
use App\Models\User;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Event>
|
||||
*/
|
||||
/** @extends Factory<Event> */
|
||||
final class EventFactory extends Factory
|
||||
{
|
||||
protected $model = Event::class;
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
$startTime = fake()->time('H:i');
|
||||
$endTime = fake()->optional()->time('H:i');
|
||||
$name = fake()->unique()->words(3, true);
|
||||
$startDate = fake()->dateTimeBetween('+1 week', '+3 months');
|
||||
|
||||
return [
|
||||
'title' => fake()->sentence(3),
|
||||
'description' => fake()->optional()->paragraph(),
|
||||
'event_date' => fake()->dateTimeBetween('+1 week', '+6 months'),
|
||||
'start_time' => $startTime,
|
||||
'end_time' => $endTime,
|
||||
'fee' => fake()->optional()->randomFloat(2, 100, 5000),
|
||||
'currency' => 'EUR',
|
||||
'status' => fake()->randomElement(EventStatus::cases()),
|
||||
'visibility' => EventVisibility::Members,
|
||||
'notes' => fake()->optional()->paragraph(),
|
||||
'created_by' => User::factory(),
|
||||
'organisation_id' => Organisation::factory(),
|
||||
'name' => ucfirst($name),
|
||||
'slug' => str($name)->slug()->toString(),
|
||||
'start_date' => $startDate,
|
||||
'end_date' => fake()->dateTimeBetween($startDate, (clone $startDate)->modify('+3 days')),
|
||||
'timezone' => 'Europe/Amsterdam',
|
||||
'status' => 'draft',
|
||||
];
|
||||
}
|
||||
|
||||
public function draft(): static
|
||||
public function published(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => EventStatus::Draft]);
|
||||
}
|
||||
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => EventStatus::Pending]);
|
||||
}
|
||||
|
||||
public function confirmed(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => EventStatus::Confirmed]);
|
||||
}
|
||||
|
||||
public function completed(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => EventStatus::Completed]);
|
||||
}
|
||||
|
||||
public function cancelled(): static
|
||||
{
|
||||
return $this->state(fn () => ['status' => EventStatus::Cancelled]);
|
||||
}
|
||||
|
||||
public function withLocation(): static
|
||||
{
|
||||
return $this->state(fn () => ['location_id' => Location::factory()]);
|
||||
}
|
||||
|
||||
public function withCustomer(): static
|
||||
{
|
||||
return $this->state(fn () => ['customer_id' => Customer::factory()]);
|
||||
}
|
||||
|
||||
public function upcoming(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'event_date' => fake()->dateTimeBetween('+1 day', '+1 month'),
|
||||
'status' => EventStatus::Confirmed,
|
||||
]);
|
||||
}
|
||||
|
||||
public function past(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'event_date' => fake()->dateTimeBetween('-6 months', '-1 day'),
|
||||
'status' => EventStatus::Completed,
|
||||
]);
|
||||
}
|
||||
|
||||
public function privateEvent(): static
|
||||
{
|
||||
return $this->state(fn () => ['visibility' => EventVisibility::Private]);
|
||||
}
|
||||
|
||||
public function publicEvent(): static
|
||||
{
|
||||
return $this->state(fn () => ['visibility' => EventVisibility::Public]);
|
||||
return $this->state(fn () => ['status' => 'published']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\RsvpStatus;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventInvitation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<EventInvitation>
|
||||
*/
|
||||
final class EventInvitationFactory extends Factory
|
||||
{
|
||||
protected $model = EventInvitation::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'event_id' => Event::factory(),
|
||||
'user_id' => User::factory(),
|
||||
'rsvp_status' => RsvpStatus::Pending,
|
||||
'rsvp_note' => null,
|
||||
'rsvp_responded_at' => null,
|
||||
'invited_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'rsvp_status' => RsvpStatus::Pending,
|
||||
'rsvp_responded_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function available(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'rsvp_status' => RsvpStatus::Available,
|
||||
'rsvp_responded_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function unavailable(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'rsvp_status' => RsvpStatus::Unavailable,
|
||||
'rsvp_responded_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function tentative(): static
|
||||
{
|
||||
return $this->state(fn () => [
|
||||
'rsvp_status' => RsvpStatus::Tentative,
|
||||
'rsvp_responded_at' => now(),
|
||||
'rsvp_note' => fake()->sentence(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Location;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Location>
|
||||
*/
|
||||
final class LocationFactory extends Factory
|
||||
{
|
||||
protected $model = Location::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->company() . ' ' . fake()->randomElement(['Theater', 'Hall', 'Arena', 'Club', 'Venue']),
|
||||
'address' => fake()->streetAddress(),
|
||||
'city' => fake()->city(),
|
||||
'postal_code' => fake()->postcode(),
|
||||
'country' => 'NL',
|
||||
'latitude' => fake()->optional()->latitude(),
|
||||
'longitude' => fake()->optional()->longitude(),
|
||||
'capacity' => fake()->optional()->numberBetween(50, 2000),
|
||||
'contact_name' => fake()->optional()->name(),
|
||||
'contact_email' => fake()->optional()->safeEmail(),
|
||||
'contact_phone' => fake()->optional()->phoneNumber(),
|
||||
'notes' => fake()->optional()->paragraph(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
25
api/database/factories/OrganisationFactory.php
Normal file
25
api/database/factories/OrganisationFactory.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/** @extends Factory<Organisation> */
|
||||
final class OrganisationFactory extends Factory
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->unique()->company();
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'slug' => str($name)->slug()->toString(),
|
||||
'billing_status' => 'active',
|
||||
'settings' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
/** @extends Factory<User> */
|
||||
final class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
@@ -28,17 +22,14 @@ class UserFactory extends Factory
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'timezone' => 'Europe/Amsterdam',
|
||||
'locale' => 'nl',
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
return $this->state(fn () => ['email_verified_at' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
30
api/database/factories/UserInvitationFactory.php
Normal file
30
api/database/factories/UserInvitationFactory.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use App\Models\UserInvitation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** @extends Factory<UserInvitation> */
|
||||
final class UserInvitationFactory extends Factory
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'invited_by_user_id' => User::factory(),
|
||||
'organisation_id' => Organisation::factory(),
|
||||
'event_id' => null,
|
||||
'role' => 'org_member',
|
||||
'token' => strtolower((string) Str::ulid()),
|
||||
'status' => 'pending',
|
||||
'expires_at' => now()->addDays(7),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('customers', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->string('company_name')->nullable();
|
||||
$table->enum('type', ['individual', 'company'])->default('individual');
|
||||
$table->string('email')->nullable();
|
||||
$table->string('phone', 20)->nullable();
|
||||
$table->text('address')->nullable();
|
||||
$table->string('city')->nullable();
|
||||
$table->string('postal_code', 20)->nullable();
|
||||
$table->string('country', 2)->default('NL');
|
||||
$table->text('notes')->nullable();
|
||||
$table->boolean('is_portal_enabled')->default(false);
|
||||
$table->foreignUlid('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['type', 'city']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('customers');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('locations', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->text('address')->nullable();
|
||||
$table->string('city');
|
||||
$table->string('postal_code', 20)->nullable();
|
||||
$table->string('country', 2)->default('NL');
|
||||
$table->decimal('latitude', 10, 7)->nullable();
|
||||
$table->decimal('longitude', 10, 7)->nullable();
|
||||
$table->unsignedInteger('capacity')->nullable();
|
||||
$table->string('contact_name')->nullable();
|
||||
$table->string('contact_email')->nullable();
|
||||
$table->string('contact_phone', 20)->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('city');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('locations');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('setlists', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('name');
|
||||
$table->text('description')->nullable();
|
||||
$table->unsignedInteger('total_duration_seconds')->nullable();
|
||||
$table->boolean('is_template')->default(false);
|
||||
$table->boolean('is_archived')->default(false);
|
||||
$table->foreignUlid('created_by')->constrained('users');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['is_template', 'is_archived']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('setlists');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('events', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('title');
|
||||
$table->text('description')->nullable();
|
||||
$table->foreignUlid('location_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignUlid('customer_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignUlid('setlist_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->date('event_date');
|
||||
$table->time('start_time');
|
||||
$table->time('end_time')->nullable();
|
||||
$table->time('load_in_time')->nullable();
|
||||
$table->time('soundcheck_time')->nullable();
|
||||
$table->decimal('fee', 10, 2)->nullable();
|
||||
$table->string('currency', 3)->default('EUR');
|
||||
$table->enum('status', ['draft', 'pending', 'confirmed', 'completed', 'cancelled'])->default('draft');
|
||||
$table->enum('visibility', ['private', 'members', 'public'])->default('members');
|
||||
$table->dateTime('rsvp_deadline')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->text('internal_notes')->nullable();
|
||||
$table->boolean('is_public_setlist')->default(false);
|
||||
$table->foreignUlid('created_by')->constrained('users');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['event_date', 'status']);
|
||||
$table->index('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('events');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('event_invitations', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->enum('rsvp_status', ['pending', 'available', 'unavailable', 'tentative'])->default('pending');
|
||||
$table->text('rsvp_note')->nullable();
|
||||
$table->timestamp('rsvp_responded_at')->nullable();
|
||||
$table->timestamp('invited_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['event_id', 'user_id']);
|
||||
$table->index(['user_id', 'rsvp_status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('event_invitations');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('music_numbers', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('title');
|
||||
$table->string('artist')->nullable();
|
||||
$table->string('genre')->nullable();
|
||||
$table->unsignedInteger('duration_seconds')->nullable();
|
||||
$table->string('key', 10)->nullable();
|
||||
$table->unsignedSmallInteger('tempo_bpm')->nullable();
|
||||
$table->string('time_signature', 10)->nullable();
|
||||
$table->text('lyrics')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->json('tags')->nullable();
|
||||
$table->unsignedInteger('play_count')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->foreignUlid('created_by')->constrained('users');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['is_active', 'title']);
|
||||
$table->index('genre');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('music_numbers');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('music_attachments', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('music_number_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('file_name');
|
||||
$table->string('original_name');
|
||||
$table->string('file_path');
|
||||
$table->enum('file_type', ['lyrics', 'chords', 'sheet_music', 'audio', 'other'])->default('other');
|
||||
$table->unsignedInteger('file_size');
|
||||
$table->string('mime_type');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['music_number_id', 'file_type']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('music_attachments');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('setlist_items', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('setlist_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('music_number_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->unsignedSmallInteger('position');
|
||||
$table->unsignedTinyInteger('set_number')->default(1);
|
||||
$table->boolean('is_break')->default(false);
|
||||
$table->unsignedSmallInteger('break_duration_seconds')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['setlist_id', 'position']);
|
||||
$table->index(['setlist_id', 'set_number']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('setlist_items');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('booking_requests', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('customer_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('contact_name');
|
||||
$table->string('contact_email');
|
||||
$table->string('contact_phone', 20)->nullable();
|
||||
$table->string('event_type')->nullable();
|
||||
$table->date('preferred_date');
|
||||
$table->date('alternative_date')->nullable();
|
||||
$table->time('preferred_time')->nullable();
|
||||
$table->string('location')->nullable();
|
||||
$table->unsignedInteger('expected_guests')->nullable();
|
||||
$table->decimal('budget', 10, 2)->nullable();
|
||||
$table->text('message')->nullable();
|
||||
$table->enum('status', ['new', 'contacted', 'quoted', 'accepted', 'declined', 'cancelled'])->default('new');
|
||||
$table->text('internal_notes')->nullable();
|
||||
$table->foreignUlid('assigned_to')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignUlid('converted_event_id')->nullable()->constrained('events')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['status', 'preferred_date']);
|
||||
$table->index('assigned_to');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('booking_requests');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('activity_logs', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('log_name')->nullable();
|
||||
$table->text('description');
|
||||
$table->nullableMorphs('subject');
|
||||
$table->nullableMorphs('causer');
|
||||
$table->json('properties')->nullable();
|
||||
$table->string('event')->nullable();
|
||||
$table->uuid('batch_uuid')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('log_name');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('activity_logs');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('activity_log', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('log_name')->nullable()->index();
|
||||
$table->text('description');
|
||||
$table->nullableMorphs('subject', 'subject');
|
||||
$table->string('event')->nullable();
|
||||
$table->nullableMorphs('causer', 'causer');
|
||||
$table->json('attribute_changes')->nullable();
|
||||
$table->json('properties')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$teams = config('permission.teams');
|
||||
$tableNames = config('permission.table_names');
|
||||
$columnNames = config('permission.column_names');
|
||||
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
|
||||
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
|
||||
|
||||
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
|
||||
/**
|
||||
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||
*/
|
||||
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
|
||||
$table->id(); // permission id
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['name', 'guard_name']);
|
||||
});
|
||||
|
||||
/**
|
||||
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||
*/
|
||||
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
|
||||
$table->id(); // role id
|
||||
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
|
||||
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
|
||||
}
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
if ($teams || config('permission.testing')) {
|
||||
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
|
||||
} else {
|
||||
$table->unique(['name', 'guard_name']);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
|
||||
$table->unsignedBigInteger($pivotPermission);
|
||||
|
||||
$table->string('model_type');
|
||||
$table->string($columnNames['model_morph_key'], 26);
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
|
||||
|
||||
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_permissions_permission_model_type_primary');
|
||||
} else {
|
||||
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_permissions_permission_model_type_primary');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
|
||||
$table->unsignedBigInteger($pivotRole);
|
||||
|
||||
$table->string('model_type');
|
||||
$table->string($columnNames['model_morph_key'], 26);
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
|
||||
|
||||
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_roles_role_model_type_primary');
|
||||
} else {
|
||||
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_roles_role_model_type_primary');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
|
||||
$table->unsignedBigInteger($pivotPermission);
|
||||
$table->unsignedBigInteger($pivotRole);
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
|
||||
});
|
||||
|
||||
app('cache')
|
||||
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
|
||||
->forget(config('permission.cache.key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$tableNames = config('permission.table_names');
|
||||
|
||||
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
|
||||
|
||||
Schema::dropIfExists($tableNames['role_has_permissions']);
|
||||
Schema::dropIfExists($tableNames['model_has_roles']);
|
||||
Schema::dropIfExists($tableNames['model_has_permissions']);
|
||||
Schema::dropIfExists($tableNames['roles']);
|
||||
Schema::dropIfExists($tableNames['permissions']);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Remove old band-management columns
|
||||
$table->dropIndex(['type', 'status']);
|
||||
$table->dropColumn(['phone', 'bio', 'instruments', 'avatar_path', 'type', 'role', 'status']);
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Add EventCrew columns per SCHEMA.md
|
||||
$table->string('timezone')->default('Europe/Amsterdam')->after('password');
|
||||
$table->string('locale')->default('nl')->after('timezone');
|
||||
$table->string('avatar')->nullable()->after('locale');
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
$table->dropColumn(['timezone', 'locale', 'avatar']);
|
||||
});
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('phone', 20)->nullable();
|
||||
$table->text('bio')->nullable();
|
||||
$table->json('instruments')->nullable();
|
||||
$table->string('avatar_path')->nullable();
|
||||
$table->enum('type', ['member', 'customer'])->default('member');
|
||||
$table->enum('role', ['admin', 'booking_agent', 'music_manager', 'member'])->nullable();
|
||||
$table->enum('status', ['active', 'inactive'])->default('active');
|
||||
$table->index(['type', 'status']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -10,21 +10,19 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
Schema::create('organisations', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('type');
|
||||
$table->morphs('notifiable');
|
||||
$table->json('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->string('billing_status')->default('active');
|
||||
$table->json('settings')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['notifiable_type', 'notifiable_id', 'read_at']);
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('notifications');
|
||||
Schema::dropIfExists('organisations');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('organisation_user', function (Blueprint $table) {
|
||||
$table->id(); // int AI for join performance
|
||||
$table->foreignUlid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('role');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'organisation_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('organisation_user');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('events', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('slug');
|
||||
$table->date('start_date');
|
||||
$table->date('end_date');
|
||||
$table->string('timezone')->default('Europe/Amsterdam');
|
||||
$table->enum('status', [
|
||||
'draft',
|
||||
'published',
|
||||
'registration_open',
|
||||
'buildup',
|
||||
'showday',
|
||||
'teardown',
|
||||
'closed',
|
||||
])->default('draft');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->index(['organisation_id', 'status']);
|
||||
$table->unique(['organisation_id', 'slug']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('events');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_invitations', function (Blueprint $table) {
|
||||
$table->ulid('id')->primary();
|
||||
$table->string('email');
|
||||
$table->foreignUlid('invited_by_user_id')->constrained('users')->cascadeOnDelete();
|
||||
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('event_id')->nullable()->constrained()->cascadeOnDelete();
|
||||
$table->string('role');
|
||||
$table->ulid('token')->unique();
|
||||
$table->enum('status', ['pending', 'accepted', 'expired'])->default('pending');
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['email', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_invitations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('event_user_roles', function (Blueprint $table) {
|
||||
$table->id(); // int AI for join performance
|
||||
$table->foreignUlid('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUlid('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('role');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'event_id', 'role']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('event_user_roles');
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -12,44 +13,23 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// Create admin user
|
||||
User::create([
|
||||
'name' => 'Admin User',
|
||||
'email' => 'admin@bandmanagement.nl',
|
||||
$this->call(RoleSeeder::class);
|
||||
|
||||
$admin = User::create([
|
||||
'name' => 'Super Admin',
|
||||
'email' => 'admin@eventcrew.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'type' => 'member',
|
||||
'role' => 'admin',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Create booking agent
|
||||
User::create([
|
||||
'name' => 'Booking Agent',
|
||||
'email' => 'booking@bandmanagement.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'type' => 'member',
|
||||
'role' => 'booking_agent',
|
||||
'status' => 'active',
|
||||
$admin->assignRole('super_admin');
|
||||
|
||||
$organisation = Organisation::query()->create([
|
||||
'name' => 'Demo Organisation',
|
||||
'slug' => 'demo',
|
||||
'billing_status' => 'active',
|
||||
'settings' => [],
|
||||
]);
|
||||
|
||||
// Create music manager
|
||||
User::create([
|
||||
'name' => 'Music Manager',
|
||||
'email' => 'music@bandmanagement.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'type' => 'member',
|
||||
'role' => 'music_manager',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Create regular member
|
||||
User::create([
|
||||
'name' => 'Band Member',
|
||||
'email' => 'member@bandmanagement.nl',
|
||||
'password' => Hash::make('password'),
|
||||
'type' => 'member',
|
||||
'role' => 'member',
|
||||
'status' => 'active',
|
||||
]);
|
||||
$admin->organisations()->attach($organisation->id, ['role' => 'org_admin']);
|
||||
}
|
||||
}
|
||||
|
||||
26
api/database/seeders/RoleSeeder.php
Normal file
26
api/database/seeders/RoleSeeder.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class RoleSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
// App-level
|
||||
Role::findOrCreate('super_admin', 'web');
|
||||
|
||||
// Organisation-level
|
||||
Role::findOrCreate('org_admin', 'web');
|
||||
Role::findOrCreate('org_member', 'web');
|
||||
|
||||
// Event-level
|
||||
Role::findOrCreate('event_manager', 'web');
|
||||
Role::findOrCreate('staff_coordinator', 'web');
|
||||
Role::findOrCreate('volunteer_coordinator', 'web');
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Controllers\Api\V1\AuthController;
|
||||
use App\Http\Controllers\Api\V1\EventController;
|
||||
use App\Http\Controllers\Api\V1\LoginController;
|
||||
use App\Http\Controllers\Api\V1\LogoutController;
|
||||
use App\Http\Controllers\Api\V1\MeController;
|
||||
use App\Http\Controllers\Api\V1\OrganisationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/*
|
||||
@@ -18,26 +21,24 @@ use Illuminate\Support\Facades\Route;
|
||||
// Health check
|
||||
Route::get('/', fn () => response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Band Management API v1',
|
||||
'message' => 'EventCrew API v1',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
]));
|
||||
|
||||
// Public auth routes
|
||||
Route::prefix('auth')->group(function () {
|
||||
Route::post('/login', [AuthController::class, 'login']);
|
||||
Route::post('/register', [AuthController::class, 'register']);
|
||||
});
|
||||
Route::post('auth/login', LoginController::class);
|
||||
|
||||
// Protected routes
|
||||
Route::middleware('auth:sanctum')->group(function () {
|
||||
// Auth
|
||||
Route::prefix('auth')->group(function () {
|
||||
Route::get('/user', [AuthController::class, 'user']);
|
||||
Route::post('/logout', [AuthController::class, 'logout']);
|
||||
});
|
||||
Route::get('auth/me', MeController::class);
|
||||
Route::post('auth/logout', LogoutController::class);
|
||||
|
||||
// Events
|
||||
Route::apiResource('events', EventController::class);
|
||||
Route::post('events/{event}/invite', [EventController::class, 'invite'])->name('events.invite');
|
||||
Route::post('events/{event}/rsvp', [EventController::class, 'rsvp'])->name('events.rsvp');
|
||||
// Organisations
|
||||
Route::apiResource('organisations', OrganisationController::class)
|
||||
->only(['index', 'show', 'store', 'update']);
|
||||
|
||||
// Events (nested under organisations)
|
||||
Route::apiResource('organisations.events', EventController::class)
|
||||
->only(['index', 'show', 'store', 'update']);
|
||||
});
|
||||
|
||||
52
api/tests/Feature/Auth/LoginTest.php
Normal file
52
api/tests/Feature/Auth/LoginTest.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LoginTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_can_login_with_valid_credentials(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->postJson('/api/v1/auth/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => ['user' => ['id', 'name', 'email'], 'token'],
|
||||
'message',
|
||||
])
|
||||
->assertJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function test_login_fails_with_invalid_credentials(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->postJson('/api/v1/auth/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_login_requires_email_and_password(): void
|
||||
{
|
||||
$response = $this->postJson('/api/v1/auth/login', []);
|
||||
|
||||
$response->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['email', 'password']);
|
||||
}
|
||||
}
|
||||
33
api/tests/Feature/Auth/LogoutTest.php
Normal file
33
api/tests/Feature/Auth/LogoutTest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class LogoutTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_authenticated_user_can_logout(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->postJson('/api/v1/auth/logout');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['success' => true]);
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_logout(): void
|
||||
{
|
||||
$response = $this->postJson('/api/v1/auth/logout');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
45
api/tests/Feature/Auth/MeTest.php
Normal file
45
api/tests/Feature/Auth/MeTest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_authenticated_user_can_get_profile(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$organisation = Organisation::factory()->create();
|
||||
$organisation->users()->attach($user, ['role' => 'org_admin']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson('/api/v1/auth/me');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'success',
|
||||
'data' => [
|
||||
'id', 'name', 'email', 'timezone', 'locale',
|
||||
'organisations',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertCount(1, $response->json('data.organisations'));
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_get_profile(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/auth/me');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
}
|
||||
185
api/tests/Feature/Event/EventTest.php
Normal file
185
api/tests/Feature/Event/EventTest.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Event;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
private User $orgAdmin;
|
||||
private User $outsider;
|
||||
private Organisation $organisation;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
|
||||
$this->admin = User::factory()->create();
|
||||
$this->admin->assignRole('super_admin');
|
||||
|
||||
$this->organisation = Organisation::factory()->create();
|
||||
|
||||
$this->orgAdmin = User::factory()->create();
|
||||
$this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']);
|
||||
|
||||
$this->outsider = User::factory()->create();
|
||||
}
|
||||
|
||||
// --- INDEX ---
|
||||
|
||||
public function test_org_admin_can_list_events(): void
|
||||
{
|
||||
Event::factory()->count(3)->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events");
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(3, $response->json('data'));
|
||||
}
|
||||
|
||||
public function test_outsider_cannot_list_events(): void
|
||||
{
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_list_events(): void
|
||||
{
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events");
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
// --- SHOW ---
|
||||
|
||||
public function test_org_admin_can_view_event(): 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}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['data' => ['id' => $event->id]]);
|
||||
}
|
||||
|
||||
public function test_outsider_cannot_view_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
// --- STORE ---
|
||||
|
||||
public function test_org_admin_can_create_event(): void
|
||||
{
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [
|
||||
'name' => 'Summer Festival 2026',
|
||||
'slug' => 'summer-festival-2026',
|
||||
'start_date' => '2026-07-01',
|
||||
'end_date' => '2026-07-03',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJson(['data' => ['name' => 'Summer Festival 2026']]);
|
||||
|
||||
$this->assertDatabaseHas('events', [
|
||||
'organisation_id' => $this->organisation->id,
|
||||
'slug' => 'summer-festival-2026',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_outsider_cannot_create_event(): void
|
||||
{
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [
|
||||
'name' => 'Hacked Event',
|
||||
'slug' => 'hacked-event',
|
||||
'start_date' => '2026-07-01',
|
||||
'end_date' => '2026-07-03',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_create_event(): void
|
||||
{
|
||||
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [
|
||||
'name' => 'Anon Event',
|
||||
'slug' => 'anon-event',
|
||||
'start_date' => '2026-07-01',
|
||||
'end_date' => '2026-07-03',
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
// --- UPDATE ---
|
||||
|
||||
public function test_org_admin_can_update_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->orgAdmin);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}", [
|
||||
'name' => 'Updated Festival',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['data' => ['name' => 'Updated Festival']]);
|
||||
}
|
||||
|
||||
public function test_outsider_cannot_update_event(): void
|
||||
{
|
||||
$event = Event::factory()->create(['organisation_id' => $this->organisation->id]);
|
||||
|
||||
Sanctum::actingAs($this->outsider);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}", [
|
||||
'name' => 'Hacked',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
// --- CROSS-ORG ---
|
||||
|
||||
public function test_event_from_other_org_returns_404(): void
|
||||
{
|
||||
$otherOrg = Organisation::factory()->create();
|
||||
$event = Event::factory()->create(['organisation_id' => $otherOrg->id]);
|
||||
|
||||
Sanctum::actingAs($this->admin);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}");
|
||||
|
||||
$response->assertNotFound();
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
use RefreshDatabase;
|
||||
|
||||
$response->assertStatus(200);
|
||||
public function test_the_api_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson(['success' => true]);
|
||||
}
|
||||
}
|
||||
|
||||
158
api/tests/Feature/Organisation/OrganisationTest.php
Normal file
158
api/tests/Feature/Organisation/OrganisationTest.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Organisation;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OrganisationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RoleSeeder::class);
|
||||
}
|
||||
|
||||
// --- INDEX ---
|
||||
|
||||
public function test_super_admin_can_list_all_organisations(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('super_admin');
|
||||
Organisation::factory()->count(3)->create();
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
|
||||
$response = $this->getJson('/api/v1/organisations');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(3, $response->json('data'));
|
||||
}
|
||||
|
||||
public function test_org_member_sees_only_own_organisations(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$ownOrg = Organisation::factory()->create();
|
||||
$otherOrg = Organisation::factory()->create();
|
||||
|
||||
$ownOrg->users()->attach($user, ['role' => 'org_member']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson('/api/v1/organisations');
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertCount(1, $response->json('data'));
|
||||
$this->assertEquals($ownOrg->id, $response->json('data.0.id'));
|
||||
}
|
||||
|
||||
public function test_unauthenticated_user_cannot_list_organisations(): void
|
||||
{
|
||||
$response = $this->getJson('/api/v1/organisations');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
}
|
||||
|
||||
// --- SHOW ---
|
||||
|
||||
public function test_org_member_can_view_own_organisation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org = Organisation::factory()->create();
|
||||
$org->users()->attach($user, ['role' => 'org_member']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$org->id}");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['data' => ['id' => $org->id]]);
|
||||
}
|
||||
|
||||
public function test_user_cannot_view_other_organisation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org = Organisation::factory()->create();
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson("/api/v1/organisations/{$org->id}");
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
// --- STORE ---
|
||||
|
||||
public function test_super_admin_can_create_organisation(): void
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('super_admin');
|
||||
|
||||
Sanctum::actingAs($admin);
|
||||
|
||||
$response = $this->postJson('/api/v1/organisations', [
|
||||
'name' => 'Test Org',
|
||||
'slug' => 'test-org',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJson(['data' => ['name' => 'Test Org', 'slug' => 'test-org']]);
|
||||
|
||||
$this->assertDatabaseHas('organisations', ['slug' => 'test-org']);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_create_organisation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->postJson('/api/v1/organisations', [
|
||||
'name' => 'Test Org',
|
||||
'slug' => 'test-org',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
|
||||
// --- UPDATE ---
|
||||
|
||||
public function test_org_admin_can_update_organisation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org = Organisation::factory()->create();
|
||||
$org->users()->attach($user, ['role' => 'org_admin']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$org->id}", [
|
||||
'name' => 'Updated Name',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson(['data' => ['name' => 'Updated Name']]);
|
||||
}
|
||||
|
||||
public function test_org_member_cannot_update_organisation(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$org = Organisation::factory()->create();
|
||||
$org->users()->attach($user, ['role' => 'org_member']);
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->putJson("/api/v1/organisations/{$org->id}", [
|
||||
'name' => 'Hacked Name',
|
||||
]);
|
||||
|
||||
$response->assertForbidden();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user