Skip to content

Commit 3f85cdc

Browse files
authored
Add user profile API endpoint (#185)
1 parent ddb626b commit 3f85cdc

6 files changed

Lines changed: 283 additions & 1 deletion

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Http\Resources\UserProfileResource;
6+
use App\Models\ChallengeUser;
7+
use App\Models\Certificate;
8+
use App\Models\User;
9+
use App\Models\UserActionPoints;
10+
class UserProfileController extends Controller
11+
{
12+
public function show(string $githubUser)
13+
{
14+
$user = User::where('github_user', $githubUser)->first();
15+
16+
if (!$user) {
17+
return response()->json(['message' => 'User not found'], 404);
18+
}
19+
20+
$stats = UserActionPoints::selectRaw(
21+
'COALESCE(SUM(points), 0) as points, ' .
22+
"COALESCE(SUM(CASE WHEN action_name = 'challenge_completed' THEN 1 ELSE 0 END), 0) AS completed_challenge_count, " .
23+
"COALESCE(SUM(CASE WHEN action_name = 'reaction_received' THEN 1 ELSE 0 END), 0) AS received_reaction_count"
24+
)
25+
->where('user_id', $user->id)
26+
->first();
27+
28+
$completedChallenges = ChallengeUser::where('user_id', $user->id)
29+
->whereNotNull('submitted_at')
30+
->where('listed', true)
31+
->with('challenge:id,name,slug,image_url')
32+
->orderByDesc('submitted_at')
33+
->get();
34+
35+
$certificates = Certificate::where('user_id', $user->id)
36+
->where('status', 'published')
37+
->orderByDesc('created_at')
38+
->get();
39+
40+
return new UserProfileResource([
41+
'user' => $user,
42+
'stats' => $stats,
43+
'completed_challenges' => $completedChallenges,
44+
'certificates' => $certificates,
45+
]);
46+
}
47+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
namespace App\Http\Resources;
4+
5+
use Illuminate\Http\Request;
6+
use Illuminate\Http\Resources\Json\JsonResource;
7+
8+
class UserProfileChallengeResource extends JsonResource
9+
{
10+
public function toArray(Request $request): array
11+
{
12+
return [
13+
'id' => $this->id,
14+
'submission_image_url' => $this->submission_image_url,
15+
'submission_url' => $this->submission_url,
16+
'submitted_at' => $this->submitted_at,
17+
'challenge' => [
18+
'name' => $this->challenge->name,
19+
'slug' => $this->challenge->slug,
20+
'image_url' => $this->challenge->image_url,
21+
],
22+
];
23+
}
24+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace App\Http\Resources;
4+
5+
use Illuminate\Http\Request;
6+
use Illuminate\Http\Resources\Json\JsonResource;
7+
8+
class UserProfileResource extends JsonResource
9+
{
10+
public function toArray(Request $request): array
11+
{
12+
$user = $this->resource['user'];
13+
$stats = $this->resource['stats'];
14+
$completedChallenges = $this->resource['completed_challenges'];
15+
$certificates = $this->resource['certificates'];
16+
17+
return [
18+
'user' => [
19+
'name' => $user->name,
20+
'github_user' => $user->github_user,
21+
'linkedin_user' => $user->linkedin_user,
22+
'avatar' => new UserAvatarResource($user),
23+
'created_at' => $user->created_at,
24+
],
25+
'stats' => [
26+
'points' => (int) $stats->points,
27+
'completed_challenge_count' => (int) $stats->completed_challenge_count,
28+
'received_reaction_count' => (int) $stats->received_reaction_count,
29+
],
30+
'completed_challenges' => UserProfileChallengeResource::collection($completedChallenges),
31+
'certificates' => $certificates->map(fn ($cert) => [
32+
'id' => $cert->id,
33+
'metadata' => $cert->metadata,
34+
'status' => $cert->status,
35+
'created_at' => $cert->created_at,
36+
]),
37+
];
38+
}
39+
}

app/Services/ChallengeRepository.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,12 @@ protected function challengeCardsBaseQuery($query, $currentUser)
3434
$query
3535
->select(
3636
'users.id',
37+
'users.name',
3738
'users.avatar_url',
39+
'users.github_user',
3840
'users.is_pro',
39-
'users.is_admin'
41+
'users.is_admin',
42+
'users.settings'
4043
)
4144
->when($currentUser, function ($query) use (
4245
$currentUser

routes/api.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
use App\Http\Controllers\AdminApi\WorkshopAdminController;
44
use App\Http\Controllers\BlogPostController;
5+
use App\Http\Controllers\UserProfileController;
56
use App\Http\Controllers\BugsnagWebhookController;
67
use App\Http\Controllers\CalendarController;
78
use App\Http\Controllers\CertificateController;
@@ -280,6 +281,8 @@
280281
'editWorkshop',
281282
]);
282283

284+
Route::get('/users/{github_user}/profile', [UserProfileController::class, 'show']);
285+
283286
require __DIR__ . '/auth.php';
284287

285288
Route::fallback(function () {

tests/Feature/UserProfileTest.php

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Models\Certificate;
6+
use App\Models\Challenge;
7+
use App\Models\ChallengeUser;
8+
use App\Models\User;
9+
use App\Models\UserActionPoints;
10+
use Illuminate\Foundation\Testing\RefreshDatabase;
11+
use Tests\TestCase;
12+
13+
class UserProfileTest extends TestCase
14+
{
15+
use RefreshDatabase;
16+
17+
/** @test */
18+
public function it_returns_a_user_profile_by_github_username(): void
19+
{
20+
$user = User::factory()->pro()->create([
21+
'github_user' => 'testuser',
22+
'linkedin_user' => 'test-linkedin',
23+
]);
24+
25+
$challenge = Challenge::factory()->published()->create();
26+
27+
ChallengeUser::factory()->create([
28+
'user_id' => $user->id,
29+
'challenge_id' => $challenge->id,
30+
'submitted_at' => now(),
31+
'listed' => true,
32+
]);
33+
34+
UserActionPoints::create([
35+
'user_id' => $user->id,
36+
'action_name' => 'challenge_completed',
37+
'points' => 100,
38+
]);
39+
40+
UserActionPoints::create([
41+
'user_id' => $user->id,
42+
'action_name' => 'reaction_received',
43+
'points' => 5,
44+
]);
45+
46+
$response = $this->getJson('/api/users/testuser/profile');
47+
48+
$response->assertOk()
49+
->assertJsonStructure([
50+
'data' => [
51+
'user' => [
52+
'name',
53+
'github_user',
54+
'linkedin_user',
55+
'avatar' => ['avatar_url', 'name', 'badge', 'github_user'],
56+
'created_at',
57+
],
58+
'stats' => [
59+
'points',
60+
'completed_challenge_count',
61+
'received_reaction_count',
62+
],
63+
'completed_challenges',
64+
'certificates',
65+
],
66+
])
67+
->assertJsonPath('data.user.github_user', 'testuser')
68+
->assertJsonPath('data.user.linkedin_user', 'test-linkedin')
69+
->assertJsonPath('data.user.avatar.badge', 'pro')
70+
->assertJsonPath('data.stats.points', 105)
71+
->assertJsonPath('data.stats.completed_challenge_count', 1)
72+
->assertJsonPath('data.stats.received_reaction_count', 1);
73+
74+
$this->assertCount(1, $response->json('data.completed_challenges'));
75+
}
76+
77+
/** @test */
78+
public function it_returns_404_for_nonexistent_user(): void
79+
{
80+
$response = $this->getJson('/api/users/nonexistent/profile');
81+
82+
$response->assertNotFound();
83+
}
84+
85+
/** @test */
86+
public function it_filters_out_unlisted_submissions(): void
87+
{
88+
$user = User::factory()->create(['github_user' => 'filteruser']);
89+
$challenge1 = Challenge::factory()->published()->create();
90+
$challenge2 = Challenge::factory()->published()->create();
91+
92+
ChallengeUser::factory()->create([
93+
'user_id' => $user->id,
94+
'challenge_id' => $challenge1->id,
95+
'submitted_at' => now(),
96+
'listed' => true,
97+
]);
98+
99+
ChallengeUser::factory()->create([
100+
'user_id' => $user->id,
101+
'challenge_id' => $challenge2->id,
102+
'submitted_at' => now(),
103+
'listed' => false,
104+
]);
105+
106+
$response = $this->getJson('/api/users/filteruser/profile');
107+
108+
$response->assertOk();
109+
$this->assertCount(1, $response->json('data.completed_challenges'));
110+
}
111+
112+
/** @test */
113+
public function it_respects_show_badge_setting(): void
114+
{
115+
User::factory()->pro()->create([
116+
'github_user' => 'nobadge',
117+
'settings' => ['show_badge' => false],
118+
]);
119+
120+
$response = $this->getJson('/api/users/nobadge/profile');
121+
122+
$response->assertOk()
123+
->assertJsonPath('data.user.avatar.badge', null);
124+
}
125+
126+
/** @test */
127+
public function it_returns_empty_data_for_user_with_no_activity(): void
128+
{
129+
User::factory()->create(['github_user' => 'newuser']);
130+
131+
$response = $this->getJson('/api/users/newuser/profile');
132+
133+
$response->assertOk()
134+
->assertJsonPath('data.stats.points', 0)
135+
->assertJsonPath('data.stats.completed_challenge_count', 0)
136+
->assertJsonPath('data.stats.received_reaction_count', 0);
137+
138+
$this->assertCount(0, $response->json('data.completed_challenges'));
139+
$this->assertCount(0, $response->json('data.certificates'));
140+
}
141+
142+
/** @test */
143+
public function it_returns_404_for_soft_deleted_user(): void
144+
{
145+
$user = User::factory()->create(['github_user' => 'deleteduser']);
146+
$user->delete();
147+
148+
$response = $this->getJson('/api/users/deleteduser/profile');
149+
150+
$response->assertNotFound();
151+
}
152+
153+
/** @test */
154+
public function it_does_not_expose_email(): void
155+
{
156+
User::factory()->create([
157+
'github_user' => 'emailtest',
158+
'email' => 'secret@example.com',
159+
]);
160+
161+
$response = $this->getJson('/api/users/emailtest/profile');
162+
163+
$response->assertOk();
164+
$this->assertArrayNotHasKey('email', $response->json('data.user'));
165+
}
166+
}

0 commit comments

Comments
 (0)