diff --git a/app/Http/Controllers/Api/AuthenticatedApiController.php b/app/Http/Controllers/Api/AuthenticatedApiController.php index 85c6155..4014ca4 100644 --- a/app/Http/Controllers/Api/AuthenticatedApiController.php +++ b/app/Http/Controllers/Api/AuthenticatedApiController.php @@ -21,9 +21,97 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; +use Illuminate\Validation\ValidationException; class AuthenticatedApiController extends Controller { + /** + * Get groups by user email + * + * Retrieve all groups associated with a specific user's email address. + * + * @group External API + * + * @subgroup Group Management + * + * @queryParam email string required The email address of the user. Example: existing.user@example.com + */ + public function getGroups(Request $request): JsonResponse + { + $request->validate([ + 'email' => 'required|email', + ]); + + $user = User::where('email', $request->input('email'))->first(); + + if (! $user) { + return response()->json(['data' => []]); + } + + $groups = $user->groups() + ->select('user_groups.*', 'user_user_group.role') + ->orderBy('user_groups.name') + ->get(); + + return response()->json([ + 'data' => $groups->map(fn (UserGroup $group) => [ + 'id' => $group->id, + 'name' => $group->name, + 'slug' => $group->slug, + 'role' => $group->pivot?->role, + ]), + ]); + } + + /** + * Create a new group + * + * Create a group and optionally attach an owner by email. This supports workspace creation in upstream systems. + * + * @group External API + * + * @subgroup Group Management + * + * @bodyParam name string required Human-readable group name. Example: Acme Workspace + * @bodyParam owner_email email optional Existing user email to attach as group owner. Example: owner@example.com + * + * @response 201 { + * "message": "Group created", + * "data": { + * "id": 12, + * "name": "Acme Workspace", + * "slug": "acme-workspace" + * } + * } + */ + public function createGroup(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'owner_email' => 'nullable|email|exists:users,email', + ]); + + $group = UserGroup::create([ + 'name' => $validated['name'], + ]); + + if (! empty($validated['owner_email'])) { + $owner = User::where('email', $validated['owner_email'])->firstOrFail(); + $owner->groups()->syncWithoutDetaching([ + $group->id => ['role' => UserGroupRoleEnum::OWNER->value], + ]); + } + + return response()->json([ + 'message' => 'Group created', + 'data' => [ + 'id' => $group->id, + 'name' => $group->name, + 'slug' => $group->slug, + ], + ], 201); + } + /** * Get all store apps * @@ -96,24 +184,63 @@ public function getEnums(): JsonResponse * * @subgroup Instance Management * - * @queryParam email string required The email address of the user. Example: existing.user@example.com + * @queryParam email string optional Limit results to groups associated with this email address. Example: existing.user@example.com + * @queryParam group_id integer optional Limit results to a specific group id the user belongs to. Example: 12 + * @queryParam group_slug string optional Limit results to a specific group slug the user belongs to. Example: acme-workspace */ public function getInstances(Request $request): JsonResponse { - $request->validate([ - 'email' => 'required|email', + $validated = $request->validate([ + 'email' => 'nullable|email', + 'group_id' => 'nullable|integer|exists:user_groups,id', + 'group_slug' => 'nullable|string|exists:user_groups,slug', ]); - $user = User::where('email', $request->input('email'))->first(); + if (! $request->filled('email') && ! $request->filled('group_id') && ! $request->filled('group_slug')) { + throw ValidationException::withMessages([ + 'email' => ['At least one of email, group_id, or group_slug is required.'], + ]); + } - if (! $user) { - return response()->json(['data' => []]); + if ($request->filled('group_id') && $request->filled('group_slug')) { + throw ValidationException::withMessages([ + 'group_id' => ['Only one of group_id or group_slug may be provided.'], + 'group_slug' => ['Only one of group_id or group_slug may be provided.'], + ]); } - // Fetch instances for all groups this user is associated with - $instances = PolydockAppInstance::whereIn('user_group_id', $user->groups()->pluck('user_groups.id')) - ->with(['storeApp']) - ->get(); + $targetGroup = null; + + if (isset($validated['group_id'])) { + $targetGroup = UserGroup::findOrFail($validated['group_id']); + } elseif (isset($validated['group_slug'])) { + $targetGroup = UserGroup::where('slug', $validated['group_slug'])->firstOrFail(); + } + + /** @var \App\Models\User $tokenUser */ + $tokenUser = $request->user(); + + if ($targetGroup !== null && ! $tokenUser->groups()->whereKey($targetGroup->id)->exists()) { + abort(403, 'You do not have access to the selected group.'); + } + + $instanceQuery = PolydockAppInstance::query()->with(['storeApp', 'userGroup']); + + if ($request->filled('email')) { + $user = User::where('email', $validated['email'])->first(); + + if (! $user) { + return response()->json(['data' => []]); + } + + $instanceQuery->whereIn('user_group_id', $user->groups()->pluck('user_groups.id')); + } + + if ($targetGroup !== null) { + $instanceQuery->where('user_group_id', $targetGroup->id); + } + + $instances = $instanceQuery->get(); $formattedInstances = $instances->map(fn (PolydockAppInstance $instance) => [ 'uuid' => $instance->uuid, @@ -126,6 +253,11 @@ public function getInstances(Request $request): JsonResponse 'uuid' => $instance->storeApp->uuid, 'name' => $instance->storeApp->name, ], + 'group' => $instance->userGroup ? [ + 'id' => $instance->userGroup->id, + 'name' => $instance->userGroup->name, + 'slug' => $instance->userGroup->slug, + ] : null, 'created_at' => $instance->created_at, ]); @@ -149,6 +281,9 @@ public function getInstances(Request $request): JsonResponse * @bodyParam storeAppId string required The UUID of the store app to provision. Example: 3a105da1-9c87-43ca-9ac8-72787fc5e315 * @bodyParam name string optional The display name for this instance. Defaults to lagoon-project-name if not provided. Example: "My awesome instance" * @bodyParam label string optional A free-form human-readable label for this instance. Not used as an identifier; may contain spaces and special characters. Example: "Acme Corp trial" + * @bodyParam group_id integer optional Existing group id to provision the instance into. If omitted, the user's primary group is used or created. Example: 12 + * @bodyParam group_slug string optional Existing group slug to provision the instance into. If omitted, the user's primary group is used or created. Example: acme-workspace + * @bodyParam group_name string optional Create a new group with this name and provision the instance into it. Example: Acme Workspace * @bodyParam secret object optional Sensitive AI and VectorDB credentials. Example: {"ai": {"llm_url": "https://llm", "api_key": "sk-123"}, "vector": {"db_host": "localhost", "db_port": 5432, "db_name": "db_d1234", "db_user": "admin", "db_pass": "pass"}} * @bodyParam secret.ai object optional AI LLM configuration. * @bodyParam secret.ai.llm_url string optional The LLM API base URL. Example: https://llm.local @@ -180,6 +315,9 @@ public function createInstance(Request $request): JsonResponse 'storeAppId' => 'required|string|exists:polydock_store_apps,uuid', 'name' => 'nullable|string|max:255', 'label' => 'nullable|string|max:255', + 'group_id' => 'nullable|integer|exists:user_groups,id', + 'group_slug' => 'nullable|string|exists:user_groups,slug', + 'group_name' => 'nullable|string|max:255', 'secret' => 'nullable|array', 'secret.ai' => 'nullable|array', 'secret.ai.llm_url' => 'nullable|string', @@ -210,6 +348,19 @@ function ($attribute, $value, $fail) { ], ]); + if ($request->filled('group_id') && $request->filled('group_slug')) { + throw ValidationException::withMessages([ + 'group_id' => ['Only one of group_id or group_slug may be provided.'], + 'group_slug' => ['Only one of group_id or group_slug may be provided.'], + ]); + } + + if ($request->filled('group_name') && ($request->filled('group_id') || $request->filled('group_slug'))) { + throw ValidationException::withMessages([ + 'group_name' => ['group_name cannot be combined with group_id or group_slug.'], + ]); + } + $email = $request->input('email'); // @todo Track migration strategy for when a user's static email changes in the future, transitioning to UUIDs. @@ -242,14 +393,7 @@ function ($attribute, $value, $fail) { $user->save(); } - // Find or create a default primary user group for this user if they don't have one - $primaryGroup = $user->primaryGroups()->first(); - if (! $primaryGroup) { - $primaryGroup = UserGroup::create([ - 'name' => "Personal Group - $user->email", - ]); - $user->groups()->attach($primaryGroup->id, ['role' => UserGroupRoleEnum::OWNER->value]); - } + $primaryGroup = $this->resolveTargetGroup($request, $user); $storeApp = PolydockStoreApp::where('uuid', $request->input('storeAppId'))->firstOrFail(); @@ -297,11 +441,78 @@ function ($attribute, $value, $fail) { 'uuid' => $instance->uuid, 'name' => $instance->name, 'label' => $instance->getKeyValue('instance-label') ?: null, + 'group' => [ + 'id' => $primaryGroup->id, + 'name' => $primaryGroup->name, + 'slug' => $primaryGroup->slug, + ], 'status' => $instance->status?->value, ], ], 201); } + /** + * Assign an instance to an existing group + * + * Reassign an existing app instance to another group. Intended for migration and backfill workflows. + * + * @group External API + * + * @subgroup Instance Management + * + * @urlParam uuid string required The UUID of the instance. Example: 3a105da1-9c87-43ca-9ac8-72787fc5e315 + * @bodyParam group_id integer optional Existing group id to assign the instance to. Example: 12 + * @bodyParam group_slug string optional Existing group slug to assign the instance to. Example: acme-workspace + */ + public function assignInstanceToGroup(Request $request, string $uuid): JsonResponse + { + $validated = $request->validate([ + 'group_id' => 'nullable|integer|exists:user_groups,id', + 'group_slug' => 'nullable|string|exists:user_groups,slug', + ]); + + if (! $request->filled('group_id') && ! $request->filled('group_slug')) { + throw ValidationException::withMessages([ + 'group_id' => ['Either group_id or group_slug is required.'], + ]); + } + + if ($request->filled('group_id') && $request->filled('group_slug')) { + throw ValidationException::withMessages([ + 'group_id' => ['Only one of group_id or group_slug may be provided.'], + 'group_slug' => ['Only one of group_id or group_slug may be provided.'], + ]); + } + + $instance = PolydockAppInstance::where('uuid', $uuid)->firstOrFail(); + + $group = isset($validated['group_id']) + ? UserGroup::findOrFail($validated['group_id']) + : UserGroup::where('slug', $validated['group_slug'])->firstOrFail(); + + /** @var \App\Models\User $user */ + $user = $request->user(); + + if (! $user->groups()->whereKey($group->id)->exists()) { + abort(403, 'You do not have access to the selected group.'); + } + + $instance->user_group_id = $group->id; + $instance->save(); + + return response()->json([ + 'message' => 'Instance assigned to group', + 'data' => [ + 'uuid' => $instance->uuid, + 'group' => [ + 'id' => $group->id, + 'name' => $group->name, + 'slug' => $group->slug, + ], + ], + ]); + } + /** * Get instance status * @@ -398,4 +609,39 @@ public function deleteInstance(string $uuid): JsonResponse ], ]); } + + private function resolveTargetGroup(Request $request, User $user): UserGroup + { + if ($request->filled('group_id')) { + return UserGroup::findOrFail($request->integer('group_id')); + } + + if ($request->filled('group_slug')) { + return UserGroup::where('slug', $request->string('group_slug')->toString())->firstOrFail(); + } + + if ($request->filled('group_name')) { + $group = UserGroup::create([ + 'name' => $request->string('group_name')->toString(), + ]); + + $user->groups()->syncWithoutDetaching([ + $group->id => ['role' => UserGroupRoleEnum::OWNER->value], + ]); + + return $group; + } + + $primaryGroup = $user->primaryGroups()->first(); + if ($primaryGroup) { + return $primaryGroup; + } + + $primaryGroup = UserGroup::create([ + 'name' => "Personal Group - $user->email", + ]); + $user->groups()->attach($primaryGroup->id, ['role' => UserGroupRoleEnum::OWNER->value]); + + return $primaryGroup; + } } diff --git a/routes/api.php b/routes/api.php index 664809a..2ecd0fb 100644 --- a/routes/api.php +++ b/routes/api.php @@ -14,6 +14,7 @@ // Routes consumed by MoaD - read operations Route::middleware('instances.read.ability')->group(function () { + Route::get('/groups', [AuthenticatedApiController::class, 'getGroups'])->name('api.groups.get'); Route::get('/store-apps', [AuthenticatedApiController::class, 'getStoreApps'])->name('api.store-apps'); Route::get('/instances', [AuthenticatedApiController::class, 'getInstances'])->name('api.instances.get'); Route::get('/instance/{uuid}/status', [AuthenticatedApiController::class, 'getInstanceStatus'])->name('api.instance.status'); @@ -22,7 +23,9 @@ // Routes consumed by MoaD - write operations Route::middleware('instances.write.ability')->group(function () { + Route::post('/groups', [AuthenticatedApiController::class, 'createGroup'])->name('api.groups.create'); Route::post('/instance', [AuthenticatedApiController::class, 'createInstance'])->name('api.instance.create'); + Route::patch('/instance/{uuid}/group', [AuthenticatedApiController::class, 'assignInstanceToGroup'])->name('api.instance.assign-group'); Route::delete('/instance/{uuid}', [AuthenticatedApiController::class, 'deleteInstance'])->name('api.instance.delete'); }); }); diff --git a/tests/Feature/Api/AuthenticatedApiTest.php b/tests/Feature/Api/AuthenticatedApiTest.php index 841bc9b..27f5abc 100644 --- a/tests/Feature/Api/AuthenticatedApiTest.php +++ b/tests/Feature/Api/AuthenticatedApiTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Api; +use App\Enums\UserGroupRoleEnum; use App\Events\PolydockAppInstanceCreatedWithNewStatus; use App\Events\PolydockAppInstanceStatusChanged; use App\Models\PolydockAppInstance; @@ -147,6 +148,42 @@ public function test_get_instances_returns_user_instances(): void $response->assertOk(); $this->assertCount(1, $response->json('data')); $this->assertEquals($instance->uuid, $response->json('data.0.uuid')); + $this->assertEquals($group->id, $response->json('data.0.group.id')); + $this->assertEquals($group->slug, $response->json('data.0.group.slug')); + } + + public function test_get_groups_returns_groups_for_user_email(): void + { + Sanctum::actingAs($this->user, ['instances.read']); + + $group = UserGroup::create(['name' => 'Workspace Group']); + $this->user->groups()->attach($group->id, ['role' => 'owner']); + + $response = $this->getJson('/api/groups?email='.$this->user->email); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $this->assertEquals($group->id, $response->json('data.0.id')); + $this->assertEquals($group->slug, $response->json('data.0.slug')); + $this->assertEquals('owner', $response->json('data.0.role')); + } + + public function test_create_group_creates_group_and_assigns_owner(): void + { + Sanctum::actingAs($this->user, ['instances.write']); + + $response = $this->postJson('/api/groups', [ + 'name' => 'Created Workspace', + 'owner_email' => $this->user->email, + ]); + + $response->assertCreated(); + $groupId = $response->json('data.id'); + $group = UserGroup::find($groupId); + + $this->assertNotNull($group); + $this->assertEquals('created-workspace', $group->slug); + $this->assertTrue($this->user->groups()->whereKey($groupId)->exists()); } public function test_create_instance_provisions_instance_and_creates_user(): void @@ -174,6 +211,66 @@ public function test_create_instance_provisions_instance_and_creates_user(): voi $this->assertEquals(1, $instanceCount); } + public function test_create_instance_with_group_name_creates_group_for_workspace_flow(): void + { + Sanctum::actingAs($this->user, ['instances.write']); + + $response = $this->postJson('/api/instance', [ + 'email' => $this->user->email, + 'storeAppId' => $this->storeApp->uuid, + 'group_name' => 'Acme Workspace', + ]); + + $response->assertCreated(); + $groupId = $response->json('data.group.id'); + $group = UserGroup::find($groupId); + $instance = PolydockAppInstance::where('uuid', $response->json('data.uuid'))->first(); + + $this->assertNotNull($group); + $this->assertEquals('acme-workspace', $group->slug); + $this->assertEquals($group->id, $instance->user_group_id); + $this->assertTrue($this->user->groups()->whereKey($group->id)->exists()); + } + + public function test_create_instance_with_existing_group_id_assigns_instance_to_group(): void + { + Sanctum::actingAs($this->user, ['instances.write']); + + $group = UserGroup::create(['name' => 'Shared Workspace']); + + $response = $this->postJson('/api/instance', [ + 'email' => $this->user->email, + 'storeAppId' => $this->storeApp->uuid, + 'group_id' => $group->id, + ]); + + $response->assertCreated(); + $instance = PolydockAppInstance::where('uuid', $response->json('data.uuid'))->first(); + + $this->assertEquals($group->id, $instance->user_group_id); + $this->assertEquals($group->id, $response->json('data.group.id')); + } + + public function test_create_instance_with_existing_group_slug_assigns_instance_to_group(): void + { + Sanctum::actingAs($this->user, ['instances.write']); + + $group = UserGroup::create(['name' => 'Slug Workspace']); + $this->user->groups()->attach($group->id, ['role' => UserGroupRoleEnum::MEMBER->value]); + + $response = $this->postJson('/api/instance', [ + 'email' => $this->user->email, + 'storeAppId' => $this->storeApp->uuid, + 'group_slug' => $group->slug, + ]); + + $response->assertCreated(); + $instance = PolydockAppInstance::where('uuid', $response->json('data.uuid'))->first(); + + $this->assertEquals($group->id, $instance->user_group_id); + $this->assertEquals($group->slug, $response->json('data.group.slug')); + } + public function test_create_instance_provisions_instance_and_creates_user_with_names(): void { Sanctum::actingAs($this->user, ['instances.write']); @@ -475,4 +572,133 @@ public function test_get_instances_returns_null_label_when_not_set(): void $this->assertNotNull($found); $this->assertNull($found['label']); } + + public function test_get_instances_can_filter_by_group_id_without_email(): void + { + Sanctum::actingAs($this->user, ['instances.read']); + + $targetGroup = UserGroup::create(['name' => 'Target Group']); + $otherGroup = UserGroup::create(['name' => 'Other Group']); + + $this->user->groups()->syncWithoutDetaching([ + $targetGroup->id => ['role' => UserGroupRoleEnum::MEMBER->value], + ]); + + $targetInstance = PolydockAppInstance::create([ + 'polydock_store_app_id' => $this->storeApp->id, + 'user_group_id' => $targetGroup->id, + 'name' => 'target-instance', + 'status' => PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, + ]); + + PolydockAppInstance::create([ + 'polydock_store_app_id' => $this->storeApp->id, + 'user_group_id' => $otherGroup->id, + 'name' => 'other-instance', + 'status' => PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, + ]); + + $response = $this->getJson('/api/instances?group_id='.$targetGroup->id); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $this->assertEquals($targetInstance->uuid, $response->json('data.0.uuid')); + $this->assertEquals($targetGroup->id, $response->json('data.0.group.id')); + } + + public function test_get_instances_can_filter_by_group_slug_without_email(): void + { + Sanctum::actingAs($this->user, ['instances.read']); + + $targetGroup = UserGroup::create(['name' => 'Slug Target Group']); + $otherGroup = UserGroup::create(['name' => 'Slug Other Group']); + + $this->user->groups()->syncWithoutDetaching([ + $targetGroup->id => ['role' => UserGroupRoleEnum::MEMBER->value], + ]); + + $targetInstance = PolydockAppInstance::create([ + 'polydock_store_app_id' => $this->storeApp->id, + 'user_group_id' => $targetGroup->id, + 'name' => 'slug-target-instance', + 'status' => PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, + ]); + + PolydockAppInstance::create([ + 'polydock_store_app_id' => $this->storeApp->id, + 'user_group_id' => $otherGroup->id, + 'name' => 'slug-other-instance', + 'status' => PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, + ]); + + $response = $this->getJson('/api/instances?group_slug='.$targetGroup->slug); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $this->assertEquals($targetInstance->uuid, $response->json('data.0.uuid')); + $this->assertEquals($targetGroup->slug, $response->json('data.0.group.slug')); + } + + public function test_get_instances_forbidden_when_not_member_of_group(): void + { + Sanctum::actingAs($this->user, ['instances.read']); + + $inaccessibleGroup = UserGroup::create(['name' => 'Inaccessible Group']); + + $response = $this->getJson('/api/instances?group_id='.$inaccessibleGroup->id); + + $response->assertForbidden(); + } + + public function test_assign_instance_to_group_reassigns_existing_instance(): void + { + Sanctum::actingAs($this->user, ['instances.write']); + + $originalGroup = UserGroup::create(['name' => 'Original Group']); + $targetGroup = UserGroup::create(['name' => 'Target Migration Group']); + + $this->user->groups()->syncWithoutDetaching([ + $targetGroup->id => ['role' => UserGroupRoleEnum::MEMBER->value], + ]); + + $instance = PolydockAppInstance::create([ + 'polydock_store_app_id' => $this->storeApp->id, + 'user_group_id' => $originalGroup->id, + 'name' => 'migration-instance', + 'status' => PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, + ]); + + $response = $this->patchJson('/api/instance/'.$instance->uuid.'/group', [ + 'group_id' => $targetGroup->id, + ]); + + $response->assertOk(); + $instance->refresh(); + + $this->assertEquals($targetGroup->id, $instance->user_group_id); + $this->assertEquals($targetGroup->slug, $response->json('data.group.slug')); + } + + public function test_assign_instance_to_group_forbidden_when_not_member(): void + { + Sanctum::actingAs($this->user, ['instances.write']); + + $originalGroup = UserGroup::create(['name' => 'Original Group']); + $targetGroup = UserGroup::create(['name' => 'Inaccessible Group']); + + $instance = PolydockAppInstance::create([ + 'polydock_store_app_id' => $this->storeApp->id, + 'user_group_id' => $originalGroup->id, + 'name' => 'migration-instance', + 'status' => PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, + ]); + + $response = $this->patchJson('/api/instance/'.$instance->uuid.'/group', [ + 'group_id' => $targetGroup->id, + ]); + + $response->assertForbidden(); + $instance->refresh(); + $this->assertEquals($originalGroup->id, $instance->user_group_id); + } }