Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
282 changes: 264 additions & 18 deletions app/Http/Controllers/Api/AuthenticatedApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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);
}
Comment thread
dan2k3k4 marked this conversation as resolved.

$instances = $instanceQuery->get();

$formattedInstances = $instances->map(fn (PolydockAppInstance $instance) => [
'uuid' => $instance->uuid,
Expand All @@ -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,
]);

Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Comment thread
dan2k3k4 marked this conversation as resolved.
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
*
Expand Down Expand Up @@ -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;
}
}
3 changes: 3 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
});
});
Expand Down
Loading
Loading