-
{{ $reply->user->name }}user"
+ class="group flex min-w-0 items-center gap-x-2"
>
- @if ($reply->user->username)
{{ '@' . $reply->user->username }}{{ $reply->user->name }}
- @endif
+ @if ($reply->user->username)
+
{{ '@' . $reply->user->username }}
+ @endif
+
{{ $reply->created_at->diffForHumans(short: true) }}dispatch('timeline.post-created');
}
- #[Computed]
- public function avatarUrl(): ?string
- {
- /** @var User $user */
- $user = auth()->user();
-
- return $user->getFirstMediaUrl('avatar') ?: null;
- }
-
public function render(): View
{
return view('panel-app::livewire.timeline.composer');
diff --git a/app-modules/panel-app/src/Livewire/Timeline/PostShow.php b/app-modules/panel-app/src/Livewire/Timeline/PostShow.php
index ff0ff640..c4171404 100644
--- a/app-modules/panel-app/src/Livewire/Timeline/PostShow.php
+++ b/app-modules/panel-app/src/Livewire/Timeline/PostShow.php
@@ -42,10 +42,10 @@ public function render(): View
{
$timeline = Timeline::query()
->where('id', $this->timelineId)->with([
- 'user',
+ 'user.media',
'postable',
'reactions',
- 'children' => fn (Relation $q) => $q->with('user', 'postable')->latest(),
+ 'children' => fn (Relation $q) => $q->with('user.media', 'postable.media')->latest(),
])
->withCount('children', 'reactions')
->firstOrFail();
diff --git a/app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php b/app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php
index 2dfe0c85..57001606 100644
--- a/app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php
+++ b/app-modules/panel-app/src/Livewire/Timeline/ReplyComposer.php
@@ -13,7 +13,6 @@
use He4rt\Activity\Timeline\DTOs\CreateReplyDTO;
use He4rt\Identity\User\Models\User;
use Illuminate\View\View;
-use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -82,15 +81,6 @@ public function reply(): void
$this->dispatch('timeline.reply-created');
}
- #[Computed]
- public function avatarUrl(): ?string
- {
- /** @var User $user */
- $user = auth()->user();
-
- return $user->getFirstMediaUrl('avatar') ?: null;
- }
-
public function render(): View
{
return view('panel-app::livewire.timeline.reply-composer');
diff --git a/app-modules/panel-app/src/Pages/ProfilePage.php b/app-modules/panel-app/src/Pages/ProfilePage.php
index fb551c21..167a137f 100644
--- a/app-modules/panel-app/src/Pages/ProfilePage.php
+++ b/app-modules/panel-app/src/Pages/ProfilePage.php
@@ -39,6 +39,7 @@
use He4rt\Profile\Enums\StartAvailability;
use He4rt\Profile\Models\Profile;
use He4rt\Profile\Models\Skill;
+use He4rt\Profile\Support\ProfileInitials;
use Illuminate\Support\Str;
use Livewire\Attributes\Computed;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
@@ -579,11 +580,7 @@ public function character(): ?Character
#[Computed]
public function initials(): string
{
- return Str::of(auth()->user()->name)
- ->explode(' ')
- ->map(fn (string $part): string => Str::upper(Str::substr($part, 0, 1)))
- ->take(2)
- ->implode('');
+ return ProfileInitials::for(auth()->user()->name, auth()->user()->username);
}
#[Computed]
diff --git a/app-modules/panel-app/tests/Feature/Timeline/PostShowQueriesTest.php b/app-modules/panel-app/tests/Feature/Timeline/PostShowQueriesTest.php
new file mode 100644
index 00000000..b78eb20b
--- /dev/null
+++ b/app-modules/panel-app/tests/Feature/Timeline/PostShowQueriesTest.php
@@ -0,0 +1,72 @@
+actingAs(User::factory()->create());
+
+ Filament::setCurrentPanel(Filament::getPanel('app'));
+});
+
+function rootPost(): Timeline
+{
+ $entry = PostEntry::factory()->create(['content' => 'raiz']);
+
+ return Timeline::factory()
+ ->for(User::factory()->create(['name' => 'Autor Raiz']))
+ ->create([
+ 'postable_type' => (new PostEntry)->getMorphClass(),
+ 'postable_id' => $entry->id,
+ ]);
+}
+
+function replyTo(Timeline $root, string $author): void
+{
+ $entry = PostEntry::factory()->create(['content' => 'resposta de '.$author]);
+
+ Timeline::factory()
+ ->for(User::factory()->create(['name' => $author]))
+ ->create([
+ 'postable_type' => (new PostEntry)->getMorphClass(),
+ 'postable_id' => $entry->id,
+ 'root_id' => $root->id,
+ 'parent_id' => $root->id,
+ ]);
+}
+
+function mediaQueriesWhileRendering(Timeline $root): int
+{
+ $count = 0;
+
+ DB::listen(function (QueryExecuted $query) use (&$count): void {
+ if (str_contains($query->sql, 'media')) {
+ $count++;
+ }
+ });
+
+ livewire(PostShow::class, ['timelineId' => $root->id])->assertOk();
+
+ return $count;
+}
+
+it('batches media instead of querying once per reply shown', function (): void {
+ $root = rootPost();
+ replyTo($root, 'Resposta Um');
+
+ $comUmaResposta = mediaQueriesWhileRendering($root);
+
+ replyTo($root, 'Resposta Dois');
+ replyTo($root, 'Resposta Tres');
+
+ expect(mediaQueriesWhileRendering($root))->toBe($comUmaResposta);
+});
diff --git a/app-modules/panel-app/tests/Feature/Timeline/ProfileLinkTest.php b/app-modules/panel-app/tests/Feature/Timeline/ProfileLinkTest.php
new file mode 100644
index 00000000..c8d73b78
--- /dev/null
+++ b/app-modules/panel-app/tests/Feature/Timeline/ProfileLinkTest.php
@@ -0,0 +1,48 @@
+conteudo',
+ ['user' => $user],
+ );
+}
+
+it('links to the public profile', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+
+ expect(renderLink($user))
+ ->toContain('href="'.route('profile.public', 'danielhe4rt').'"')
+ ->toContain('conteudo');
+});
+
+it('points the hovercard at the card endpoint', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+
+ expect(str_replace('\\/', '/', renderLink($user)))
+ ->toContain(route('profile.card', 'danielhe4rt'));
+});
+
+it('does not link a banned author', function (): void {
+ $user = User::factory()->create(['username' => 'banido', 'banned_at' => now()]);
+
+ expect(renderLink($user))
+ ->not->toContain('
and(renderLink($user))->toContain('conteudo');
+});
+
+it('does not link a missing author', function (): void {
+ expect(renderLink(user: null))
+ ->not->toContain('and(renderLink(user: null))->toContain('conteudo');
+});
+
+it('does not link an author without a username', function (): void {
+ expect(renderLink(new User(['name' => 'Sem Username'])))
+ ->not->toContain('', ['user' => $user]);
+}
+
+it('shows the same initials the profile card shows', function (): void {
+ $user = User::factory()->create(['name' => 'Daniel Reis', 'username' => 'danielhe4rt']);
+
+ $card = resolve(BuildProfileCard::class)->handle($user);
+
+ expect($card->initials)->toBe('DR')
+ ->and(renderAvatar($user))->toContain('DR');
+});
+
+it('falls back to the username when the name has no letters', function (): void {
+ $user = User::factory()->create(['name' => '42', 'username' => 'zeta']);
+
+ expect(renderAvatar($user))->toContain('Z')
+ ->and(resolve(BuildProfileCard::class)->handle($user)->initials)->toBe('Z');
+});
+
+it('renders a placeholder when the author is gone', function (): void {
+ expect(renderAvatar(user: null))->toContain('UR');
+});
diff --git a/app-modules/profile/database/factories/ProfileProjectFactory.php b/app-modules/profile/database/factories/ProfileProjectFactory.php
new file mode 100644
index 00000000..08eacfb3
--- /dev/null
+++ b/app-modules/profile/database/factories/ProfileProjectFactory.php
@@ -0,0 +1,27 @@
+
+ */
+final class ProfileProjectFactory extends Factory
+{
+ protected $model = ProfileProject::class;
+
+ public function definition(): array
+ {
+ return [
+ 'profile_id' => Profile::factory(),
+ 'name' => fake()->words(3, asText: true),
+ 'description' => fake()->sentence(),
+ 'url' => fake()->url(),
+ ];
+ }
+}
diff --git a/app-modules/profile/database/migrations/2026_08_22_002135_create_profile_projects_table.php b/app-modules/profile/database/migrations/2026_08_22_002135_create_profile_projects_table.php
new file mode 100644
index 00000000..667f6eac
--- /dev/null
+++ b/app-modules/profile/database/migrations/2026_08_22_002135_create_profile_projects_table.php
@@ -0,0 +1,24 @@
+uuid('id')->primary();
+ $table->foreignUuid('profile_id')->constrained('user_profiles')->cascadeOnDelete();
+ $table->string('name');
+ $table->text('description')->nullable();
+ $table->string('url')->nullable();
+ $table->timestampsTz();
+
+ $table->index(['profile_id', 'created_at']);
+ });
+ }
+};
diff --git a/app-modules/profile/resources/views/card.blade.php b/app-modules/profile/resources/views/card.blade.php
new file mode 100644
index 00000000..5e0fa9ba
--- /dev/null
+++ b/app-modules/profile/resources/views/card.blade.php
@@ -0,0 +1,67 @@
+
+
+ @if ($card->avatarUrl)
+

+ @else
+
+ {{ $card->initials }}
+
+ @endif
+
+
+
+
{{ $card->name }}
+ @if ($card->level)
+
+ LVL {{ $card->level }}
+
+ @endif
+
+
{{ '@' . $card->username }}
+
+
+
+ @if ($card->role)
+ {{ $card->role }}
+ @endif
+
+ @if ($card->location)
+
+
+ {{ $card->location }}
+
+ @endif
+
+ @if ($card->skills !== [])
+
+ @foreach ($card->skills as $skill)
+
+ {{ $skill }}
+
+ @endforeach
+ @if ($card->remainingSkills > 0)
+ +{{ $card->remainingSkills }}
+ @endif
+
+ @endif
+
+ @if ($card->availableForProposals)
+
+
+ Aberto a propostas
+
+ @endif
+
diff --git a/app-modules/profile/resources/views/public.blade.php b/app-modules/profile/resources/views/public.blade.php
new file mode 100644
index 00000000..e39a6e83
--- /dev/null
+++ b/app-modules/profile/resources/views/public.blade.php
@@ -0,0 +1,371 @@
+@php
+ $facts = array_filter([
+ 'Senioridade' => $profile->seniority,
+ 'Experiência' => $profile->yearsExperience
+ ? $profile->yearsExperience . ($profile->yearsExperience === 1 ? ' ano' : ' anos')
+ : null,
+ 'Disponibilidade' => $profile->startAvailability,
+ ]);
+
+ $workPreferences = array_filter([
+ $profile->openToRemote ? 'Aberto a remoto' : null,
+ $profile->willingToRelocate ? 'Disposto a mudar de cidade' : null,
+ ...$profile->employmentTypes,
+ ]);
+
+ $links = [...$profile->socialLinks, ...$profile->connectedAccounts];
+
+ $hasBody = $profile->about || $profile->skills !== [] || $profile->experiences !== [] || $profile->projects !== [];
+ $hasAside = (bool) $profile->level || $profile->badges !== [];
+
+ $card = 'border-outline-low bg-elevation-01dp/60 rounded-3xl border p-6 backdrop-blur-sm sm:p-7';
+ $sectionHeading = 'text-text-high flex items-center gap-2.5 text-xs font-semibold tracking-[0.14em] uppercase';
+ $sectionDot = 'bg-primary size-1.5 rounded-full';
+ $eyebrow = 'text-text-low text-[0.7rem] font-semibold tracking-[0.14em] uppercase';
+@endphp
+
+
+
+
+ @if ($profile->coverUrl)
+
+ @endif
+
+
+
+
+
+
+
+ @if ($profile->avatarUrl)
+

