diff --git a/src/app/Http/Controllers/API/V4/SearchController.php b/src/app/Http/Controllers/API/V4/SearchController.php new file mode 100644 --- /dev/null +++ b/src/app/Http/Controllers/API/V4/SearchController.php @@ -0,0 +1,159 @@ +guard()->user(); + $search = trim(request()->input('search')); + $with_aliases = !empty(request()->input('alias')); + $limit = intval(request()->input('limit')); + + if ($limit <= 0) { + $limit = 15; + } elseif ($limit > 100) { + $limit = 100; + } + + // Prepare the query + $query = User::select('email', 'id')->where('id', $user->id); + $aliases = DB::table('user_aliases')->select(DB::raw('alias as email, user_id as id')) + ->where('user_id', $user->id); + + if (strlen($search)) { + $aliases->whereLike('alias', $search); + $query->whereLike('email', $search); + } + + if ($with_aliases) { + $query->union($aliases); + } + + // Execute the query + $result = $query->orderBy('email')->limit($limit)->get(); + + $result = $this->resultFormat($result); + + return response()->json([ + 'list' => $result, + 'count' => count($result), + ]); + } + + /** + * Search request for addresses of all users (in an account) + * + * @param \Illuminate\Http\Request $request The API request. + * + * @return \Illuminate\Http\JsonResponse The response + */ + public function searchUser(Request $request) + { + $user = $this->guard()->user(); + $search = trim(request()->input('search')); + $with_aliases = !empty(request()->input('alias')); + $limit = intval(request()->input('limit')); + + if ($limit <= 0) { + $limit = 15; + } elseif ($limit > 100) { + $limit = 100; + } + + $wallet = $user->wallet(); + + // Limit users to the user's account + $allUsers = $wallet->entitlements()->where('entitleable_type', User::class)->select('entitleable_id')->distinct(); + + // Sub-query for user IDs who's names match the search criteria + $foundUserIds = UserSetting::select('user_id') + ->whereIn('key', ['first_name', 'last_name']) + ->whereLike('value', $search) + ->whereIn('user_id', $allUsers); + + // Prepare the query + $query = User::select('email', 'id')->whereIn('id', $allUsers); + $aliases = DB::table('user_aliases')->select(DB::raw('alias as email, user_id as id')) + ->whereIn('user_id', $allUsers); + + if (strlen($search)) { + $query->where(function ($query) use ($foundUserIds, $search) { + $query->whereLike('email', $search) + ->orWhereIn('id', $foundUserIds); + }); + + $aliases->where(function ($query) use ($foundUserIds, $search) { + $query->whereLike('alias', $search) + ->orWhereIn('user_id', $foundUserIds); + }); + } + + if ($with_aliases) { + $query->union($aliases); + } + + // Execute the query + $result = $query->orderBy('email')->limit($limit)->get(); + + $result = $this->resultFormat($result); + + return response()->json([ + 'list' => $result, + 'count' => count($result), + ]); + } + + /** + * Format the search result, inject user names + */ + protected function resultFormat($result) + { + if ($result->count()) { + // Get user names + $settings = UserSetting::whereIn('key', ['first_name', 'last_name']) + ->whereIn('user_id', $result->pluck('id')) + ->get() + ->mapWithKeys(function ($item) { + return [($item->user_id . ':' . $item->key) => $item->value]; + }) + ->all(); + + // "Format" the result, include user names + $result = $result->map(function ($record) use ($settings) { + return [ + 'email' => $record->email, + 'name' => trim( + ($settings["{$record->id}:first_name"] ?? '') + . ' ' + . ($settings["{$record->id}:last_name"] ?? '') + ), + ]; + }) + ->sortBy(['name', 'email']) + ->values(); + } + + return $result; + } +} diff --git a/src/config/app.php b/src/config/app.php --- a/src/config/app.php +++ b/src/config/app.php @@ -257,6 +257,7 @@ 'with_resources' => (bool) env('APP_WITH_RESOURCES', true), 'with_meet' => (bool) env('APP_WITH_MEET', true), 'with_companion_app' => (bool) env('APP_WITH_COMPANION_APP', true), + 'with_user_search' => (bool) env('APP_WITH_USER_SEARCH', false), 'signup' => [ 'email_limit' => (int) env('SIGNUP_LIMIT_EMAIL', 0), diff --git a/src/routes/api.php b/src/routes/api.php --- a/src/routes/api.php +++ b/src/routes/api.php @@ -183,6 +183,11 @@ Route::get('payments/has-pending', [API\V4\PaymentsController::class, 'hasPayments']); Route::get('payments/status', [API\V4\PaymentsController::class, 'paymentStatus']); + Route::get('search/self', [API\V4\SearchController::class, 'searchSelf']); + if (\config('app.with_user_search')) { + Route::get('search/user', [API\V4\SearchController::class, 'searchUser']); + } + Route::post('support/request', [API\V4\SupportController::class, 'request']) ->withoutMiddleware(['auth:api', 'scope:api']) ->middleware(['api']); diff --git a/src/tests/Feature/Controller/SearchTest.php b/src/tests/Feature/Controller/SearchTest.php new file mode 100644 --- /dev/null +++ b/src/tests/Feature/Controller/SearchTest.php @@ -0,0 +1,192 @@ +deleteTestUser('jane@kolabnow.com'); + } + + /** + * {@inheritDoc} + */ + public function tearDown(): void + { + $this->deleteTestUser('jane@kolabnow.com'); + + parent::tearDown(); + } + + /** + * Test searching + */ + public function testSearchSelf(): void + { + // Unauth access not allowed + $response = $this->get("api/v4/search/self"); + $response->assertStatus(401); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + + // w/o aliases, w/o search + $response = $this->actingAs($john)->get("api/v4/search/self"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame(['email' => 'john@kolab.org', 'name' => 'John Doe'], $json['list'][0]); + + // with aliases, w/o search + $response = $this->actingAs($john)->get("api/v4/search/self?alias=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + $this->assertSame(['email' => 'john.doe@kolab.org', 'name' => 'John Doe'], $json['list'][0]); + $this->assertSame(['email' => 'john@kolab.org', 'name' => 'John Doe'], $json['list'][1]); + + // with aliases and search + $response = $this->actingAs($john)->get("api/v4/search/self?alias=1&search=doe@"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(1, $json['count']); + $this->assertCount(1, $json['list']); + $this->assertSame(['email' => 'john.doe@kolab.org', 'name' => 'John Doe'], $json['list'][0]); + + // User no account owner - with aliases, w/o search + $response = $this->actingAs($jack)->get("api/v4/search/self?alias=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + $this->assertSame(['email' => 'jack.daniels@kolab.org', 'name' => 'Jack Daniels'], $json['list'][0]); + $this->assertSame(['email' => 'jack@kolab.org', 'name' => 'Jack Daniels'], $json['list'][1]); + } + + /** + * Test searching + */ + public function testSearchUser(): void + { + \putenv('APP_WITH_USER_SEARCH=false'); // can't be done using \config() + $this->refreshApplication(); // reload routes + + // User search route disabled + $response = $this->get("api/v4/search/user"); + $response->assertStatus(404); + + \putenv('APP_WITH_USER_SEARCH=true'); // can't be done using \config() + $this->refreshApplication(); // reload routes + + // Unauth access not allowed + $response = $this->get("api/v4/search/user"); + $response->assertStatus(401); + + $john = $this->getTestUser('john@kolab.org'); + $jack = $this->getTestUser('jack@kolab.org'); + $jane = $this->getTestUser('jane@kolabnow.com'); + + // Account owner - without aliases, w/o search + $response = $this->actingAs($john)->get("api/v4/search/user"); + $response->assertStatus(200); + + $json = $response->json(); + + $users = [ + [ + 'email' => 'joe@kolab.org', + 'name' => '', + ], + [ + 'email' => 'ned@kolab.org', + 'name' => 'Edward Flanders', + ], + [ + 'email' => 'jack@kolab.org', + 'name' => 'Jack Daniels', + ], + [ + 'email' => 'john@kolab.org', + 'name' => 'John Doe', + ], + ]; + + + $this->assertSame(count($users), $json['count']); + $this->assertSame($users, $json['list']); + + // User no account owner, without aliases w/o search + $response = $this->actingAs($jack)->get("api/v4/search/user"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(count($users), $json['count']); + $this->assertSame($users, $json['list']); + + // with aliases, w/o search + $response = $this->actingAs($john)->get("api/v4/search/user?alias=1"); + $response->assertStatus(200); + + $json = $response->json(); + + $expected = [ + [ + 'email' => 'joe.monster@kolab.org', + 'name' => '', + ], + $users[0], + $users[1], + [ + 'email' => 'jack.daniels@kolab.org', + 'name' => 'Jack Daniels', + ], + $users[2], + [ + 'email' => 'john.doe@kolab.org', + 'name' => 'John Doe', + ], + $users[3], + ]; + + $this->assertSame(count($expected), $json['count']); + $this->assertSame($expected, $json['list']); + + // with aliases and search + $response = $this->actingAs($john)->get("api/v4/search/user?alias=1&search=john"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(2, $json['count']); + $this->assertCount(2, $json['list']); + $this->assertSame(['email' => 'john.doe@kolab.org', 'name' => 'John Doe'], $json['list'][0]); + $this->assertSame(['email' => 'john@kolab.org', 'name' => 'John Doe'], $json['list'][1]); + + // Make sure we can't find users from outside of an account + $response = $this->actingAs($john)->get("api/v4/search/user?alias=1&search=jane"); + $response->assertStatus(200); + + $json = $response->json(); + + $this->assertSame(0, $json['count']); + } +}