feat: initial commit - Band Management application

This commit is contained in:
2026-01-06 03:11:46 +01:00
commit 34e12e00b3
24543 changed files with 3991790 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
<?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');
}
}

View File

@@ -0,0 +1,154 @@
<?php
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 Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
final class EventController extends Controller
{
/**
* Display a listing of events.
*/
public function index(): EventCollection
{
Gate::authorize('viewAny', Event::class);
$events = Event::query()
->with(['location', 'customer'])
->latest('event_date')
->paginate();
return new EventCollection($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
{
Gate::authorize('view', $event);
return new EventResource(
$event->load(['location', 'customer', 'setlist.items.musicNumber', 'invitations.user', 'creator'])
);
}
/**
* Update the specified event.
*/
public function update(UpdateEventRequest $request, Event $event): JsonResponse
{
Gate::authorize('update', $event);
$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'
);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
abstract class Controller
{
protected function success(mixed $data = null, string $message = 'Success', int $code = 200): JsonResponse
{
return response()->json([
'success' => true,
'data' => $data,
'message' => $message,
], $code);
}
protected function created(mixed $data = null, string $message = 'Created'): JsonResponse
{
return $this->success($data, $message, 201);
}
protected function error(string $message, int $code = 400, array $errors = []): JsonResponse
{
$response = [
'success' => false,
'message' => $message,
];
if (!empty($errors)) {
$response['errors'] = $errors;
}
return response()->json($response, $code);
}
protected function unauthorized(string $message = 'Unauthorized'): JsonResponse
{
return $this->error($message, 401);
}
protected function forbidden(string $message = 'Forbidden'): JsonResponse
{
return $this->error($message, 403);
}
protected function notFound(string $message = 'Resource not found'): JsonResponse
{
return $this->error($message, 404);
}
}

View File

@@ -0,0 +1,32 @@
<?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.',
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
final class LoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string', 'min:8'],
];
}
public function messages(): array
{
return [
'email.required' => 'Email is required',
'email.email' => 'Please enter a valid email address',
'password.required' => 'Password is required',
'password.min' => 'Password must be at least 8 characters',
];
}
}

View File

@@ -0,0 +1,38 @@
<?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',
];
}
}

View File

@@ -0,0 +1,33 @@
<?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.',
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
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
{
public function authorize(): bool
{
return true;
}
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.',
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
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
{
public function authorize(): bool
{
return true;
}
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.',
];
}
}

View File

@@ -0,0 +1,33 @@
<?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(),
];
}
}

View File

@@ -0,0 +1,38 @@
<?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(),
],
],
];
}
}

View File

@@ -0,0 +1,28 @@
<?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')),
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class EventResource extends JsonResource
{
public function toArray(Request $request): array
{
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')),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,33 @@
<?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(),
];
}
}

View File

@@ -0,0 +1,26 @@
<?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(),
];
}
}

View File

@@ -0,0 +1,34 @@
<?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(),
];
}
}

View File

@@ -0,0 +1,27 @@
<?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')),
];
}
}

View File

@@ -0,0 +1,29 @@
<?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(),
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'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,
'email_verified_at' => $this->email_verified_at?->toIso8601String(),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}