+ @else
+
+ {{ $profile->initials }}
+
+ @endif
+
+
+
{{ $profile->name }}
+
+
+ {{ '@' . $profile->username }}
+
+ @if ($profile->nickname)
+ ·
+ {{ $profile->nickname }}
+ @endif
+
+
+ @if ($profile->headline)
+
{{ $profile->headline }}
+ @endif
+
+
+ @if ($profile->availableForProposals)
+
+
+ Disponível para propostas
+
+ @endif
+
+
+ @if ($profile->currentPosition || $profile->location || $profile->memberFor)
+
+ @if ($profile->currentPosition)
+
+
+ {{ $profile->currentPosition }}@if ($profile->currentCompany)
+ · {{ $profile->currentCompany }}
+ @endif
+
+ @endif
+
+ @if ($profile->location)
+
+
+ {{ $profile->location }}
+
+ @endif
+
+ @if ($profile->memberFor)
+
+
+ Membro há {{ $profile->memberFor }}
+
+ @endif
+
+ @endif
+
+ @if ($links !== [])
+
+ @endif
+
+
+
+
+ @if ($facts !== [] || $workPreferences !== [])
+
+ @foreach ($facts as $label => $value)
+
+
{{ $label }}
+
{{ $value }}
+
+ @endforeach
+
+ @if ($workPreferences !== [])
+
+
Preferências
+
+
+ @foreach ($workPreferences as $preference)
+ -
+ {{ $preference }}
+
+ @endforeach
+
+
+ @endif
+
+ @endif
+
+ @if ($hasBody || $hasAside)
+
$hasBody && $hasAside,
+ 'mx-auto max-w-sm' => ! $hasBody && $hasAside,
+ ])
+ >
+ @if ($hasBody)
+
+ @if ($profile->about)
+
+
+
+ Sobre
+
+
+ {{ $profile->about }}
+
+ @endif
+
+ @if ($profile->skills !== [])
+
+
+
+ Skills
+
+
+
+ @foreach ($profile->skills as $skill)
+ -
+ {{ $skill->name }}
+ {{ $skill->proficiency }}
+
+ @if ($skill->yearsExperience)
+
+ {{ $skill->yearsExperience }}{{ $skill->yearsExperience === 1 ? ' ano' : ' anos' }}
+
+ @endif
+
+ @endforeach
+
+
+ @endif
+
+ @if ($profile->experiences !== [])
+
+
+
+ Experiência profissional
+
+
+
+ @foreach ($profile->experiences as $experience)
+ -
+
+
+
+
{{ $experience->position }}
+ · {{ $experience->company }}
+
+ @if ($experience->isCurrent)
+
+ Atual
+
+ @endif
+
+
+
+ {{ $experience->period }}@if ($experience->duration)
+ · {{ $experience->duration }}
+ @endif
+
+
+ @if ($experience->description)
+
+ {{ $experience->description }}
+
+ @endif
+
+ @endforeach
+
+
+ @endif
+
+ @if ($profile->projects !== [])
+
+ @endif
+
+ @endif
+
+ @if ($hasAside)
+
+ @endif
+
+ @endif
+
+
+
diff --git a/app-modules/profile/routes/profile-routes.php b/app-modules/profile/routes/profile-routes.php
new file mode 100644
index 00000000..ce01339d
--- /dev/null
+++ b/app-modules/profile/routes/profile-routes.php
@@ -0,0 +1,17 @@
+where('username', '[A-Za-z0-9_.-]+')
+ ->middleware('throttle:public-profile')
+ ->name('profile.public');
+
+Route::get('/@{username}/card', ProfileCardController::class)
+ ->where('username', '[A-Za-z0-9_.-]+')
+ ->middleware(['web', 'throttle:profile-card'])
+ ->name('profile.card');
diff --git a/app-modules/profile/src/Actions/BuildProfileCard.php b/app-modules/profile/src/Actions/BuildProfileCard.php
new file mode 100644
index 00000000..79cf9247
--- /dev/null
+++ b/app-modules/profile/src/Actions/BuildProfileCard.php
@@ -0,0 +1,53 @@
+buildPublicProfile->handle($user);
+
+ $skills = array_map(
+ static fn (ProfileSkillData $skill): string => $skill->name,
+ $profile->skills,
+ );
+
+ return new ProfileCardData(
+ name: $profile->name,
+ username: $profile->username,
+ url: route('profile.public', $profile->username),
+ avatarUrl: $profile->avatarUrl,
+ initials: $profile->initials,
+ level: $profile->level,
+ role: $this->role($profile->headline, $profile->currentPosition, $profile->currentCompany),
+ location: $profile->location,
+ skills: array_slice($skills, 0, self::MAX_SKILLS),
+ remainingSkills: max(0, count($skills) - self::MAX_SKILLS),
+ availableForProposals: $profile->availableForProposals,
+ );
+ }
+
+ private function role(?string $headline, ?string $position, ?string $company): ?string
+ {
+ if (filled($headline)) {
+ return $headline;
+ }
+
+ $parts = array_filter([$position, $company], filled(...));
+
+ return $parts === [] ? null : implode(' · ', $parts);
+ }
+}
diff --git a/app-modules/profile/src/Actions/BuildPublicProfile.php b/app-modules/profile/src/Actions/BuildPublicProfile.php
new file mode 100644
index 00000000..dc52ec19
--- /dev/null
+++ b/app-modules/profile/src/Actions/BuildPublicProfile.php
@@ -0,0 +1,378 @@
+getKey(),
+ fn (): PublicProfileData => $this->build($user),
+ );
+ }
+
+ private function build(User $user): PublicProfileData
+ {
+ $profile = Profile::query()
+ ->where('user_id', $user->getKey())
+ ->first();
+
+ $experiences = $profile instanceof Profile
+ ? $profile->workExperiences()->get()
+ : new Collection();
+
+ $currentRole = $experiences->first(
+ static fn (WorkExperience $experience): bool => $experience->is_currently_working_here,
+ );
+ $preferences = $profile?->preferences;
+
+ $character = Character::query()
+ ->with('badges.media')
+ ->where('user_id', $user->getKey())
+ ->first();
+
+ return new PublicProfileData(
+ name: $user->name,
+ username: $user->username,
+ avatarUrl: $this->avatarUrl($user),
+ initials: $this->initials($user),
+ coverUrl: $user->getFirstMediaUrl('cover') ?: null,
+ nickname: $profile?->nickname,
+ headline: $profile?->headline,
+ currentPosition: $currentRole?->position,
+ currentCompany: $currentRole?->company_name,
+ availableForProposals: $profile instanceof Profile && $profile->available_for_proposals,
+ location: $this->location($user),
+ about: $profile?->about,
+ seniority: $profile?->seniority_level?->getLabel(),
+ yearsExperience: $profile?->years_experience,
+ startAvailability: $profile?->start_availability?->getLabel(),
+ openToRemote: $preferences instanceof WorkPreferences && $preferences->isOpenToRemote,
+ willingToRelocate: $preferences instanceof WorkPreferences && $preferences->willingToRelocate,
+ employmentTypes: $this->employmentTypes($preferences),
+ socialLinks: $this->socialLinks($profile),
+ connectedAccounts: $this->connectedAccounts($user),
+ skills: $this->skills($profile),
+ experiences: $this->experiences($experiences),
+ projects: $this->projects($profile),
+ level: $character?->level,
+ experience: $character?->experience,
+ levelProgress: $character?->percentage_experience,
+ experienceToNextLevel: $character instanceof Character && $character->experience_progress > 0
+ ? $character->experience_progress
+ : null,
+ memberFor: $this->humanDuration(
+ $user->created_at instanceof CarbonInterface
+ ? (int) $user->created_at->diffInMonths(now())
+ : null,
+ ),
+ badges: $this->badges($character),
+ );
+ }
+
+ /**
+ * @return list
+ */
+ private function badges(?Character $character): array
+ {
+ if (!$character instanceof Character) {
+ return [];
+ }
+
+ $badges = [];
+
+ $rows = $character->badges
+ ->sortBy(static fn (Badge $badge): string => $badge->name)
+ ->values();
+
+ foreach ($rows as $badge) {
+ $badges[] = new ProfileBadgeData(
+ name: $badge->name,
+ description: $badge->description,
+ imageUrl: $badge->getFirstMediaUrl('badge') ?: null,
+ );
+ }
+
+ return $badges;
+ }
+
+ /**
+ * @return list
+ */
+ private function skills(?Profile $profile): array
+ {
+ if (!$profile instanceof Profile) {
+ return [];
+ }
+
+ $skills = [];
+
+ $rows = $profile->profileSkills()
+ ->with('skill')
+ ->get()
+ ->sortBy(static fn (ProfileSkill $row): string => $row->skill->name)
+ ->values();
+
+ foreach ($rows as $row) {
+ $skills[] = new ProfileSkillData(
+ name: $row->skill->name,
+ category: $row->skill->category->getLabel(),
+ proficiency: $row->proficiency->getLabel(),
+ yearsExperience: $row->years_experience,
+ );
+ }
+
+ return $skills;
+ }
+
+ /**
+ * @param Collection $rows
+ * @return list
+ */
+ private function experiences(Collection $rows): array
+ {
+ $experiences = [];
+
+ foreach ($rows as $experience) {
+ $experiences[] = new WorkExperienceData(
+ company: $experience->company_name,
+ position: $experience->position,
+ period: $this->period($experience),
+ description: $experience->description,
+ duration: $this->humanDuration($experience->durationInMonths()),
+ isCurrent: $experience->is_currently_working_here,
+ );
+ }
+
+ return $experiences;
+ }
+
+ private function period(WorkExperience $experience): string
+ {
+ $start = $experience->start_date->format('m/Y');
+
+ if ($experience->is_currently_working_here) {
+ return $start.' — atual';
+ }
+
+ return $experience->end_date instanceof CarbonInterface
+ ? $start.' — '.$experience->end_date->format('m/Y')
+ : $start;
+ }
+
+ private function humanDuration(?int $months): ?string
+ {
+ if ($months === null || $months < 1) {
+ return null;
+ }
+
+ $years = intdiv($months, 12);
+ $remainingMonths = $months % 12;
+ $parts = [];
+
+ if ($years > 0) {
+ $parts[] = $years.($years === 1 ? ' ano' : ' anos');
+ }
+
+ if ($remainingMonths > 0) {
+ $parts[] = $remainingMonths.($remainingMonths === 1 ? ' mês' : ' meses');
+ }
+
+ return implode(' e ', $parts);
+ }
+
+ /**
+ * @return list
+ */
+ private function socialLinks(?Profile $profile): array
+ {
+ if (!$profile instanceof Profile) {
+ return [];
+ }
+
+ $links = [];
+
+ foreach ($profile->social_links ?? [] as $key => $handle) {
+ $platform = SocialPlatform::tryFrom((string) $key);
+ if (!$platform instanceof SocialPlatform) {
+ continue;
+ }
+
+ if (blank($handle)) {
+ continue;
+ }
+
+ $links[] = new ProfileLinkData(
+ label: $platform->getLabel(),
+ handle: $handle,
+ icon: $platform->getBrandIcon(),
+ url: $platform->getUrl($handle),
+ );
+ }
+
+ return $links;
+ }
+
+ /**
+ * @return list
+ */
+ private function connectedAccounts(User $user): array
+ {
+ $supported = IdentityProvider::supportedProviders();
+ $accounts = [];
+
+ /** @var ExternalIdentity $identity */
+ foreach ($user->providers()->get() as $identity) {
+ if (!$identity->isConnected()) {
+ continue;
+ }
+
+ if (!in_array($identity->provider, $supported, strict: true)) {
+ continue;
+ }
+
+ $handle = $identity->metadata['username'] ?? null;
+ if (!is_string($handle)) {
+ continue;
+ }
+
+ if (blank($handle)) {
+ continue;
+ }
+
+ $accounts[] = new ProfileLinkData(
+ label: $identity->provider->getLabel(),
+ handle: $handle,
+ icon: $identity->provider->getIcon(),
+ url: $identity->provider->profileUrl($handle),
+ );
+ }
+
+ return $accounts;
+ }
+
+ /**
+ * @return list
+ */
+ private function employmentTypes(?WorkPreferences $preferences): array
+ {
+ if (!$preferences instanceof WorkPreferences) {
+ return [];
+ }
+
+ return array_map(
+ static fn (EmploymentType $type): string => $type->getLabel(),
+ $preferences->employmentTypes,
+ );
+ }
+
+ private function avatarUrl(User $user): ?string
+ {
+ $uploaded = $user->getFirstMediaUrl('avatar');
+
+ if ($uploaded !== '') {
+ return $uploaded;
+ }
+
+ $handle = $this->githubHandle($user);
+
+ return $handle === null ? null : sprintf('https://github.com/%s.png', $handle);
+ }
+
+ /**
+ * @return list
+ */
+ private function projects(?Profile $profile): array
+ {
+ if (!$profile instanceof Profile) {
+ return [];
+ }
+
+ $projects = [];
+
+ foreach ($profile->projects as $project) {
+ $projects[] = new ProfileProjectData(
+ name: $project->name,
+ description: $project->description,
+ url: $this->safeUrl($project->url),
+ );
+ }
+
+ return $projects;
+ }
+
+ private function safeUrl(?string $url): ?string
+ {
+ if ($url === null || $url === '') {
+ return null;
+ }
+
+ $scheme = parse_url($url, PHP_URL_SCHEME);
+
+ return in_array($scheme, ['http', 'https'], strict: true) ? $url : null;
+ }
+
+ private function githubHandle(User $user): ?string
+ {
+ /** @var ExternalIdentity|null $identity */
+ $identity = $user->providers()
+ ->where('provider', IdentityProvider::GitHub)
+ ->first();
+
+ if (!$identity instanceof ExternalIdentity || !$identity->isConnected()) {
+ return null;
+ }
+
+ $handle = $identity->metadata['username'] ?? null;
+
+ return is_string($handle) && filled($handle) ? $handle : null;
+ }
+
+ private function initials(User $user): string
+ {
+ return ProfileInitials::for($user->name, $user->username);
+ }
+
+ private function location(User $user): ?string
+ {
+ /** @var Address|null $address */
+ $address = $user->address()->first();
+
+ if (!$address instanceof Address) {
+ return null;
+ }
+
+ $parts = array_filter(
+ [$address->city, $address->state, $address->country],
+ filled(...),
+ );
+
+ return $parts === [] ? null : implode(', ', $parts);
+ }
+}
diff --git a/app-modules/profile/src/DTOs/ProfileBadgeData.php b/app-modules/profile/src/DTOs/ProfileBadgeData.php
new file mode 100644
index 00000000..b6aceb86
--- /dev/null
+++ b/app-modules/profile/src/DTOs/ProfileBadgeData.php
@@ -0,0 +1,14 @@
+ $skills Names only, already limited to what the card shows.
+ */
+ public function __construct(
+ public string $name,
+ public string $username,
+ public string $url,
+ public ?string $avatarUrl,
+ public string $initials,
+ public ?int $level,
+ public ?string $role,
+ public ?string $location,
+ public array $skills,
+ public int $remainingSkills,
+ public bool $availableForProposals,
+ ) {}
+}
diff --git a/app-modules/profile/src/DTOs/ProfileLinkData.php b/app-modules/profile/src/DTOs/ProfileLinkData.php
new file mode 100644
index 00000000..a04cd8e0
--- /dev/null
+++ b/app-modules/profile/src/DTOs/ProfileLinkData.php
@@ -0,0 +1,15 @@
+ $employmentTypes Already translated labels, not enum values.
+ * @param list $socialLinks
+ * @param list $connectedAccounts
+ * @param list $skills
+ * @param list $experiences
+ * @param list $projects
+ * @param list $badges
+ */
+ public function __construct(
+ public string $name,
+ public string $username,
+ public ?string $avatarUrl,
+ public string $initials,
+ public ?string $coverUrl = null,
+ public ?string $nickname = null,
+ public ?string $headline = null,
+ public ?string $currentPosition = null,
+ public ?string $currentCompany = null,
+ public bool $availableForProposals = false,
+ public ?string $location = null,
+ public ?string $about = null,
+ public ?string $seniority = null,
+ public ?int $yearsExperience = null,
+ public ?string $startAvailability = null,
+ public bool $openToRemote = false,
+ public bool $willingToRelocate = false,
+ public array $employmentTypes = [],
+ public array $socialLinks = [],
+ public array $connectedAccounts = [],
+ public array $skills = [],
+ public array $experiences = [],
+ public array $projects = [],
+ public ?int $level = null,
+ public ?int $experience = null,
+ public ?float $levelProgress = null,
+ public ?int $experienceToNextLevel = null,
+ public ?string $memberFor = null,
+ public array $badges = [],
+ ) {}
+}
diff --git a/app-modules/profile/src/DTOs/WorkExperienceData.php b/app-modules/profile/src/DTOs/WorkExperienceData.php
new file mode 100644
index 00000000..3b0278cc
--- /dev/null
+++ b/app-modules/profile/src/DTOs/WorkExperienceData.php
@@ -0,0 +1,17 @@
+ 'https://instagram.com/'.$slug,
- self::Twitter => 'https://x.com/'.$slug,
- self::LinkedIn => 'https://linkedin.com/in/'.$slug,
- self::YouTube => 'https://youtube.com/@'.$slug,
- self::Bluesky => 'https://bsky.app/profile/'.$slug,
- self::Website => 'https://'.$slug,
+ $base = match ($this) {
+ self::Instagram => 'https://instagram.com/',
+ self::Twitter => 'https://x.com/',
+ self::LinkedIn => 'https://linkedin.com/in/',
+ self::YouTube => 'https://youtube.com/@',
+ self::Bluesky => 'https://bsky.app/profile/',
+ self::Website => 'https://',
};
+
+ return ProfileHandle::url($base, $handle);
}
}
diff --git a/app-modules/profile/src/Http/Controllers/ProfileCardController.php b/app-modules/profile/src/Http/Controllers/ProfileCardController.php
new file mode 100644
index 00000000..39f3c295
--- /dev/null
+++ b/app-modules/profile/src/Http/Controllers/ProfileCardController.php
@@ -0,0 +1,35 @@
+check(), 401);
+
+ $user = $this->findPublicProfileUser->handle($username);
+
+ abort_unless($user instanceof User, 404);
+
+ return response()
+ ->view('profile::card', [
+ 'card' => $this->buildProfileCard->handle($user),
+ ])
+ ->header('Cache-Control', 'private, max-age='.PublicProfileCache::TTL_SECONDS);
+ }
+}
diff --git a/app-modules/profile/src/Http/Controllers/PublicProfileController.php b/app-modules/profile/src/Http/Controllers/PublicProfileController.php
new file mode 100644
index 00000000..7916bec7
--- /dev/null
+++ b/app-modules/profile/src/Http/Controllers/PublicProfileController.php
@@ -0,0 +1,33 @@
+findPublicProfileUser->handle($username);
+
+ abort_unless($user instanceof User, 404);
+
+ $profile = $this->buildPublicProfile->handle($user);
+
+ PublicProfileHead::apply($profile);
+
+ return view('profile::public', ['profile' => $profile]);
+ }
+}
diff --git a/app-modules/profile/src/Models/Profile.php b/app-modules/profile/src/Models/Profile.php
index d2710e24..fbff5202 100644
--- a/app-modules/profile/src/Models/Profile.php
+++ b/app-modules/profile/src/Models/Profile.php
@@ -103,6 +103,14 @@ public function profileSkills(): HasMany
return $this->hasMany(ProfileSkill::class);
}
+ /**
+ * @return HasMany
+ */
+ public function projects(): HasMany
+ {
+ return $this->hasMany(ProfileProject::class)->latest('created_at');
+ }
+
/**
* @return BelongsToMany
*/
@@ -119,7 +127,7 @@ protected static function newFactory(): ProfileFactory
}
/**
- * @return Attribute|null>
+ * @return Attribute|null, array|null>
*/
protected function socialLinks(): Attribute
{
diff --git a/app-modules/profile/src/Models/ProfileProject.php b/app-modules/profile/src/Models/ProfileProject.php
new file mode 100644
index 00000000..c0cd5dc4
--- /dev/null
+++ b/app-modules/profile/src/Models/ProfileProject.php
@@ -0,0 +1,48 @@
+ */
+ use HasFactory;
+ use HasUuids;
+
+ protected $fillable = [
+ 'profile_id',
+ 'name',
+ 'description',
+ 'url',
+ ];
+
+ /**
+ * @return BelongsTo
+ */
+ public function profile(): BelongsTo
+ {
+ return $this->belongsTo(Profile::class);
+ }
+}
diff --git a/app-modules/profile/src/ProfileServiceProvider.php b/app-modules/profile/src/ProfileServiceProvider.php
index d9d8af28..38685d45 100644
--- a/app-modules/profile/src/ProfileServiceProvider.php
+++ b/app-modules/profile/src/ProfileServiceProvider.php
@@ -5,6 +5,10 @@
namespace He4rt\Profile;
use He4rt\Profile\Models\Profile;
+use He4rt\Profile\Models\ProfileProject;
+use He4rt\Profile\Models\ProfileSkill;
+use He4rt\Profile\Models\WorkExperience;
+use He4rt\Profile\Support\PublicProfileCache;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\ServiceProvider;
@@ -18,5 +22,31 @@ public function boot(): void
Relation::morphMap([
'profile' => Profile::class,
]);
+
+ $this->forgetPublicProfileOnWrite();
+ }
+
+ private static function forget(?Profile $profile): null
+ {
+ if ($profile instanceof Profile) {
+ PublicProfileCache::forget((string) $profile->user_id);
+ }
+
+ return null;
+ }
+
+ private function forgetPublicProfileOnWrite(): void
+ {
+ Profile::saved(static fn (Profile $profile): null => self::forget($profile));
+ Profile::deleted(static fn (Profile $profile): null => self::forget($profile));
+
+ WorkExperience::saved(static fn (WorkExperience $row): null => self::forget($row->profile));
+ WorkExperience::deleted(static fn (WorkExperience $row): null => self::forget($row->profile));
+
+ ProfileSkill::saved(static fn (ProfileSkill $row): null => self::forget($row->profile));
+ ProfileSkill::deleted(static fn (ProfileSkill $row): null => self::forget($row->profile));
+
+ ProfileProject::saved(static fn (ProfileProject $row): null => self::forget($row->profile));
+ ProfileProject::deleted(static fn (ProfileProject $row): null => self::forget($row->profile));
}
}
diff --git a/app-modules/profile/src/Queries/FindPublicProfileUser.php b/app-modules/profile/src/Queries/FindPublicProfileUser.php
new file mode 100644
index 00000000..dfa37c99
--- /dev/null
+++ b/app-modules/profile/src/Queries/FindPublicProfileUser.php
@@ -0,0 +1,18 @@
+where('username', $username)
+ ->whereNull('banned_at')
+ ->first();
+ }
+}
diff --git a/app-modules/profile/src/Seo/PublicProfileHead.php b/app-modules/profile/src/Seo/PublicProfileHead.php
new file mode 100644
index 00000000..25ad755e
--- /dev/null
+++ b/app-modules/profile/src/Seo/PublicProfileHead.php
@@ -0,0 +1,137 @@
+coverUrl ?? $profile->avatarUrl);
+
+ Head::title($profile->name)
+ ->description($description)
+ ->og(type: OgType::Profile, url: $url)
+ ->schema(self::person($profile, $description, $url, $image));
+
+ if ($image !== null) {
+ Head::ogImage($image, alt: $profile->name);
+ }
+
+ $creator = self::twitterHandle($profile);
+
+ if ($creator !== null) {
+ Head::twitter(creator: $creator);
+ }
+ }
+
+ private static function description(PublicProfileData $profile): string
+ {
+ $role = $profile->currentPosition !== null && $profile->currentCompany !== null
+ ? $profile->currentPosition.' · '.$profile->currentCompany
+ : $profile->currentPosition;
+
+ $description = collect([$profile->headline, $role, $profile->about])
+ ->filter()
+ ->first() ?? $profile->name.' na comunidade He4rt Developers.';
+
+ return Str::limit($description, self::DESCRIPTION_LIMIT);
+ }
+
+ private static function url(PublicProfileData $profile): string
+ {
+ return secure_url(route('profile.public', ['username' => $profile->username], absolute: false));
+ }
+
+ private static function absolute(?string $url): ?string
+ {
+ if ($url === null) {
+ return null;
+ }
+
+ return str_starts_with($url, '/') ? url($url) : $url;
+ }
+
+ private static function person(PublicProfileData $profile, string $description, string $url, ?string $image): Person
+ {
+ $person = Schema::person()
+ ->name($profile->name)
+ ->url($url)
+ ->set('description', $description)
+ ->set('alternateName', '@'.$profile->username);
+
+ if ($image !== null) {
+ $person->set('image', $image);
+ }
+
+ if ($profile->currentPosition !== null) {
+ $person->set('jobTitle', $profile->currentPosition);
+ }
+
+ if ($profile->currentCompany !== null) {
+ $person->set('worksFor', ['@type' => 'Organization', 'name' => $profile->currentCompany]);
+ }
+
+ $sameAs = self::sameAs($profile);
+
+ if ($sameAs !== []) {
+ $person->set('sameAs', $sameAs);
+ }
+
+ $knowsAbout = array_map(
+ static fn (ProfileSkillData $skill): string => $skill->name,
+ $profile->skills,
+ );
+
+ if ($knowsAbout !== []) {
+ $person->set('knowsAbout', $knowsAbout);
+ }
+
+ return $person;
+ }
+
+ /**
+ * @return list
+ */
+ private static function sameAs(PublicProfileData $profile): array
+ {
+ $urls = [];
+
+ foreach ([...$profile->socialLinks, ...$profile->connectedAccounts] as $link) {
+ if ($link->url !== null) {
+ $urls[] = $link->url;
+ }
+ }
+
+ return array_values(array_unique($urls));
+ }
+
+ private static function twitterHandle(PublicProfileData $profile): ?string
+ {
+ foreach ($profile->socialLinks as $link) {
+ if ($link->icon !== SocialPlatform::Twitter->getBrandIcon()) {
+ continue;
+ }
+
+ $handle = mb_ltrim(mb_trim($link->handle), '@');
+
+ return $handle === '' || str_contains($handle, '/') ? null : '@'.$handle;
+ }
+
+ return null;
+ }
+}
diff --git a/app-modules/profile/src/Support/ProfileInitials.php b/app-modules/profile/src/Support/ProfileInitials.php
new file mode 100644
index 00000000..8a730701
--- /dev/null
+++ b/app-modules/profile/src/Support/ProfileInitials.php
@@ -0,0 +1,25 @@
+squish()
+ ->explode(' ')
+ ->filter(static fn (string $word): bool => preg_match('/^\p{L}/u', $word) === 1)
+ ->take(2)
+ ->map(static fn (string $word): string => Str::upper(Str::substr($word, 0, 1)))
+ ->implode('');
+
+ return $initials !== ''
+ ? $initials
+ : Str::upper(Str::substr(Str::squish((string) $fallback), 0, 1));
+ }
+}
diff --git a/app-modules/profile/src/Support/PublicProfileCache.php b/app-modules/profile/src/Support/PublicProfileCache.php
new file mode 100644
index 00000000..de8c30e8
--- /dev/null
+++ b/app-modules/profile/src/Support/PublicProfileCache.php
@@ -0,0 +1,48 @@
+getLocale());
+ }
+
+ /**
+ * @param Closure(): PublicProfileData $resolve
+ */
+ public static function remember(string $userId, Closure $resolve): PublicProfileData
+ {
+ return Cache::remember(self::key($userId), self::TTL_SECONDS, $resolve);
+ }
+
+ public static function forget(string $userId): void
+ {
+ foreach (self::locales() as $locale) {
+ Cache::forget(self::key($userId, $locale));
+ }
+ }
+
+ /**
+ * @return list
+ */
+ private static function locales(): array
+ {
+ return array_values(array_unique([
+ ...ApplicationLocale::SUPPORTED,
+ app()->getLocale(),
+ ]));
+ }
+}
diff --git a/app-modules/profile/tests/Feature/BuildProfileCardTest.php b/app-modules/profile/tests/Feature/BuildProfileCardTest.php
new file mode 100644
index 00000000..cac0866f
--- /dev/null
+++ b/app-modules/profile/tests/Feature/BuildProfileCardTest.php
@@ -0,0 +1,67 @@
+create(['username' => 'danielhe4rt']);
+ $profile = Profile::factory()->for($user)->create(['headline' => 'Developer Advocate']);
+
+ WorkExperience::factory()->for($profile)->current()->create([
+ 'company_name' => 'ScyllaDB',
+ 'position' => 'DevRel',
+ ]);
+
+ expect(resolve(BuildProfileCard::class)->handle($user)->role)
+ ->toBe('Developer Advocate');
+});
+
+it('falls back to position and company when there is no headline', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+ $profile = Profile::factory()->for($user)->create(['headline' => null]);
+
+ WorkExperience::factory()->for($profile)->current()->create([
+ 'company_name' => 'ScyllaDB',
+ 'position' => 'DevRel',
+ ]);
+
+ expect(resolve(BuildProfileCard::class)->handle($user)->role)
+ ->toBe('DevRel · ScyllaDB');
+});
+
+it('has no role when neither headline nor current job exists', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+ Profile::factory()->for($user)->create(['headline' => null]);
+
+ expect(resolve(BuildProfileCard::class)->handle($user)->role)->toBeNull();
+});
+
+it('limits skills and counts the remainder', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+ $profile = Profile::factory()->for($user)->create();
+
+ foreach (['Ada', 'Basic', 'Cobol', 'Dart', 'Elixir'] as $name) {
+ ProfileSkill::factory()->for($profile)->create([
+ 'skill_id' => Skill::factory()->create(['name' => $name])->id,
+ ]);
+ }
+
+ $card = resolve(BuildProfileCard::class)->handle($user);
+
+ expect($card->skills)->toBe(['Ada', 'Basic', 'Cobol'])
+ ->and($card->remainingSkills)->toBe(2);
+});
+
+it('builds initials from the name and falls back to the username', function (): void {
+ $named = User::factory()->create(['name' => 'Daniel Reis', 'username' => 'danielhe4rt']);
+ $unnamed = User::factory()->create(['name' => '42', 'username' => 'zeta']);
+
+ expect(resolve(BuildProfileCard::class)->handle($named)->initials)->toBe('DR')
+ ->and(resolve(BuildProfileCard::class)->handle($unnamed)->initials)->toBe('Z');
+});
diff --git a/app-modules/profile/tests/Feature/BuildPublicProfileTest.php b/app-modules/profile/tests/Feature/BuildPublicProfileTest.php
new file mode 100644
index 00000000..e8d65c22
--- /dev/null
+++ b/app-modules/profile/tests/Feature/BuildPublicProfileTest.php
@@ -0,0 +1,109 @@
+handle($user);
+}
+
+it('leaves the location empty when the user has no address', function (): void {
+ $user = User::factory()->create();
+
+ expect(buildProfileFor($user)->location)->toBeNull();
+});
+
+it('joins city, state and country but never the zip code', function (): void {
+ $user = User::factory()->create();
+
+ Address::factory()->forUser($user)->create([
+ 'city' => 'São Paulo',
+ 'state' => 'SP',
+ 'country' => 'BR',
+ 'zip_code' => '01310-100',
+ ]);
+
+ expect(buildProfileFor($user)->location)->toBe('São Paulo, SP, BR');
+});
+
+it('skips the missing pieces of a partial address', function (): void {
+ $user = User::factory()->create();
+
+ Address::factory()->forUser($user)->create([
+ 'city' => 'Recife',
+ 'state' => null,
+ 'country' => 'BR',
+ ]);
+
+ expect(buildProfileFor($user)->location)->toBe('Recife, BR');
+});
+
+it('picks the ongoing experience as the current role', function (): void {
+ $user = User::factory()->create();
+ $profile = Profile::factory()->for($user)->create();
+
+ WorkExperience::factory()->for($profile)->create([
+ 'company_name' => 'Empresa Antiga',
+ 'position' => 'Estagiário',
+ 'is_currently_working_here' => false,
+ 'end_date' => now()->subYear(),
+ ]);
+
+ WorkExperience::factory()->for($profile)->current()->create([
+ 'company_name' => 'ScyllaDB',
+ 'position' => 'Developer Advocate',
+ ]);
+
+ $data = buildProfileFor($user);
+
+ expect($data->currentPosition)->toBe('Developer Advocate')
+ ->and($data->currentCompany)->toBe('ScyllaDB');
+});
+
+it('leaves the current role empty when no experience is ongoing', function (): void {
+ $user = User::factory()->create();
+ $profile = Profile::factory()->for($user)->create();
+
+ WorkExperience::factory()->for($profile)->create([
+ 'is_currently_working_here' => false,
+ 'end_date' => now()->subMonths(3),
+ ]);
+
+ $data = buildProfileFor($user);
+
+ expect($data->currentPosition)->toBeNull()
+ ->and($data->currentCompany)->toBeNull();
+});
+
+it('falls back to the connected github picture and leaves the cover empty', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+
+ ExternalIdentity::factory()->create([
+ 'model_type' => $user->getMorphClass(),
+ 'model_id' => $user->getKey(),
+ 'provider' => IdentityProvider::GitHub,
+ 'metadata' => ['username' => 'dani-no-github'],
+ 'connected_at' => now(),
+ 'disconnected_at' => null,
+ ]);
+
+ $data = buildProfileFor($user);
+
+ expect($data->avatarUrl)->toBe('https://github.com/dani-no-github.png')
+ ->and($data->coverUrl)->toBeNull();
+});
+
+it('leaves the avatar empty when nothing was uploaded and no github is connected', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+
+ expect(buildProfileFor($user)->avatarUrl)->toBeNull();
+});
diff --git a/app-modules/profile/tests/Feature/ProfileCardTest.php b/app-modules/profile/tests/Feature/ProfileCardTest.php
new file mode 100644
index 00000000..5b90c491
--- /dev/null
+++ b/app-modules/profile/tests/Feature/ProfileCardTest.php
@@ -0,0 +1,145 @@
+withoutVite();
+
+ $this->viewer = User::factory()->create();
+});
+
+it('renders the card for an authenticated viewer', function (): void {
+ $user = User::factory()->create([
+ 'name' => 'Daniel Reis',
+ 'username' => 'danielhe4rt',
+ ]);
+
+ $profile = Profile::factory()->for($user)->create([
+ 'headline' => 'Developer Advocate na He4rt',
+ 'available_for_proposals' => true,
+ ]);
+
+ ProfileSkill::factory()->for($profile)->create([
+ 'skill_id' => Skill::factory()->create(['name' => 'Rust'])->id,
+ ]);
+
+ $this->actingAs($this->viewer)
+ ->get('/@danielhe4rt/card')
+ ->assertOk()
+ ->assertSee('Daniel Reis')
+ ->assertSee('@danielhe4rt')
+ ->assertSee('Developer Advocate na He4rt')
+ ->assertSee('Rust')
+ ->assertSee('Aberto a propostas')
+ ->assertSee('/@danielhe4rt', escape: false)
+ ->assertHeader('Cache-Control', 'max-age=600, private');
+});
+
+it('shows at most three skills and counts the rest', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+ $profile = Profile::factory()->for($user)->create();
+
+ foreach (['Ada', 'Basic', 'Cobol', 'Dart', 'Elixir'] as $name) {
+ ProfileSkill::factory()->for($profile)->create([
+ 'skill_id' => Skill::factory()->create(['name' => $name])->id,
+ ]);
+ }
+
+ $this->actingAs($this->viewer)
+ ->get('/@danielhe4rt/card')
+ ->assertOk()
+ ->assertSee('Ada')
+ ->assertSee('Basic')
+ ->assertSee('Cobol')
+ ->assertDontSee('Dart')
+ ->assertDontSee('Elixir')
+ ->assertSee('+2');
+});
+
+it('renders a card for a user without a profile', function (): void {
+ User::factory()->create([
+ 'name' => 'Sem Perfil',
+ 'username' => 'semperfil',
+ ]);
+
+ $this->actingAs($this->viewer)
+ ->get('/@semperfil/card')
+ ->assertOk()
+ ->assertSee('Sem Perfil')
+ ->assertSee('@semperfil');
+});
+
+it('blocks guests', function (): void {
+ User::factory()->create(['username' => 'danielhe4rt']);
+
+ $this->get('/@danielhe4rt/card')->assertUnauthorized();
+});
+
+it('returns 404 for a banned user', function (): void {
+ User::factory()->create([
+ 'username' => 'banido',
+ 'banned_at' => now(),
+ ]);
+
+ $this->actingAs($this->viewer)
+ ->get('/@banido/card')
+ ->assertNotFound();
+});
+
+it('returns 404 for an unknown username', function (): void {
+ $this->actingAs($this->viewer)
+ ->get('/@ninguem/card')
+ ->assertNotFound();
+});
+
+it('reuses the cached profile instead of rebuilding it', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+ Profile::factory()->for($user)->create(['headline' => 'Developer Advocate']);
+
+ $this->actingAs($this->viewer)->get('/@danielhe4rt/card')->assertOk();
+
+ $queries = 0;
+
+ DB::listen(function (QueryExecuted $query) use (&$queries): void {
+ $queries++;
+ });
+
+ $this->actingAs($this->viewer)->get('/@danielhe4rt/card')->assertOk();
+
+ expect(Cache::has(PublicProfileCache::key((string) $user->getKey())))->toBeTrue()
+ ->and($queries)->toBeLessThan(3);
+});
+
+it('throttles a burst of card requests from the same viewer', function (): void {
+ User::factory()->create(['username' => 'danielhe4rt']);
+
+ $this->actingAs($this->viewer);
+
+ foreach (range(1, 120) as $ignored) {
+ $this->get('/@danielhe4rt/card')->assertOk();
+ }
+
+ $this->get('/@danielhe4rt/card')->assertStatus(429);
+});
+
+it('counts the card throttle per viewer, not per profile', function (): void {
+ User::factory()->create(['username' => 'primeiro']);
+ User::factory()->create(['username' => 'segundo']);
+
+ $this->actingAs($this->viewer);
+
+ foreach (range(1, 120) as $ignored) {
+ $this->get('/@primeiro/card')->assertOk();
+ }
+
+ $this->get('/@segundo/card')->assertStatus(429);
+});
diff --git a/app-modules/profile/tests/Feature/PublicProfileAboutTest.php b/app-modules/profile/tests/Feature/PublicProfileAboutTest.php
new file mode 100644
index 00000000..f17d8261
--- /dev/null
+++ b/app-modules/profile/tests/Feature/PublicProfileAboutTest.php
@@ -0,0 +1,103 @@
+withoutVite();
+});
+
+it('renders the about section of a filled profile', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+
+ Profile::factory()->for($user)->create([
+ 'about' => 'Trabalho com comunidade e dados.',
+ 'seniority_level' => SeniorityLevel::Senior,
+ 'years_experience' => 8,
+ 'start_availability' => StartAvailability::Immediate,
+ 'preferences' => new WorkPreferences(
+ willingToRelocate: true,
+ isOpenToRemote: true,
+ employmentTypes: [EmploymentType::SalariedEmployee, EmploymentType::IndependentContractor],
+ ),
+ ]);
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertSee('Sobre')
+ ->assertSee('Trabalho com comunidade e dados.')
+ ->assertSee(SeniorityLevel::Senior->getLabel())
+ ->assertSee('8 anos')
+ ->assertSee(StartAvailability::Immediate->getLabel())
+ ->assertSee('Aberto a remoto')
+ ->assertSee('Disposto a mudar de cidade')
+ ->assertSee(EmploymentType::SalariedEmployee->getLabel())
+ ->assertSee(EmploymentType::IndependentContractor->getLabel());
+});
+
+it('hides the whole about section when there is nothing to say', function (): void {
+ User::factory()->create(['username' => 'vazio']);
+
+ $this->get('/@vazio')
+ ->assertOk()
+ ->assertDontSee('Sobre')
+ ->assertDontSee('Senioridade')
+ ->assertDontSee('Aberto a remoto');
+});
+
+it('never exposes the age nor the birthdate', function (): void {
+ $user = User::factory()->create(['username' => 'aniversariante']);
+
+ Profile::factory()->for($user)->create([
+ 'birthdate' => now()->subYears(30)->subMonths(2)->toDateString(),
+ ]);
+
+ $data = resolve(BuildPublicProfile::class)->handle($user->refresh());
+ expect($data)->not->toHaveProperty('age');
+
+ $this->get('/@aniversariante')
+ ->assertOk()
+ ->assertDontSee('30 anos')
+ ->assertDontSee('Idade')
+ ->assertDontSee(now()->subYears(30)->subMonths(2)->format('Y-m-d'))
+ ->assertDontSee(now()->subYears(30)->subMonths(2)->format('d/m/Y'));
+});
+
+it('never exposes the disability flag', function (): void {
+ $user = User::factory()->create(['username' => 'pcd']);
+
+ Profile::factory()->for($user)->create([
+ 'preferences' => new WorkPreferences(
+ hasDisability: true,
+ isOpenToRemote: true,
+ ),
+ ]);
+
+ $data = resolve(BuildPublicProfile::class)->handle($user->refresh());
+
+ expect($data)->not->toHaveProperty('hasDisability');
+ expect($data->openToRemote)->toBeTrue();
+
+ $this->get('/@pcd')
+ ->assertOk()
+ ->assertDontSee('deficiência', escape: false)
+ ->assertDontSee('disability');
+});
+
+it('leaves availability off when the profile has no preferences', function (): void {
+ $user = User::factory()->create();
+
+ $data = resolve(BuildPublicProfile::class)->handle($user);
+
+ expect($data->openToRemote)->toBeFalse()
+ ->and($data->willingToRelocate)->toBeFalse()
+ ->and($data->employmentTypes)->toBeEmpty()
+ ->and($data->seniority)->toBeNull();
+});
diff --git a/app-modules/profile/tests/Feature/PublicProfileCacheTest.php b/app-modules/profile/tests/Feature/PublicProfileCacheTest.php
new file mode 100644
index 00000000..ca2f1abd
--- /dev/null
+++ b/app-modules/profile/tests/Feature/PublicProfileCacheTest.php
@@ -0,0 +1,164 @@
+withoutVite();
+});
+
+function visitProfile(string $username): TestResponse
+{
+ app()->forgetScopedInstances();
+
+ return test()->get('/@'.$username);
+}
+
+function queriesWhileVisiting(string $username): int
+{
+ $count = 0;
+
+ DB::listen(function (QueryExecuted $query) use (&$count): void {
+ $count++;
+ });
+
+ visitProfile($username)->assertOk();
+
+ return $count;
+}
+
+it('serves a second visit without touching the database', function (): void {
+ $user = User::factory()->create(['username' => 'cacheado']);
+ Profile::factory()->for($user)->create(['headline' => 'Backend Engineer']);
+
+ expect(queriesWhileVisiting('cacheado'))->toBeGreaterThan(0);
+ expect(queriesWhileVisiting('cacheado'))->toBe(1);
+});
+
+it('keys the cache by user id, so renaming leaves no orphan entry', function (): void {
+ $user = User::factory()->create(['username' => 'antigo']);
+
+ visitProfile('antigo')->assertOk();
+
+ expect(Cache::has(PublicProfileCache::key((string) $user->getKey())))->toBeTrue();
+
+ $user->update(['username' => 'novo']);
+
+ visitProfile('novo')->assertOk();
+
+ expect(Cache::has(PublicProfileCache::key((string) $user->getKey())))->toBeTrue();
+});
+
+it('drops the cache when the profile itself changes', function (): void {
+ $user = User::factory()->create(['username' => 'editor']);
+ $profile = Profile::factory()->for($user)->create(['headline' => 'Antes']);
+
+ visitProfile('editor')->assertOk()->assertSee('Antes');
+
+ $profile->update(['headline' => 'Depois']);
+
+ visitProfile('editor')
+ ->assertOk()
+ ->assertSee('Depois')
+ ->assertDontSee('Antes');
+});
+
+it('drops the cache when a profile-owned row changes', function (string $model, array $attributes, string $before, string $after): void {
+ $user = User::factory()->create(['username' => 'dono']);
+ $profile = Profile::factory()->for($user)->create();
+
+ $row = $model::factory()->for($profile)->create($attributes);
+
+ visitProfile('dono')->assertOk()->assertSee($before);
+
+ $row->update([array_key_first($attributes) => $after]);
+
+ visitProfile('dono')->assertOk()->assertSee($after)->assertDontSee($before);
+})->with([
+ 'work experience' => [
+ WorkExperience::class,
+ ['company_name' => 'Empresa Antiga'],
+ 'Empresa Antiga',
+ 'Empresa Nova',
+ ],
+ 'project' => [
+ ProfileProject::class,
+ ['name' => 'Projeto Antigo'],
+ 'Projeto Antigo',
+ 'Projeto Novo',
+ ],
+]);
+
+it('drops the cache when a profile-owned row is deleted', function (): void {
+ $user = User::factory()->create(['username' => 'apagador']);
+ $profile = Profile::factory()->for($user)->create();
+
+ $project = ProfileProject::factory()->for($profile)->create(['name' => 'Some Sumido']);
+
+ visitProfile('apagador')->assertOk()->assertSee('Some Sumido');
+
+ $project->delete();
+
+ visitProfile('apagador')->assertOk()->assertDontSee('Some Sumido');
+});
+
+it('keys the cache by locale', function (): void {
+ $user = User::factory()->create(['username' => 'poliglota']);
+
+ expect(PublicProfileCache::key((string) $user->getKey(), ApplicationLocale::EN))
+ ->toBe('public-profile:'.$user->getKey().':en')
+ ->and(PublicProfileCache::key((string) $user->getKey(), ApplicationLocale::PT_BR))
+ ->toBe('public-profile:'.$user->getKey().':pt_BR');
+});
+
+it('does not let a viewer in another language poison the public page', function (): void {
+ $user = User::factory()->create(['username' => 'poliglota']);
+
+ Profile::factory()->for($user)->create([
+ 'seniority_level' => SeniorityLevel::Mid,
+ ]);
+
+ $viewer = User::factory()->create();
+
+ test()->actingAs($viewer)
+ ->withSession([ApplicationLocale::SESSION_KEY => ApplicationLocale::EN])
+ ->get('/@poliglota/card')
+ ->assertOk();
+
+ expect(Cache::get(PublicProfileCache::key((string) $user->getKey(), ApplicationLocale::EN))->seniority)
+ ->toBe('Mid-Level');
+
+ ApplicationLocale::apply(ApplicationLocale::PT_BR);
+
+ visitProfile('poliglota')
+ ->assertOk()
+ ->assertSee('Pleno')
+ ->assertDontSee('Mid-Level');
+});
+
+it('drops every locale entry when the profile changes', function (): void {
+ $user = User::factory()->create(['username' => 'poliglota']);
+ $profile = Profile::factory()->for($user)->create();
+
+ foreach (ApplicationLocale::SUPPORTED as $locale) {
+ Cache::put(PublicProfileCache::key((string) $user->getKey(), $locale), 'seed', 60);
+ }
+
+ $profile->update(['headline' => 'mudou']);
+
+ foreach (ApplicationLocale::SUPPORTED as $locale) {
+ expect(Cache::has(PublicProfileCache::key((string) $user->getKey(), $locale)))
+ ->toBeFalse("a entrada de {$locale} sobreviveu ao forget()");
+ }
+});
diff --git a/app-modules/profile/tests/Feature/PublicProfileGamificationTest.php b/app-modules/profile/tests/Feature/PublicProfileGamificationTest.php
new file mode 100644
index 00000000..010e88d6
--- /dev/null
+++ b/app-modules/profile/tests/Feature/PublicProfileGamificationTest.php
@@ -0,0 +1,170 @@
+withoutVite();
+});
+
+function characterFor(User $user, int $experience = 1_500): Character
+{
+ return Character::factory()->for($user)->create(['experience' => $experience]);
+}
+
+it('renders the level derived from the experience', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+
+ characterFor($user);
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertSee('Comunidade')
+ ->assertSee('Nível 6');
+});
+
+it('renders each earned badge with its name and description', function (): void {
+ $user = User::factory()->create(['username' => 'colecionador']);
+ $character = characterFor($user);
+
+ $badge = Badge::factory()->create([
+ 'name' => 'Fundador',
+ 'description' => 'Esteve na He4rt desde o primeiro dia.',
+ ]);
+
+ $character->badges()->attach($badge, ['claimed_at' => now()]);
+
+ $this->get('/@colecionador')
+ ->assertOk()
+ ->assertSee('Fundador')
+ ->assertSee('Esteve na He4rt desde o primeiro dia.');
+});
+
+it('never publishes the badge redeem code', function (): void {
+ $user = User::factory()->create(['username' => 'colecionador']);
+ $character = characterFor($user);
+
+ $badge = Badge::factory()->create([
+ 'name' => 'Fundador',
+ 'redeem_code' => 'HE4RT-SECRET-2026',
+ ]);
+
+ $character->badges()->attach($badge, ['claimed_at' => now()]);
+
+ $this->get('/@colecionador')
+ ->assertOk()
+ ->assertSee('Fundador')
+ ->assertDontSee('HE4RT-SECRET-2026');
+});
+
+it('keeps the redeem code out of the DTO entirely', function (): void {
+ $user = User::factory()->create();
+ $character = characterFor($user);
+
+ $character->badges()->attach(Badge::factory()->create(), ['claimed_at' => now()]);
+
+ $badge = resolve(BuildPublicProfile::class)->handle($user)->badges[0];
+
+ expect($badge)->toBeInstanceOf(ProfileBadgeData::class)
+ ->and($badge)->not->toHaveProperty('redeem_code')
+ ->and($badge)->not->toHaveProperty('redeemCode');
+});
+
+it('renders the badge image when the badge has one', function (): void {
+ Storage::fake('public');
+
+ $user = User::factory()->create(['username' => 'ilustrado']);
+ $character = characterFor($user);
+
+ $badge = Badge::factory()->create(['name' => 'Speaker']);
+ $badge->addMediaFromString('fake-png')
+ ->usingFileName('speaker.png')
+ ->toMediaCollection('badge');
+
+ $character->badges()->attach($badge, ['claimed_at' => now()]);
+
+ $this->get('/@ilustrado')
+ ->assertOk()
+ ->assertSee('speaker.png');
+});
+
+it('renders a badge without an image', function (): void {
+ $user = User::factory()->create(['username' => 'sem-imagem']);
+ $character = characterFor($user);
+
+ $character->badges()->attach(
+ Badge::factory()->create(['name' => 'Beta Tester']),
+ ['claimed_at' => now()],
+ );
+
+ expect(resolve(BuildPublicProfile::class)->handle($user)->badges[0]->imageUrl)->toBeNull();
+
+ $this->get('/@sem-imagem')
+ ->assertOk()
+ ->assertSee('Beta Tester');
+});
+
+it('hides the whole community section when the member never played', function (): void {
+ User::factory()->create(['username' => 'vazio']);
+
+ $this->get('/@vazio')
+ ->assertOk()
+ ->assertDontSee('Comunidade')
+ ->assertDontSee('Nível');
+});
+
+it('tells how much XP the next level needs', function (): void {
+ $user = User::factory()->create(['username' => 'subindo']);
+
+ characterFor($user, experience: 2_400);
+
+ $this->get('/@subindo')
+ ->assertOk()
+ ->assertSee('2.400 XP')
+ ->assertSee('400')
+ ->assertSee('para o próximo nível');
+});
+
+it('shows only the total at the level cap, where there is no next level', function (): void {
+ $user = User::factory()->create(['username' => 'lendario']);
+
+ characterFor($user, experience: 450_000);
+
+ $this->get('/@lendario')
+ ->assertOk()
+ ->assertSee('Nível 50')
+ ->assertSee('450.000 XP')
+ ->assertDontSee('para o próximo nível');
+});
+
+it('tells how long the person has been a member', function (): void {
+ $user = User::factory()->create([
+ 'username' => 'veterano',
+ 'created_at' => now()->subMonths(16),
+ ]);
+
+ characterFor($user);
+
+ $this->get('/@veterano')
+ ->assertOk()
+ ->assertSee('Membro há 1 ano e 4 meses');
+});
+
+it('hides the membership line during the first month', function (): void {
+ $user = User::factory()->create([
+ 'username' => 'recem-chegado',
+ 'created_at' => now()->subDays(10),
+ ]);
+
+ characterFor($user);
+
+ $this->get('/@recem-chegado')
+ ->assertOk()
+ ->assertDontSee('Membro há');
+});
diff --git a/app-modules/profile/tests/Feature/PublicProfileHeaderTest.php b/app-modules/profile/tests/Feature/PublicProfileHeaderTest.php
new file mode 100644
index 00000000..c54961ec
--- /dev/null
+++ b/app-modules/profile/tests/Feature/PublicProfileHeaderTest.php
@@ -0,0 +1,131 @@
+withoutVite();
+});
+
+function connectGithub(User $user, string $handle, ?CarbonInterface $disconnectedAt = null): ExternalIdentity
+{
+ return ExternalIdentity::factory()->create([
+ 'model_type' => $user->getMorphClass(),
+ 'model_id' => $user->getKey(),
+ 'provider' => IdentityProvider::GitHub,
+ 'metadata' => ['username' => $handle],
+ 'connected_at' => now(),
+ 'disconnected_at' => $disconnectedAt,
+ ]);
+}
+
+it('renders every header field of a filled profile', function (): void {
+ $user = User::factory()->create([
+ 'name' => 'Daniel Reis',
+ 'username' => 'danielhe4rt',
+ ]);
+
+ $profile = Profile::factory()->for($user)->create([
+ 'nickname' => 'dani',
+ 'headline' => 'Developer Advocate na He4rt',
+ 'available_for_proposals' => true,
+ ]);
+
+ WorkExperience::factory()->for($profile)->current()->create([
+ 'company_name' => 'ScyllaDB',
+ 'position' => 'Developer Advocate',
+ ]);
+
+ Address::factory()->forUser($user)->create([
+ 'city' => 'São Paulo',
+ 'state' => 'SP',
+ 'country' => 'BR',
+ ]);
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertSee('Daniel Reis')
+ ->assertSee('@danielhe4rt')
+ ->assertSee('dani')
+ ->assertSee('Developer Advocate na He4rt')
+ ->assertSee('ScyllaDB')
+ ->assertSee('Disponível para propostas')
+ ->assertSee('São Paulo, SP, BR');
+});
+
+it('renders an empty profile without leaking nulls or placeholders', function (): void {
+ User::factory()->create([
+ 'name' => 'Perfil Vazio',
+ 'username' => 'vazio',
+ ]);
+
+ $this->get('/@vazio')
+ ->assertOk()
+ ->assertSee('Perfil Vazio')
+ ->assertSee('@vazio')
+ ->assertDontSee('Disponível para propostas')
+ ->assertDontSee('null');
+});
+
+it('falls back to the picture of the connected github account', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+
+ connectGithub($user, 'dani-no-github');
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertSee('https://github.com/dani-no-github.png');
+});
+
+it('never guesses a github picture out of the he4rt username', function (): void {
+ User::factory()->create(['username' => 'vazio', 'name' => 'Perfil Vazio']);
+
+ $this->get('/@vazio')
+ ->assertOk()
+ ->assertDontSee('github.com/vazio.png')
+ ->assertSee('PV');
+});
+
+it('draws initials instead of a picture when the github account is disconnected', function (): void {
+ $user = User::factory()->create(['username' => 'saiu', 'name' => 'Fulano Silva']);
+
+ connectGithub($user, 'fulano-gh', disconnectedAt: now());
+
+ $this->get('/@saiu')
+ ->assertOk()
+ ->assertDontSee('github.com/fulano-gh.png')
+ ->assertSee('FS');
+});
+
+it('never leaks private fields into the public page', function (): void {
+ $user = User::factory()->create([
+ 'username' => 'privado',
+ 'email' => 'privado@he4rt.dev',
+ ]);
+
+ Profile::factory()->for($user)->create([
+ 'birthdate' => '1995-03-14',
+ 'expected_salary_min' => '15000.00',
+ 'expected_salary_max' => '25000.00',
+ ]);
+
+ Address::factory()->forUser($user)->create([
+ 'city' => 'São Paulo',
+ 'zip_code' => '01310-100',
+ ]);
+
+ $this->get('/@privado')
+ ->assertOk()
+ ->assertDontSee('privado@he4rt.dev')
+ ->assertDontSee('1995-03-14')
+ ->assertDontSee('15000')
+ ->assertDontSee('25000')
+ ->assertDontSee('01310-100');
+});
diff --git a/app-modules/profile/tests/Feature/PublicProfileLinksTest.php b/app-modules/profile/tests/Feature/PublicProfileLinksTest.php
new file mode 100644
index 00000000..f828e5db
--- /dev/null
+++ b/app-modules/profile/tests/Feature/PublicProfileLinksTest.php
@@ -0,0 +1,123 @@
+withoutVite();
+});
+
+function connectIdentity(User $user, IdentityProvider $provider, array $metadata, ?CarbonInterface $disconnectedAt = null): ExternalIdentity
+{
+ return ExternalIdentity::factory()->create([
+ 'model_type' => $user->getMorphClass(),
+ 'model_id' => $user->getKey(),
+ 'provider' => $provider,
+ 'metadata' => $metadata,
+ 'connected_at' => now(),
+ 'disconnected_at' => $disconnectedAt,
+ ]);
+}
+
+it('renders social links with their resolved urls', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+
+ Profile::factory()->for($user)->create([
+ 'social_links' => [
+ SocialPlatform::LinkedIn->value => 'danielhe4rt',
+ SocialPlatform::Instagram->value => '@danielhe4rt',
+ SocialPlatform::Website->value => 'https://he4rt.dev',
+ ],
+ ]);
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertSee('Links')
+ ->assertSee('https://linkedin.com/in/danielhe4rt')
+ ->assertSee('https://instagram.com/danielhe4rt')
+ ->assertSee('https://he4rt.dev');
+});
+
+it('links a connected github account', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+ connectIdentity($user, IdentityProvider::GitHub, ['username' => 'danielhe4rt']);
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertSee('https://github.com/danielhe4rt');
+});
+
+it('shows a discord handle without a link', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+ connectIdentity($user, IdentityProvider::Discord, ['username' => 'dani#0001']);
+
+ $data = resolve(BuildPublicProfile::class)->handle($user);
+
+ expect($data->connectedAccounts)->toHaveCount(1)
+ ->and($data->connectedAccounts[0]->handle)->toBe('dani#0001')
+ ->and($data->connectedAccounts[0]->url)->toBeNull();
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertSee('dani#0001');
+});
+
+it('skips disconnected accounts', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+ connectIdentity($user, IdentityProvider::GitHub, ['username' => 'antigo'], now());
+
+ $data = resolve(BuildPublicProfile::class)->handle($user);
+
+ expect($data->connectedAccounts)->toBeEmpty();
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertDontSee('antigo');
+});
+
+it('skips providers outside the supported set', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+ connectIdentity($user, IdentityProvider::Steam, ['username' => 'steamhandle']);
+
+ $data = resolve(BuildPublicProfile::class)->handle($user);
+
+ expect($data->connectedAccounts)->toBeEmpty();
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertDontSee('steamhandle');
+});
+
+it('never leaks the email or the oauth tokens of a connected account', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+
+ $identity = connectIdentity($user, IdentityProvider::GitHub, [
+ 'username' => 'danielhe4rt',
+ 'email' => 'segredo@he4rt.dev',
+ ]);
+
+ $response = $this->get('/@danielhe4rt')->assertOk();
+
+ $response->assertSee('danielhe4rt')
+ ->assertDontSee('segredo@he4rt.dev');
+
+ $rawCredentials = (string) $identity->getRawOriginal('credentials');
+
+ expect($rawCredentials)->not->toBeEmpty()
+ ->and($response->getContent())->not->toContain($rawCredentials);
+});
+
+it('hides the links section when there is nothing to link', function (): void {
+ User::factory()->create(['username' => 'vazio']);
+
+ $this->get('/@vazio')
+ ->assertOk()
+ ->assertDontSee('Links');
+});
diff --git a/app-modules/profile/tests/Feature/PublicProfileMetaTest.php b/app-modules/profile/tests/Feature/PublicProfileMetaTest.php
new file mode 100644
index 00000000..f478834f
--- /dev/null
+++ b/app-modules/profile/tests/Feature/PublicProfileMetaTest.php
@@ -0,0 +1,165 @@
+withoutVite();
+});
+
+it('publishes the open graph card of a profile page', function (): void {
+ $user = User::factory()->create(['name' => 'Daniel', 'username' => 'danielhe4rt']);
+
+ Profile::factory()->for($user)->create(['headline' => 'Developer Relations na He4rt']);
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertSee('', escape: false)
+ ->assertSee('', escape: false)
+ ->assertSee('', escape: false)
+ ->assertSee('', escape: false)
+ ->assertSee('', escape: false)
+ ->assertSee('', escape: false)
+ ->assertSee('', escape: false);
+});
+
+it('herda os defaults de SEO do site', function (): void {
+ User::factory()->create(['username' => 'herdeiro']);
+
+ $this->get('/@herdeiro')
+ ->assertOk()
+ ->assertSee('', escape: false)
+ ->assertSee('', escape: false)
+ ->assertSee('', escape: false)
+ ->assertSee('"@type":"Organization"', escape: false);
+});
+
+it('describes the page as a Person for search engines', function (): void {
+ $user = User::factory()->create(['name' => 'Daniel', 'username' => 'danielhe4rt']);
+ $profile = Profile::factory()->for($user)->create(['headline' => 'Developer Relations na He4rt']);
+
+ WorkExperience::factory()->for($profile)->create([
+ 'position' => 'Developer Relations',
+ 'company_name' => 'He4rt',
+ 'is_currently_working_here' => true,
+ ]);
+
+ $html = $this->get('/@danielhe4rt')->assertOk()->getContent();
+
+ expect($html)
+ ->toContain('"@type":"Person"')
+ ->toContain('"name":"Daniel"')
+ ->toContain('"url":"'.secure_url('/@danielhe4rt').'"')
+ ->toContain('"alternateName":"@danielhe4rt"')
+ ->toContain('"jobTitle":"Developer Relations"')
+ ->toContain('"worksFor":{"@type":"Organization","name":"He4rt"}');
+});
+
+it('falls back to the current role when there is no headline', function (): void {
+ $user = User::factory()->create(['username' => 'sem-headline']);
+ $profile = Profile::factory()->for($user)->create([
+ 'headline' => null,
+ 'about' => 'Bio que não deve ganhar do cargo atual.',
+ ]);
+
+ WorkExperience::factory()->for($profile)->create([
+ 'position' => 'Backend Engineer',
+ 'company_name' => 'He4rt',
+ 'is_currently_working_here' => true,
+ ]);
+
+ $this->get('/@sem-headline')
+ ->assertOk()
+ ->assertSee('', escape: false);
+});
+
+it('falls back to the bio when there is no headline nor current role', function (): void {
+ $user = User::factory()->create(['username' => 'so-bio']);
+
+ Profile::factory()->for($user)->create([
+ 'headline' => null,
+ 'about' => 'Escrevo Go e cuido de comunidade.',
+ ]);
+
+ $this->get('/@so-bio')
+ ->assertOk()
+ ->assertSee('', escape: false);
+});
+
+it('falls back to a plain sentence on an empty profile', function (): void {
+ User::factory()->create(['name' => 'Fulano', 'username' => 'vazio']);
+
+ $this->get('/@vazio')
+ ->assertOk()
+ ->assertSee(
+ '',
+ escape: false,
+ );
+});
+
+it('trims a long bio down to a card-sized description', function (): void {
+ $user = User::factory()->create(['username' => 'prolixo']);
+
+ Profile::factory()->for($user)->create([
+ 'headline' => null,
+ 'about' => str_repeat('a', 300),
+ ]);
+
+ $this->get('/@prolixo')
+ ->assertOk()
+ ->assertSee('', escape: false);
+});
+
+it('shares the cover image when there is one', function (): void {
+ Storage::fake('public');
+
+ $user = User::factory()->create(['username' => 'com-capa']);
+ $user->addMediaFromString('fake-png')
+ ->usingFileName('capa.png')
+ ->toMediaCollection('cover');
+
+ $this->get('/@com-capa')
+ ->assertOk()
+ ->assertSee('', escape: false)
+ ->assertSee('', escape: false);
+});
+
+it('shares the avatar when there is no cover', function (): void {
+ Storage::fake('public');
+
+ $user = User::factory()->create(['username' => 'sem-capa']);
+ $user->addMediaFromString('fake-png')
+ ->usingFileName('avatar.png')
+ ->toMediaCollection('avatar');
+
+ $avatarUrl = resolve(BuildPublicProfile::class)->handle($user)->avatarUrl;
+
+ $this->get('/@sem-capa')
+ ->assertOk()
+ ->assertSee('', escape: false);
+});
+
+it('falls back to the site image when there is no cover and no picture', function (): void {
+ User::factory()->create(['username' => 'sem-nada']);
+
+ $this->get('/@sem-nada')
+ ->assertOk()
+ ->assertSee('', escape: false);
+});
+
+it('credits the member on the twitter card when they linked their X', function (): void {
+ $user = User::factory()->create(['username' => 'tuiteiro']);
+
+ Profile::factory()->for($user)->create([
+ 'social_links' => ['twitter' => '@gabrielfvdev'],
+ ]);
+
+ $this->get('/@tuiteiro')
+ ->assertOk()
+ ->assertSee('', escape: false);
+});
diff --git a/app-modules/profile/tests/Feature/PublicProfileProjectsTest.php b/app-modules/profile/tests/Feature/PublicProfileProjectsTest.php
new file mode 100644
index 00000000..515f1b0b
--- /dev/null
+++ b/app-modules/profile/tests/Feature/PublicProfileProjectsTest.php
@@ -0,0 +1,94 @@
+withoutVite();
+});
+
+it('renders each project with name, description and link', function (): void {
+ $user = User::factory()->create(['username' => 'construtora']);
+ $profile = Profile::factory()->for($user)->create();
+
+ ProfileProject::factory()->for($profile)->create([
+ 'name' => 'Bot da Comunidade',
+ 'description' => 'Bot de Discord que cuida do check-in dos eventos.',
+ 'url' => 'https://github.com/construtora/bot',
+ ]);
+
+ $this->get('/@construtora')
+ ->assertOk()
+ ->assertSee('Projetos')
+ ->assertSee('Bot da Comunidade')
+ ->assertSee('Bot de Discord que cuida do check-in dos eventos.')
+ ->assertSee('https://github.com/construtora/bot');
+});
+
+it('renders a name-only project without link or description', function (): void {
+ $user = User::factory()->create(['username' => 'minimalista']);
+ $profile = Profile::factory()->for($user)->create();
+
+ ProfileProject::factory()->for($profile)->create([
+ 'name' => 'Projeto Secreto',
+ 'description' => null,
+ 'url' => null,
+ ]);
+
+ $this->get('/@minimalista')
+ ->assertOk()
+ ->assertSee('Projeto Secreto');
+});
+
+it('hides the section when there are no projects', function (): void {
+ $user = User::factory()->create(['username' => 'sem-projetos']);
+
+ Profile::factory()->for($user)->create(['about' => 'Só a bio.']);
+
+ $this->get('/@sem-projetos')
+ ->assertOk()
+ ->assertDontSee('Projetos');
+});
+
+it('drops a url whose scheme is not http', function (): void {
+ $user = User::factory()->create(['username' => 'esperto']);
+ $profile = Profile::factory()->for($user)->create();
+
+ ProfileProject::factory()->for($profile)->create([
+ 'name' => 'Projeto Malicioso',
+ 'url' => 'javascript:alert(1)',
+ ]);
+
+ $data = resolve(BuildPublicProfile::class)->handle($user->refresh());
+
+ expect($data->projects)->toHaveCount(1)
+ ->and($data->projects[0]->url)->toBeNull();
+
+ $this->get('/@esperto')
+ ->assertOk()
+ ->assertSee('Projeto Malicioso')
+ ->assertDontSee('javascript:alert(1)');
+});
+
+it('lists the newest project first', function (): void {
+ $user = User::factory()->create(['username' => 'cronologico']);
+ $profile = Profile::factory()->for($user)->create();
+
+ ProfileProject::factory()->for($profile)->create([
+ 'name' => 'Projeto Antigo',
+ 'created_at' => now()->subYear(),
+ ]);
+ ProfileProject::factory()->for($profile)->create([
+ 'name' => 'Projeto Novo',
+ 'created_at' => now(),
+ ]);
+
+ $data = resolve(BuildPublicProfile::class)->handle($user->refresh());
+
+ expect($data->projects[0]->name)->toBe('Projeto Novo')
+ ->and($data->projects[1]->name)->toBe('Projeto Antigo');
+});
diff --git a/app-modules/profile/tests/Feature/PublicProfileQueriesTest.php b/app-modules/profile/tests/Feature/PublicProfileQueriesTest.php
new file mode 100644
index 00000000..e1f266db
--- /dev/null
+++ b/app-modules/profile/tests/Feature/PublicProfileQueriesTest.php
@@ -0,0 +1,68 @@
+withoutVite();
+});
+
+function seedProfilePage(string $username, int $rows): void
+{
+ $user = User::factory()->create(['username' => $username]);
+ $profile = Profile::factory()->for($user)->create();
+
+ Address::factory()->forUser($user)->create();
+
+ WorkExperience::factory()->for($profile)->current()->create();
+ WorkExperience::factory()->count($rows)->for($profile)->create(['is_currently_working_here' => false]);
+
+ ProfileProject::factory()->count($rows)->for($profile)->create();
+
+ for ($i = 0; $i < $rows; $i++) {
+ ProfileSkill::factory()->for($profile)->create([
+ 'skill_id' => Skill::factory()->create()->id,
+ ]);
+ }
+
+ $character = Character::factory()->for($user)->create();
+
+ for ($i = 0; $i < $rows; $i++) {
+ $character->badges()->attach(Badge::factory()->create(), ['claimed_at' => now()]);
+ }
+}
+
+function countQueriesFor(string $username): int
+{
+ $count = 0;
+
+ DB::listen(function (QueryExecuted $query) use (&$count): void {
+ $count++;
+ });
+
+ test()->get('/@'.$username)->assertOk();
+
+ return $count;
+}
+
+it('keeps the query count flat as the profile grows', function (): void {
+ seedProfilePage('pequeno', rows: 1);
+ seedProfilePage('grande', rows: 20);
+
+ $small = countQueriesFor('pequeno');
+ $large = countQueriesFor('grande');
+
+ expect($large)->toBe($small)
+ ->and($small)->toBeLessThanOrEqual(13);
+});
diff --git a/app-modules/profile/tests/Feature/PublicProfileResumeTest.php b/app-modules/profile/tests/Feature/PublicProfileResumeTest.php
new file mode 100644
index 00000000..44f9fb35
--- /dev/null
+++ b/app-modules/profile/tests/Feature/PublicProfileResumeTest.php
@@ -0,0 +1,156 @@
+withoutVite();
+});
+
+it('renders skills with proficiency and years', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+ $profile = Profile::factory()->for($user)->create();
+
+ $skill = Skill::factory()->create([
+ 'name' => 'Rust',
+ 'category' => SkillCategory::Language,
+ ]);
+
+ ProfileSkill::factory()->for($profile)->create([
+ 'skill_id' => $skill->id,
+ 'proficiency' => SkillProficiency::Advanced,
+ 'years_experience' => 4,
+ ]);
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertSee('Skills')
+ ->assertSee('Rust')
+ ->assertSee(SkillProficiency::Advanced->getLabel())
+ ->assertSee('4 anos');
+});
+
+it('sorts skills by name', function (): void {
+ $user = User::factory()->create();
+ $profile = Profile::factory()->for($user)->create();
+
+ foreach (['Zig', 'Ada', 'Rust'] as $name) {
+ ProfileSkill::factory()->for($profile)->create([
+ 'skill_id' => Skill::factory()->create(['name' => $name])->id,
+ ]);
+ }
+
+ $data = resolve(BuildPublicProfile::class)->handle($user);
+
+ expect(array_map(fn (ProfileSkillData $skill): string => $skill->name, $data->skills))
+ ->toBe(['Ada', 'Rust', 'Zig']);
+});
+
+it('renders work experiences with company, position and period', function (): void {
+ $user = User::factory()->create(['username' => 'danielhe4rt']);
+ $profile = Profile::factory()->for($user)->create();
+
+ WorkExperience::factory()->for($profile)->create([
+ 'company_name' => 'Empresa Antiga',
+ 'position' => 'Estagiário',
+ 'description' => 'Primeiro emprego.',
+ 'start_date' => '2019-01-01',
+ 'end_date' => '2020-07-01',
+ 'is_currently_working_here' => false,
+ ]);
+
+ WorkExperience::factory()->for($profile)->current()->create([
+ 'company_name' => 'ScyllaDB',
+ 'position' => 'Developer Advocate',
+ 'description' => 'Comunidade e conteúdo técnico.',
+ 'start_date' => '2023-01-01',
+ ]);
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertSee('Experiência profissional')
+ ->assertSee('ScyllaDB')
+ ->assertSee('Developer Advocate')
+ ->assertSee('01/2023 — atual')
+ ->assertSee('Comunidade e conteúdo técnico.')
+ ->assertSee('Empresa Antiga')
+ ->assertSee('01/2019 — 07/2020')
+ ->assertSee('1 ano e 6 meses');
+});
+
+it('puts the ongoing job first', function (): void {
+ $user = User::factory()->create();
+ $profile = Profile::factory()->for($user)->create();
+
+ WorkExperience::factory()->for($profile)->create([
+ 'company_name' => 'Antiga',
+ 'start_date' => '2019-01-01',
+ 'end_date' => '2020-01-01',
+ 'is_currently_working_here' => false,
+ ]);
+
+ WorkExperience::factory()->for($profile)->current()->create([
+ 'company_name' => 'Atual',
+ 'start_date' => '2021-01-01',
+ ]);
+
+ $data = resolve(BuildPublicProfile::class)->handle($user);
+
+ expect($data->experiences[0]->company)->toBe('Atual')
+ ->and($data->experiences[0]->isCurrent)->toBeTrue()
+ ->and($data->experiences[1]->company)->toBe('Antiga');
+});
+
+it('shows only the start date when a past job has no end date', function (): void {
+ $user = User::factory()->create();
+ $profile = Profile::factory()->for($user)->create();
+
+ WorkExperience::factory()->for($profile)->create([
+ 'start_date' => '2018-03-01',
+ 'end_date' => null,
+ 'is_currently_working_here' => false,
+ ]);
+
+ $data = resolve(BuildPublicProfile::class)->handle($user);
+
+ expect($data->experiences[0]->period)->toBe('03/2018')
+ ->and($data->experiences[0]->duration)->toBeNull();
+});
+
+it('spells durations in months, years, or both', function (int $months, string $expected): void {
+ $user = User::factory()->create();
+ $profile = Profile::factory()->for($user)->create();
+
+ $start = now()->subMonths($months)->startOfMonth();
+
+ WorkExperience::factory()->for($profile)->create([
+ 'start_date' => $start,
+ 'end_date' => $start->copy()->addMonths($months),
+ 'is_currently_working_here' => false,
+ ]);
+
+ expect(resolve(BuildPublicProfile::class)->handle($user)->experiences[0]->duration)->toBe($expected);
+})->with([
+ 'one month' => [1, '1 mês'],
+ 'some months' => [7, '7 meses'],
+ 'exactly one year' => [12, '1 ano'],
+ 'years and months' => [30, '2 anos e 6 meses'],
+]);
+
+it('hides both sections when the profile has no resume', function (): void {
+ User::factory()->create(['username' => 'vazio']);
+
+ $this->get('/@vazio')
+ ->assertOk()
+ ->assertDontSee('Skills')
+ ->assertDontSee('Experiência profissional');
+});
diff --git a/app-modules/profile/tests/Feature/PublicProfileRoutingTest.php b/app-modules/profile/tests/Feature/PublicProfileRoutingTest.php
new file mode 100644
index 00000000..ed2b3076
--- /dev/null
+++ b/app-modules/profile/tests/Feature/PublicProfileRoutingTest.php
@@ -0,0 +1,68 @@
+withoutVite();
+});
+
+it('renders a public profile without authentication', function (): void {
+ User::factory()->create([
+ 'name' => 'Daniel Reis',
+ 'username' => 'danielhe4rt',
+ ]);
+
+ $this->get('/@danielhe4rt')
+ ->assertOk()
+ ->assertSee('Daniel Reis')
+ ->assertSee('@danielhe4rt');
+ $this->assertGuest();
+});
+
+it('returns 404 for an unknown username', function (): void {
+ $this->get('/@ninguem')->assertNotFound();
+});
+
+it('returns 404 for a banned user', function (): void {
+ User::factory()->create([
+ 'username' => 'banido',
+ 'banned_at' => now(),
+ ]);
+
+ $this->get('/@banido')->assertNotFound();
+});
+
+it('still renders the profile of a suspended user', function (): void {
+ User::factory()->create([
+ 'name' => 'Suspenso Temporariamente',
+ 'username' => 'suspenso',
+ 'suspended_until' => now()->addDays(7),
+ ]);
+
+ $this->get('/@suspenso')
+ ->assertOk()
+ ->assertSee('Suspenso Temporariamente');
+});
+
+it('throttles a burst of requests from the same IP', function (): void {
+ User::factory()->create(['username' => 'alvo']);
+
+ foreach (range(1, 60) as $ignored) {
+ $this->get('/@alvo')->assertOk();
+ }
+
+ $this->get('/@alvo')->assertStatus(429);
+});
+
+it('counts the throttle per IP, not per profile', function (): void {
+ User::factory()->create(['username' => 'primeiro']);
+ User::factory()->create(['username' => 'segundo']);
+
+ foreach (range(1, 60) as $ignored) {
+ $this->get('/@primeiro')->assertOk();
+ }
+
+ $this->get('/@segundo')->assertStatus(429);
+});
diff --git a/app-modules/profile/tests/Unit/ProfileInitialsTest.php b/app-modules/profile/tests/Unit/ProfileInitialsTest.php
new file mode 100644
index 00000000..ff237d48
--- /dev/null
+++ b/app-modules/profile/tests/Unit/ProfileInitialsTest.php
@@ -0,0 +1,25 @@
+toBe('DR');
+});
+
+it('ignores extra whitespace between the words', function (): void {
+ expect(ProfileInitials::for(' Daniel Reis '))->toBe('DR');
+});
+
+it('stops at two letters however long the name is', function (): void {
+ expect(ProfileInitials::for('Ana Maria de Souza Lima'))->toBe('AM');
+});
+
+it('keeps accented letters', function (): void {
+ expect(ProfileInitials::for('Ávila Ñunes'))->toBe('ÁÑ');
+});
+
+it('skips words that do not start with a letter', function (): void {
+ expect(ProfileInitials::for('42 Daniel 7 Reis'))->toBe('DR');
+});
diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
index fc4e2c2e..dcc445d2 100644
--- a/app/Providers/RouteServiceProvider.php
+++ b/app/Providers/RouteServiceProvider.php
@@ -42,5 +42,9 @@ public function boot(): void
private function configureRateLimiting(): void
{
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()));
+
+ RateLimiter::for('public-profile', fn (Request $request) => Limit::perMinute(60)->by($request->ip()));
+
+ RateLimiter::for('profile-card', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->id ?: $request->ip()));
}
}
diff --git a/app/Support/ProfileHandle.php b/app/Support/ProfileHandle.php
new file mode 100644
index 00000000..1e487f54
--- /dev/null
+++ b/app/Support/ProfileHandle.php
@@ -0,0 +1,19 @@
+toBe('https://github.com/gabrielfvdev');
+});
+
+it('drops the leading at sign', function (): void {
+ expect(ProfileHandle::url('https://x.com/', '@gabrielfvdev'))
+ ->toBe('https://x.com/gabrielfvdev');
+});
+
+it('trims the handle before building the url', function (): void {
+ expect(ProfileHandle::url('https://dev.to/', ' @danielhe4rt '))
+ ->toBe('https://dev.to/danielhe4rt');
+});
+
+it('keeps a handle that is already an absolute url', function (string $url): void {
+ expect(ProfileHandle::url('https://github.com/', $url))->toBe($url);
+})->with([
+ 'https' => 'https://github.com/he4rt',
+ 'http' => 'http://example.com/perfil',
+]);
+
+it('trims an absolute url too', function (): void {
+ expect(ProfileHandle::url('https://github.com/', ' https://github.com/he4rt '))
+ ->toBe('https://github.com/he4rt');
+});