diff --git a/.cursor/ARCHITECTURE.md b/.cursor/ARCHITECTURE.md index d4f5472..8d6aa4e 100644 --- a/.cursor/ARCHITECTURE.md +++ b/.cursor/ARCHITECTURE.md @@ -1,7 +1,7 @@ # Crewli - Architecture > Multi-tenant SaaS platform for event- and festival management. -> Source of truth: `/resources/design/Crewli_Design_Document_v1.3.docx` +> Source of truth: `/resources/design/design-document.md` ## System Overview diff --git a/.cursor/instructions.md b/.cursor/instructions.md index e9f039d..162784e 100644 --- a/.cursor/instructions.md +++ b/.cursor/instructions.md @@ -1,9 +1,9 @@ # Crewli - Cursor AI Instructions > Multi-tenant SaaS platform for event- and festival management. -> Design Document: `/resources/design/Crewli_Design_Document_v1.3.docx` -> Dev Guide: `/resources/design/Crewli_Dev_Guide_v1.0.docx` -> Start Guide: `/resources/design/Crewli_Start_Guide_v1.0.docx` +> Design Document: `/resources/design/design-document.md` +> Dev Guide: `/resources/design/dev-guide.md` +> Start Guide: `/resources/design/start-guide.md` ## Project Overview diff --git a/CLAUDE.md b/CLAUDE.md index 0030ee9..4954121 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Crewli is a multi-tenant SaaS platform for event and festival management. Built for a professional volunteer organisation, with potential to expand as SaaS. -Design document: `/resources/design/Crewli_Design_Document_v1.3.docx` +Design document: `/resources/design/design-document.md` ## Tech stack diff --git a/README.md b/README.md index 37a3862..0201c6b 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ make db-shell | Resource | Contents | |----------|----------| -| [resources/design/](resources/design/) | **Canonical product specs** — place design documents here (often Word). Referenced by `.cursor` as source of truth for features and data model. Expected names include `Crewli_Design_Document_v1.3.docx`, `Crewli_Dev_Guide_v1.0.docx`, `Crewli_Start_Guide_v1.0.docx` (adjust versions if you update files). | +| [resources/design/](resources/design/) | **Canonical product specs** in Markdown. Referenced by `.cursor` and `CLAUDE.md` as source of truth for features and data model: `design-document.md`, `dev-guide.md`, `start-guide.md`. | | [.cursor/ARCHITECTURE.md](.cursor/ARCHITECTURE.md) | System diagram, apps, multi-tenancy, roles, event lifecycle, API route map, core schema overview (summarises `resources/design` when present) | | [.cursor/instructions.md](.cursor/instructions.md) | Quick reference, phased roadmap, module build order | | [.cursor/rules/](.cursor/rules/) | Workspace, Laravel, Vue, testing conventions | diff --git a/api/app/Http/Controllers/Api/V1/EventController.php b/api/app/Http/Controllers/Api/V1/EventController.php index 92e4188..b68f1fc 100644 --- a/api/app/Http/Controllers/Api/V1/EventController.php +++ b/api/app/Http/Controllers/Api/V1/EventController.php @@ -29,9 +29,7 @@ final class EventController extends Controller public function show(Organisation $organisation, Event $event): JsonResponse { - Gate::authorize('view', $event); - - abort_unless($event->organisation_id === $organisation->id, 404); + Gate::authorize('view', [$event, $organisation]); return $this->success(new EventResource($event->load('organisation'))); } @@ -47,9 +45,7 @@ final class EventController extends Controller public function update(UpdateEventRequest $request, Organisation $organisation, Event $event): JsonResponse { - Gate::authorize('update', $event); - - abort_unless($event->organisation_id === $organisation->id, 404); + Gate::authorize('update', [$event, $organisation]); $event->update($request->validated()); diff --git a/api/app/Http/Controllers/Api/V1/MeController.php b/api/app/Http/Controllers/Api/V1/MeController.php index 58c5fbe..c9b46d8 100644 --- a/api/app/Http/Controllers/Api/V1/MeController.php +++ b/api/app/Http/Controllers/Api/V1/MeController.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; -use App\Http\Resources\Api\V1\UserResource; +use App\Http\Resources\Api\V1\MeResource; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -13,8 +13,8 @@ final class MeController extends Controller { public function __invoke(Request $request): JsonResponse { - $user = $request->user()->load(['organisations', 'events']); + $user = $request->user()->load(['organisations', 'roles', 'permissions']); - return $this->success(new UserResource($user)); + return $this->success(new MeResource($user)); } } diff --git a/api/app/Http/Controllers/Api/V1/OrganisationController.php b/api/app/Http/Controllers/Api/V1/OrganisationController.php index dbfd506..b1e85dd 100644 --- a/api/app/Http/Controllers/Api/V1/OrganisationController.php +++ b/api/app/Http/Controllers/Api/V1/OrganisationController.php @@ -41,6 +41,8 @@ final class OrganisationController extends Controller $organisation = Organisation::create($request->validated()); + $organisation->users()->attach(auth()->id(), ['role' => 'org_admin']); + return $this->created(new OrganisationResource($organisation)); } diff --git a/api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php b/api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php index 4f1a085..baa1110 100644 --- a/api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreOrganisationRequest.php @@ -19,7 +19,7 @@ final class StoreOrganisationRequest extends FormRequest 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'], + 'billing_status' => ['sometimes', 'string', 'in:trial,active,suspended,cancelled'], 'settings' => ['sometimes', 'array'], ]; } diff --git a/api/app/Http/Resources/Api/V1/MeResource.php b/api/app/Http/Resources/Api/V1/MeResource.php new file mode 100644 index 0000000..f665ca8 --- /dev/null +++ b/api/app/Http/Resources/Api/V1/MeResource.php @@ -0,0 +1,34 @@ + $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'timezone' => $this->timezone, + 'locale' => $this->locale, + 'avatar' => $this->avatar, + 'email_verified_at' => $this->email_verified_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, + ]) + ), + 'app_roles' => $this->getRoleNames()->values()->all(), + 'permissions' => $this->getAllPermissions()->pluck('name')->values()->all(), + ]; + } +} diff --git a/api/app/Models/Event.php b/api/app/Models/Event.php index 8a62490..7f70ec5 100644 --- a/api/app/Models/Event.php +++ b/api/app/Models/Event.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -52,4 +53,19 @@ final class Event extends Model { return $this->hasMany(UserInvitation::class); } + + public function scopeDraft(Builder $query): Builder + { + return $query->where('status', 'draft'); + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', 'published'); + } + + public function scopeActive(Builder $query): Builder + { + return $query->whereIn('status', ['showday', 'buildup', 'teardown']); + } } diff --git a/api/app/Models/Scopes/OrganisationScope.php b/api/app/Models/Scopes/OrganisationScope.php index 1aecbba..ab8aba9 100644 --- a/api/app/Models/Scopes/OrganisationScope.php +++ b/api/app/Models/Scopes/OrganisationScope.php @@ -9,19 +9,32 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; /** - * Explicit organisation filter for queries ({@see Builder::withGlobalScope()}). - * Event data is currently scoped via the {@see Organisation} relationship and policies; - * when you add child models (persons, shifts, …), consider a global scope driven by - * the authenticated user’s active organisation (see `.cursor/rules/102_multi_tenancy.mdc`). + * Global scope that filters event-related models by organisation_id. + * + * Resolves the organisation from (in order): + * 1. An explicitly provided organisation ID (constructor) + * 2. The route parameter 'organisation' + * + * IMPORTANT: This scope is route-dependent — it only works when the + * 'organisation' parameter is present in the URL (e.g. /organisations/{organisation}/events). + * Routes without an organisation segment (e.g. GET /events/{event}) will NOT be scoped, + * silently bypassing multi-tenancy. All new routes that touch organisation-owned + * data MUST be nested under /organisations/{organisation}/... to guarantee scoping. */ final class OrganisationScope implements Scope { public function __construct( - private readonly string $organisationId, + private readonly ?string $organisationId = null, ) {} public function apply(Builder $builder, Model $model): void { - $builder->where($model->getTable() . '.organisation_id', $this->organisationId); + $id = $this->organisationId + ?? request()->route('organisation')?->id + ?? (is_string(request()->route('organisation')) ? request()->route('organisation') : null); + + if ($id) { + $builder->where($model->getTable() . '.organisation_id', $id); + } } } diff --git a/api/app/Models/User.php b/api/app/Models/User.php index cc757f0..8837c5b 100644 --- a/api/app/Models/User.php +++ b/api/app/Models/User.php @@ -7,6 +7,7 @@ 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\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -57,4 +58,9 @@ final class User extends Authenticatable ->withPivot('role') ->withTimestamps(); } + + public function invitations(): HasMany + { + return $this->hasMany(UserInvitation::class, 'invited_by_user_id'); + } } diff --git a/api/app/Models/UserInvitation.php b/api/app/Models/UserInvitation.php index 426b992..5282773 100644 --- a/api/app/Models/UserInvitation.php +++ b/api/app/Models/UserInvitation.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -46,4 +47,15 @@ final class UserInvitation extends Model { return $this->belongsTo(Event::class); } + + public function scopePending(Builder $query): Builder + { + return $query->where('status', 'pending'); + } + + public function scopeExpired(Builder $query): Builder + { + return $query->where('status', 'expired') + ->orWhere(fn (Builder $q) => $q->where('status', 'pending')->where('expires_at', '<', now())); + } } diff --git a/api/app/Policies/EventPolicy.php b/api/app/Policies/EventPolicy.php index e91c848..edaf7ca 100644 --- a/api/app/Policies/EventPolicy.php +++ b/api/app/Policies/EventPolicy.php @@ -16,8 +16,12 @@ final class EventPolicy || $organisation->users()->where('user_id', $user->id)->exists(); } - public function view(User $user, Event $event): bool + public function view(User $user, Event $event, ?Organisation $organisation = null): bool { + if ($organisation && $event->organisation_id !== $organisation->id) { + return false; + } + return $user->hasRole('super_admin') || $event->organisation->users()->where('user_id', $user->id)->exists(); } @@ -30,19 +34,34 @@ final class EventPolicy return $organisation->users() ->where('user_id', $user->id) - ->wherePivotIn('role', ['org_admin', 'org_member']) + ->wherePivot('role', 'org_admin') ->exists(); } - public function update(User $user, Event $event): bool + public function update(User $user, Event $event, ?Organisation $organisation = null): bool { + if ($organisation && $event->organisation_id !== $organisation->id) { + return false; + } + if ($user->hasRole('super_admin')) { return true; } - return $event->organisation->users() + // org_admin at organisation level + $isOrgAdmin = $event->organisation->users() ->where('user_id', $user->id) - ->wherePivotIn('role', ['org_admin', 'org_member']) + ->wherePivot('role', 'org_admin') + ->exists(); + + if ($isOrgAdmin) { + return true; + } + + // event_manager at event level + return $event->users() + ->where('user_id', $user->id) + ->wherePivot('role', 'event_manager') ->exists(); } } diff --git a/api/database/factories/EventFactory.php b/api/database/factories/EventFactory.php index fa049a0..d8bd7fc 100644 --- a/api/database/factories/EventFactory.php +++ b/api/database/factories/EventFactory.php @@ -14,12 +14,14 @@ final class EventFactory extends Factory /** @return array */ public function definition(): array { - $name = fake()->unique()->words(3, true); - $startDate = fake()->dateTimeBetween('+1 week', '+3 months'); + $city = fake('nl_NL')->city(); + $year = fake()->year('+2 years'); + $name = "Festival {$city} {$year}"; + $startDate = fake()->dateTimeBetween('+1 month', '+6 months'); return [ 'organisation_id' => Organisation::factory(), - 'name' => ucfirst($name), + 'name' => $name, 'slug' => str($name)->slug()->toString(), 'start_date' => $startDate, 'end_date' => fake()->dateTimeBetween($startDate, (clone $startDate)->modify('+3 days')), diff --git a/api/database/factories/OrganisationFactory.php b/api/database/factories/OrganisationFactory.php index 920335b..59ed29c 100644 --- a/api/database/factories/OrganisationFactory.php +++ b/api/database/factories/OrganisationFactory.php @@ -13,12 +13,12 @@ final class OrganisationFactory extends Factory /** @return array */ public function definition(): array { - $name = fake()->unique()->company(); + $name = fake('nl_NL')->unique()->company(); return [ 'name' => $name, - 'slug' => str($name)->slug()->toString(), - 'billing_status' => 'active', + 'slug' => str($name)->slug()->append('-' . fake()->numerify('##'))->toString(), + 'billing_status' => fake()->randomElement(['trial', 'active', 'suspended', 'cancelled']), 'settings' => [], ]; } diff --git a/api/database/migrations/2026_04_07_250000_fix_organisations_and_invitations.php b/api/database/migrations/2026_04_07_250000_fix_organisations_and_invitations.php new file mode 100644 index 0000000..1a2c88e --- /dev/null +++ b/api/database/migrations/2026_04_07_250000_fix_organisations_and_invitations.php @@ -0,0 +1,38 @@ +string('billing_status')->default('trial')->change(); + }); + + // Fix invited_by_user_id: nullOnDelete instead of cascadeOnDelete + Schema::table('user_invitations', function (Blueprint $table) { + $table->dropForeign(['invited_by_user_id']); + $table->foreignUlid('invited_by_user_id')->nullable()->change(); + $table->foreign('invited_by_user_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('organisations', function (Blueprint $table) { + $table->string('billing_status')->default('active')->change(); + }); + + Schema::table('user_invitations', function (Blueprint $table) { + $table->dropForeign(['invited_by_user_id']); + $table->foreignUlid('invited_by_user_id')->change(); + $table->foreign('invited_by_user_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } +}; diff --git a/api/tests/Feature/Auth/MeTest.php b/api/tests/Feature/Auth/MeTest.php index efeb715..e2789ef 100644 --- a/api/tests/Feature/Auth/MeTest.php +++ b/api/tests/Feature/Auth/MeTest.php @@ -6,6 +6,7 @@ namespace Tests\Feature\Auth; use App\Models\Organisation; use App\Models\User; +use Database\Seeders\RoleSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -14,6 +15,12 @@ class MeTest extends TestCase { use RefreshDatabase; + protected function setUp(): void + { + parent::setUp(); + $this->seed(RoleSeeder::class); + } + public function test_authenticated_user_can_get_profile(): void { $user = User::factory()->create(); @@ -29,13 +36,27 @@ class MeTest extends TestCase 'success', 'data' => [ 'id', 'name', 'email', 'timezone', 'locale', - 'organisations', + 'organisations', 'app_roles', 'permissions', ], ]); $this->assertCount(1, $response->json('data.organisations')); } + public function test_me_returns_app_roles_and_permissions(): void + { + $user = User::factory()->create(); + $user->assignRole('super_admin'); + + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/auth/me'); + + $response->assertOk(); + $this->assertContains('super_admin', $response->json('data.app_roles')); + $this->assertIsArray($response->json('data.permissions')); + } + public function test_unauthenticated_user_cannot_get_profile(): void { $response = $this->getJson('/api/v1/auth/me'); diff --git a/api/tests/Feature/Event/EventTest.php b/api/tests/Feature/Event/EventTest.php index 1d8803f..17342db 100644 --- a/api/tests/Feature/Event/EventTest.php +++ b/api/tests/Feature/Event/EventTest.php @@ -18,6 +18,7 @@ class EventTest extends TestCase private User $admin; private User $orgAdmin; + private User $orgMember; private User $outsider; private Organisation $organisation; @@ -34,6 +35,9 @@ class EventTest extends TestCase $this->orgAdmin = User::factory()->create(); $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + $this->orgMember = User::factory()->create(); + $this->organisation->users()->attach($this->orgMember, ['role' => 'org_member']); + $this->outsider = User::factory()->create(); } @@ -51,6 +55,18 @@ class EventTest extends TestCase $this->assertCount(3, $response->json('data')); } + public function test_org_member_can_list_events(): void + { + Event::factory()->count(2)->create(['organisation_id' => $this->organisation->id]); + + Sanctum::actingAs($this->orgMember); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events"); + + $response->assertOk(); + $this->assertCount(2, $response->json('data')); + } + public function test_outsider_cannot_list_events(): void { Sanctum::actingAs($this->outsider); @@ -114,6 +130,20 @@ class EventTest extends TestCase ]); } + public function test_org_member_cannot_create_event(): void + { + Sanctum::actingAs($this->orgMember); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ + 'name' => 'Member Event', + 'slug' => 'member-event', + 'start_date' => '2026-07-01', + 'end_date' => '2026-07-03', + ]); + + $response->assertForbidden(); + } + public function test_outsider_cannot_create_event(): void { Sanctum::actingAs($this->outsider); @@ -140,6 +170,21 @@ class EventTest extends TestCase $response->assertUnauthorized(); } + public function test_store_with_end_date_before_start_date_returns_422(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events", [ + 'name' => 'Bad Dates', + 'slug' => 'bad-dates', + 'start_date' => '2026-07-05', + 'end_date' => '2026-07-01', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['end_date']); + } + // --- UPDATE --- public function test_org_admin_can_update_event(): void @@ -156,6 +201,36 @@ class EventTest extends TestCase ->assertJson(['data' => ['name' => 'Updated Festival']]); } + public function test_event_manager_can_update_event(): void + { + $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + $eventManager = User::factory()->create(); + $this->organisation->users()->attach($eventManager, ['role' => 'org_member']); + $event->users()->attach($eventManager, ['role' => 'event_manager']); + + Sanctum::actingAs($eventManager); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}", [ + 'name' => 'Manager Updated', + ]); + + $response->assertOk() + ->assertJson(['data' => ['name' => 'Manager Updated']]); + } + + public function test_org_member_cannot_update_event(): void + { + $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + + Sanctum::actingAs($this->orgMember); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}", [ + 'name' => 'Hacked', + ]); + + $response->assertForbidden(); + } + public function test_outsider_cannot_update_event(): void { $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); @@ -171,7 +246,7 @@ class EventTest extends TestCase // --- CROSS-ORG --- - public function test_event_from_other_org_returns_404(): void + public function test_show_event_from_other_org_is_blocked(): void { $otherOrg = Organisation::factory()->create(); $event = Event::factory()->create(['organisation_id' => $otherOrg->id]); @@ -180,6 +255,33 @@ class EventTest extends TestCase $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}"); - $response->assertNotFound(); + $response->assertForbidden(); + } + + public function test_update_event_from_other_org_is_blocked(): void + { + $otherOrg = Organisation::factory()->create(); + $event = Event::factory()->create(['organisation_id' => $otherOrg->id]); + + Sanctum::actingAs($this->admin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}", [ + 'name' => 'Cross-org hack', + ]); + + $response->assertForbidden(); + } + + // --- UNAUTHENTICATED --- + + public function test_unauthenticated_user_cannot_update_event(): void + { + $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$event->id}", [ + 'name' => 'Anon Update', + ]); + + $response->assertUnauthorized(); } } diff --git a/api/tests/Feature/Organisation/OrganisationTest.php b/api/tests/Feature/Organisation/OrganisationTest.php index 59e6058..39c4bd3 100644 --- a/api/tests/Feature/Organisation/OrganisationTest.php +++ b/api/tests/Feature/Organisation/OrganisationTest.php @@ -41,7 +41,7 @@ class OrganisationTest extends TestCase { $user = User::factory()->create(); $ownOrg = Organisation::factory()->create(); - $otherOrg = Organisation::factory()->create(); + Organisation::factory()->create(); // other org $ownOrg->users()->attach($user, ['role' => 'org_member']); @@ -109,6 +109,28 @@ class OrganisationTest extends TestCase $this->assertDatabaseHas('organisations', ['slug' => 'test-org']); } + public function test_store_attaches_creator_as_org_admin(): void + { + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + + Sanctum::actingAs($admin); + + $response = $this->postJson('/api/v1/organisations', [ + 'name' => 'New Org', + 'slug' => 'new-org', + ]); + + $response->assertCreated(); + + $org = Organisation::where('slug', 'new-org')->first(); + $this->assertDatabaseHas('organisation_user', [ + 'user_id' => $admin->id, + 'organisation_id' => $org->id, + 'role' => 'org_admin', + ]); + } + public function test_non_admin_cannot_create_organisation(): void { $user = User::factory()->create(); @@ -123,6 +145,36 @@ class OrganisationTest extends TestCase $response->assertForbidden(); } + public function test_store_with_invalid_data_returns_422(): void + { + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + + Sanctum::actingAs($admin); + + $response = $this->postJson('/api/v1/organisations', []); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['name', 'slug']); + } + + public function test_store_with_duplicate_slug_returns_422(): void + { + $admin = User::factory()->create(); + $admin->assignRole('super_admin'); + Organisation::factory()->create(['slug' => 'taken-slug']); + + Sanctum::actingAs($admin); + + $response = $this->postJson('/api/v1/organisations', [ + 'name' => 'New Org', + 'slug' => 'taken-slug', + ]); + + $response->assertUnprocessable() + ->assertJsonValidationErrors(['slug']); + } + // --- UPDATE --- public function test_org_admin_can_update_organisation(): void @@ -155,4 +207,18 @@ class OrganisationTest extends TestCase $response->assertForbidden(); } + + public function test_non_member_cannot_update_organisation(): void + { + $user = User::factory()->create(); + $org = Organisation::factory()->create(); + + Sanctum::actingAs($user); + + $response = $this->putJson("/api/v1/organisations/{$org->id}", [ + 'name' => 'Hacked Name', + ]); + + $response->assertForbidden(); + } } diff --git a/apps/admin/auto-imports.d.ts b/apps/admin/auto-imports.d.ts index 200bbb9..51748e9 100644 --- a/apps/admin/auto-imports.d.ts +++ b/apps/admin/auto-imports.d.ts @@ -6,13 +6,14 @@ // biome-ignore lint: disable export {} declare global { - const $api: typeof import('./src/utils/api')['$api'] + const $api: typeof import('./src/lib/axios')['$api'] const COOKIE_MAX_AGE_1_YEAR: typeof import('./src/utils/constants')['COOKIE_MAX_AGE_1_YEAR'] const CreateUrl: typeof import('./src/@core/composable/CreateUrl')['CreateUrl'] const EffectScope: typeof import('vue')['EffectScope'] const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] const alphaDashValidator: typeof import('./src/@core/utils/validators')['alphaDashValidator'] const alphaValidator: typeof import('./src/@core/utils/validators')['alphaValidator'] + const apiClient: typeof import('./src/lib/axios')['apiClient'] const asyncComputed: typeof import('@vueuse/core')['asyncComputed'] const autoResetRef: typeof import('@vueuse/core')['autoResetRef'] const avatarText: typeof import('./src/@core/utils/formatters')['avatarText'] @@ -378,12 +379,13 @@ import { UnwrapRef } from 'vue' declare module 'vue' { interface GlobalComponents {} interface ComponentCustomProperties { - readonly $api: UnwrapRef + readonly $api: UnwrapRef readonly COOKIE_MAX_AGE_1_YEAR: UnwrapRef readonly EffectScope: UnwrapRef readonly acceptHMRUpdate: UnwrapRef readonly alphaDashValidator: UnwrapRef readonly alphaValidator: UnwrapRef + readonly apiClient: UnwrapRef readonly asyncComputed: UnwrapRef readonly autoResetRef: UnwrapRef readonly avatarText: UnwrapRef diff --git a/apps/admin/src/composables/useEvents.ts b/apps/admin/src/composables/useEvents.ts index 71f34a0..8344cc3 100644 --- a/apps/admin/src/composables/useEvents.ts +++ b/apps/admin/src/composables/useEvents.ts @@ -1,5 +1,5 @@ import { computed, ref } from 'vue' -import { apiClient } from '@/lib/api-client' +import { apiClient } from '@/lib/axios' import { useCurrentOrganisationId } from '@/composables/useOrganisationContext' import type { ApiResponse, CreateEventData, Event, Pagination, UpdateEventData } from '@/types/events' diff --git a/apps/admin/src/lib/api-client.ts b/apps/admin/src/lib/axios.ts similarity index 59% rename from apps/admin/src/lib/api-client.ts rename to apps/admin/src/lib/axios.ts index 4d81539..c29eeb7 100644 --- a/apps/admin/src/lib/api-client.ts +++ b/apps/admin/src/lib/axios.ts @@ -1,12 +1,7 @@ import axios from 'axios' import { parse } from 'cookie-es' -import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios' +import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios' -/** - * Single axios instance for the real Laravel API (VITE_API_URL). - * Auth: Bearer token from cookie 'accessToken' (set by login). - * Use this for all Crewli API calls; useApi (composables/useApi) stays for Vuexy demo/mock endpoints. - */ const apiClient: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_URL, headers: { @@ -57,7 +52,6 @@ apiClient.interceptors.response.use( } if (error.response?.status === 401) { - // Clear auth cookies (align with utils/api.ts / login flow) document.cookie = 'accessToken=; path=/; max-age=0' document.cookie = 'userData=; path=/; max-age=0' document.cookie = 'userAbilityRules=; path=/; max-age=0' @@ -70,4 +64,42 @@ apiClient.interceptors.response.use( }, ) +type ApiOptions = { + method?: string + body?: unknown + query?: Record + onResponseError?: (ctx: { response: { status: number; _data?: { errors?: Record; message?: string } } }) => void +} + +/** + * Thin ofetch-style wrapper kept for Vuexy template compatibility. + * Prefer apiClient directly in new Crewli code. + */ +export async function $api(url: string, options: ApiOptions = {}): Promise { + const { method = 'GET', body, query, onResponseError } = options + + const config: AxiosRequestConfig = { + method: method.toLowerCase() as AxiosRequestConfig['method'], + url, + params: query, + data: body, + } + + try { + const response = await apiClient.request(config) + return response.data + } + catch (error: any) { + if (onResponseError && error.response) { + onResponseError({ + response: { + status: error.response.status, + _data: error.response.data, + }, + }) + } + throw error + } +} + export { apiClient } diff --git a/apps/admin/src/lib/query-client.ts b/apps/admin/src/lib/query-client.ts new file mode 100644 index 0000000..9f90d29 --- /dev/null +++ b/apps/admin/src/lib/query-client.ts @@ -0,0 +1,12 @@ +import type { VueQueryPluginOptions } from '@tanstack/vue-query' + +export const queryClientConfig: VueQueryPluginOptions = { + queryClientConfig: { + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + retry: 1, + }, + }, + }, +} diff --git a/apps/admin/src/main.ts b/apps/admin/src/main.ts index 159c494..abe11a7 100644 --- a/apps/admin/src/main.ts +++ b/apps/admin/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from 'vue' import { VueQueryPlugin } from '@tanstack/vue-query' +import { queryClientConfig } from '@/lib/query-client' import App from '@/App.vue' import { registerPlugins } from '@core/utils/plugins' @@ -18,13 +19,7 @@ app.config.errorHandler = (err, instance, info) => { } // Register plugins -app.use(VueQueryPlugin, { - queryClientConfig: { - defaultOptions: { - queries: { staleTime: 1000 * 60 * 5, retry: 1 }, - }, - }, -}) +app.use(VueQueryPlugin, queryClientConfig) try { registerPlugins(app) diff --git a/apps/admin/src/utils/api.ts b/apps/admin/src/utils/api.ts deleted file mode 100644 index 27a755a..0000000 --- a/apps/admin/src/utils/api.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { AxiosRequestConfig } from 'axios' -import { apiClient } from '@/lib/api-client' - -type ApiOptions = { - method?: string - body?: unknown - query?: Record - onResponseError?: (ctx: { response: { status: number; _data?: { errors?: Record; message?: string } } }) => void -} - -/** - * Thin ofetch-style wrapper around the single axios client (lib/axios). - * Use apiClient from @/lib/axios directly in new code; $api remains for Vuexy template compatibility. - */ -export async function $api(url: string, options: ApiOptions = {}): Promise { - const { method = 'GET', body, query, onResponseError } = options - - const config: AxiosRequestConfig = { - method: method.toLowerCase() as AxiosRequestConfig['method'], - url, - params: query, - data: body, - } - - try { - const response = await apiClient.request(config) - return response.data - } - catch (error: any) { - if (onResponseError && error.response) { - onResponseError({ - response: { - status: error.response.status, - _data: error.response.data, - }, - }) - } - throw error - } -} diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 001b81c..1f35e9f 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -73,6 +73,7 @@ export default defineConfig({ './src/@core/composable/', './src/composables/', './src/utils/', + './src/lib/', './src/plugins/*/composables/*', ], vueTemplate: true, diff --git a/apps/app/src/composables/useEvents.ts b/apps/app/src/composables/useEvents.ts index b060586..a0942f3 100644 --- a/apps/app/src/composables/useEvents.ts +++ b/apps/app/src/composables/useEvents.ts @@ -1,5 +1,5 @@ import { computed, ref } from 'vue' -import { apiClient } from '@/lib/api-client' +import { apiClient } from '@/lib/axios' import { useCurrentOrganisationId } from '@/composables/useOrganisationContext' import type { ApiResponse, Event, Pagination } from '@/types/events' diff --git a/apps/app/src/lib/api-client.ts b/apps/app/src/lib/axios.ts similarity index 86% rename from apps/app/src/lib/api-client.ts rename to apps/app/src/lib/axios.ts index 3e66de5..7b555d3 100644 --- a/apps/app/src/lib/api-client.ts +++ b/apps/app/src/lib/axios.ts @@ -2,11 +2,6 @@ import axios from 'axios' import { parse } from 'cookie-es' import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios' -/** - * Single axios instance for the Laravel API (`VITE_API_URL`, e.g. …/api/v1). - * Auth: Bearer token from cookie `accessToken` (set by login). - * Use composables built on this client for real API calls; Vuexy `useApi` remains for demos/mocks. - */ const apiClient: AxiosInstance = axios.create({ baseURL: import.meta.env.VITE_API_URL, headers: { diff --git a/apps/app/src/lib/query-client.ts b/apps/app/src/lib/query-client.ts new file mode 100644 index 0000000..9f90d29 --- /dev/null +++ b/apps/app/src/lib/query-client.ts @@ -0,0 +1,12 @@ +import type { VueQueryPluginOptions } from '@tanstack/vue-query' + +export const queryClientConfig: VueQueryPluginOptions = { + queryClientConfig: { + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + retry: 1, + }, + }, + }, +} diff --git a/apps/app/src/main.ts b/apps/app/src/main.ts index dc67339..902a514 100644 --- a/apps/app/src/main.ts +++ b/apps/app/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from 'vue' import { VueQueryPlugin } from '@tanstack/vue-query' +import { queryClientConfig } from '@/lib/query-client' import App from '@/App.vue' import { registerPlugins } from '@core/utils/plugins' @@ -14,13 +15,7 @@ const app = createApp(App) // Register plugins registerPlugins(app) -app.use(VueQueryPlugin, { - queryClientConfig: { - defaultOptions: { - queries: { staleTime: 1000 * 60 * 5, retry: 1 }, - }, - }, -}) +app.use(VueQueryPlugin, queryClientConfig) // Mount vue app app.mount('#app') diff --git a/apps/app/src/pages/login.vue b/apps/app/src/pages/login.vue index 15715c1..2365f21 100644 --- a/apps/app/src/pages/login.vue +++ b/apps/app/src/pages/login.vue @@ -10,7 +10,7 @@ import authV2MaskDark from '@images/pages/misc-mask-dark.png' import authV2MaskLight from '@images/pages/misc-mask-light.png' import { VNodeRenderer } from '@layouts/components/VNodeRenderer' import { themeConfig } from '@themeConfig' -import { apiClient } from '@/lib/api-client' +import { apiClient } from '@/lib/axios' import { emailValidator, requiredValidator } from '@core/utils/validators' definePage({ @@ -54,7 +54,7 @@ async function handleLogin() { }) if (data.success && data.data) { - // Store token in cookie (api-client reads from accessToken cookie) + // Store token in cookie (axios interceptor reads from accessToken cookie) document.cookie = `accessToken=${data.data.token}; path=/` // Store user data in cookie if needed diff --git a/apps/portal/auto-imports.d.ts b/apps/portal/auto-imports.d.ts index b6a92e7..72bfd79 100644 --- a/apps/portal/auto-imports.d.ts +++ b/apps/portal/auto-imports.d.ts @@ -375,7 +375,6 @@ import { UnwrapRef } from 'vue' declare module 'vue' { interface GlobalComponents {} interface ComponentCustomProperties { - readonly $api: UnwrapRef readonly COOKIE_MAX_AGE_1_YEAR: UnwrapRef readonly EffectScope: UnwrapRef readonly acceptHMRUpdate: UnwrapRef @@ -527,7 +526,6 @@ declare module 'vue' { readonly useAbs: UnwrapRef readonly useActiveElement: UnwrapRef readonly useAnimate: UnwrapRef - readonly useApi: UnwrapRef readonly useArrayDifference: UnwrapRef readonly useArrayEvery: UnwrapRef readonly useArrayFilter: UnwrapRef diff --git a/apps/portal/src/lib/api-client.ts b/apps/portal/src/lib/axios.ts similarity index 100% rename from apps/portal/src/lib/api-client.ts rename to apps/portal/src/lib/axios.ts diff --git a/apps/portal/src/lib/query-client.ts b/apps/portal/src/lib/query-client.ts new file mode 100644 index 0000000..9f90d29 --- /dev/null +++ b/apps/portal/src/lib/query-client.ts @@ -0,0 +1,12 @@ +import type { VueQueryPluginOptions } from '@tanstack/vue-query' + +export const queryClientConfig: VueQueryPluginOptions = { + queryClientConfig: { + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 minutes + retry: 1, + }, + }, + }, +} diff --git a/apps/portal/src/main.ts b/apps/portal/src/main.ts index dc67339..902a514 100644 --- a/apps/portal/src/main.ts +++ b/apps/portal/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from 'vue' import { VueQueryPlugin } from '@tanstack/vue-query' +import { queryClientConfig } from '@/lib/query-client' import App from '@/App.vue' import { registerPlugins } from '@core/utils/plugins' @@ -14,13 +15,7 @@ const app = createApp(App) // Register plugins registerPlugins(app) -app.use(VueQueryPlugin, { - queryClientConfig: { - defaultOptions: { - queries: { staleTime: 1000 * 60 * 5, retry: 1 }, - }, - }, -}) +app.use(VueQueryPlugin, queryClientConfig) // Mount vue app app.mount('#app')