Skip to content

feat(profile): UpsertProfile and ToggleAvailability actions#283

Closed
Hadzab wants to merge 1 commit into
4.xfrom
feat/253-upsert-profile-actions
Closed

feat(profile): UpsertProfile and ToggleAvailability actions#283
Hadzab wants to merge 1 commit into
4.xfrom
feat/253-upsert-profile-actions

Conversation

@Hadzab
Copy link
Copy Markdown

@Hadzab Hadzab commented May 25, 2026

Closes #253

What was done

Implements the domain logic of the Profile module with two distinct actions and a DTO:

  • UpsertProfileDTO — DTO with the editable profile fields (nickname, birthdate, about, headline, seniorityLevel, yearsExperience, socialLinks) with a fromArray() factory method.
  • UpsertProfile — Action that receives an existing Profile and a DTO, validates the fields and performs a pure update. Validates about (max 500), headline (max 100), years_experience (0-50) and social_links against the SocialPlatform enum.
  • ToggleAvailability — Atomic action that toggles availability for proposals. When enabled, start_availability is required. When disabled, preserves the previous value.

Tests

11 feature tests covering all BDD scenarios from the issue:

  • updates all profile fields
  • partially updates only the headline
  • rejects bio above 500 characters
  • rejects headline above 100 characters
  • saves social_links with valid platforms
  • rejects social_links with invalid platform
  • rejects years_experience out of range
  • activates availability with a start date
  • rejects activating availability without a start date
  • deactivating availability preserves the previous start date
  • changes start date without changing availability status

Files created

  • app-modules/profile/src/Actions/UpsertProfile.php
  • app-modules/profile/src/Actions/ToggleAvailability.php
  • app-modules/profile/src/DTOs/UpsertProfileDTO.php
  • app-modules/profile/tests/Feature/UpsertProfileTest.php.php

Description

Implements domain logic for the Profile module by introducing UpsertProfile and ToggleAvailability actions to replace legacy Identity-based profile update flows. The PR adds a DTO with factory method for profile editing, two domain actions with comprehensive validation rules, and 11 feature tests covering all BDD scenarios. Resolves issue #253.

References

  • Issue #253: Profile domain actions

Dependencies & Requirements

No new external dependencies are introduced. The implementation uses existing Laravel/Illuminate facades and the SeniorityLevel and SocialPlatform enums already present in the codebase.

Contributor Summary

Contributor Lines Added Lines Removed Files Changed
Hadzab 253 0 4

Changes Summary

File Path Change Description
app-modules/profile/src/DTOs/UpsertProfileDTO.php New DTO with nullable fields (nickname, birthdate, about, headline, seniorityLevel, yearsExperience, socialLinks) and fromArray() factory for mapping input data
app-modules/profile/src/Actions/UpsertProfile.php Domain action to update Profile with validation: about ≤500 chars, headline ≤100 chars, yearsExperience 0–50, socialLinks keys validated against SocialPlatform enum
app-modules/profile/src/Actions/ToggleAvailability.php Atomic action to toggle available_for_proposals with required start_availability when enabling and preservation when disabling
app-modules/profile/tests/Feature/UpsertProfileTest.php Feature test suite with 11 tests covering full/partial updates, validation failures, social link validation, and availability toggle behaviors

Review Change Stack

@Hadzab Hadzab requested a review from a team May 25, 2026 02:28
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 25, 2026

📝 Walkthrough

Walkthrough

This pull request introduces two profile management actions and their supporting infrastructure. It adds UpsertProfileDTO, a typed data transfer object with a factory method for mapping input arrays to strongly-typed properties. The UpsertProfile action validates profile field constraints—including text length limits and numeric ranges—and updates only non-null fields on an existing Profile model. The ToggleAvailability action manages proposal availability state with conditional validation: it requires a start deadline when enabling availability but preserves the previous deadline when disabling. Comprehensive feature tests validate all field constraints, valid and invalid platform identifiers in social links, and the complete availability toggle lifecycle.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main changes: two new domain actions (UpsertProfile and ToggleAvailability) being added to the Profile module.
Linked Issues check ✅ Passed All coding requirements from issue #253 are met: UpsertProfileDTO with factory, UpsertProfile action with validation (about/headline/years_experience/social_links), ToggleAvailability with required start_availability on enable, and comprehensive BDD test coverage.
Out of Scope Changes check ✅ Passed All changes are directly aligned with issue #253 objectives: two new action classes, one new DTO, and feature tests covering the specified BDD scenarios, with no unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat/253-upsert-profile-actions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
app-modules/profile/src/Actions/ToggleAvailability.php (1)

15-15: ⚡ Quick win

Simplify validation and localize error message.

Two improvements for this validation:

  1. The instanceof check is redundant given the ?StartAvailability type hint. If $startAvailability is not null, it must be a StartAvailability instance. Simplify to a null check.

  2. The error message is hardcoded in Portuguese. Use Laravel's localization helpers for i18n support.

♻️ Proposed refactor
-throw_if($available && !$startAvailability instanceof StartAvailability, InvalidArgumentException::class, 'O prazo de disponibilidade é obrigatório ao ativar a disponibilidade.');
+throw_if($available && $startAvailability === null, InvalidArgumentException::class, __('profile::validation.start_availability_required'));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/profile/src/Actions/ToggleAvailability.php` at line 15, In
ToggleAvailability.php update the validation throw to use a null check instead
of instanceof and localize the error string: replace the condition
"throw_if($available && !$startAvailability instanceof StartAvailability, ... ,
'...');" with a check like "throw_if($available && is_null($startAvailability),
InvalidArgumentException::class, __('profile.availability.start_required'))"
(use StartAvailability and $startAvailability to locate the code and add a
suitable translation key in your language files).
app-modules/profile/tests/Feature/UpsertProfileTest.php (1)

57-100: ⚡ Quick win

Add regression tests for uncovered nullable/length contract cases.

Please add negative coverage for nickname length (>100) and one scenario asserting explicit null clears a nullable field (once DTO/action semantics are updated).

Example tests
+test('rejeita nickname acima de 100 caracteres', function (): void {
+    $profile = Profile::factory()->create();
+    $dto = UpsertProfileDTO::fromArray(['nickname' => str_repeat('a', 101)]);
+
+    expect(fn () => resolve(UpsertProfile::class)->handle($profile, $dto))
+        ->toThrow(InvalidArgumentException::class);
+});
+
+test('permite limpar campo nullable com null explicito', function (): void {
+    $profile = Profile::factory()->create(['headline' => 'Old Headline']);
+    $dto = UpsertProfileDTO::fromArray(['headline' => null]);
+
+    $updated = resolve(UpsertProfile::class)->handle($profile, $dto);
+    expect($updated->headline)->toBeNull();
+});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/profile/tests/Feature/UpsertProfileTest.php` around lines 57 -
100, Add two tests to UpsertProfileTest mirroring existing patterns: one that
creates a Profile, builds an UpsertProfileDTO with 'nickname' => str_repeat('a',
101) and asserts resolve(UpsertProfile::class)->handle($profile, $dto) throws
InvalidArgumentException (same style as the headline/bio length tests), and a
second that creates a Profile with an existing nickname, calls
UpsertProfileDTO::fromArray(['nickname' => null]) and asserts the returned
$updated from resolve(UpsertProfile::class)->handle($profile, $dto) has
->nickname === null (nullable-clear assertion, matching the
social_links/years_experience test patterns); use the same expect(...) helpers
and exception/type checks as in the file.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app-modules/profile/src/Actions/UpsertProfile.php`:
- Around line 33-40: The validate method is missing a max-length check for
UpsertProfileDTO::nickname; add a throw_if similar to the others inside
validate() that checks $dto->nickname !== null && mb_strlen($dto->nickname) >
100 and throws InvalidArgumentException with a clear Portuguese message (e.g. 'O
apelido não pode ultrapassar 100 caracteres.') so nickname cannot exceed 100
chars.

In `@app-modules/profile/src/DTOs/UpsertProfileDTO.php`:
- Around line 26-35: The DTO factory method fromArray currently collapses
missing keys and explicit nulls into the same property values (see fromArray and
fields like nickname, about, headline, socialLinks, seniorityLevel), so updates
cannot clear values; change fromArray to record which keys were explicitly
provided (e.g. populate a $provided array/property on the DTO for keys present
in the input) and preserve explicit nulls (use isset/array_key_exists checks
when building the DTO, calling Date::parse or SeniorityLevel::from only when the
key exists). Then update He4rt\Profile\Actions\UpsertProfile to filter updates
by the DTO’s provided keys (the new $dto->provided) instead of !is_null($value)
so explicit nulls can be applied to clear fields.

---

Nitpick comments:
In `@app-modules/profile/src/Actions/ToggleAvailability.php`:
- Line 15: In ToggleAvailability.php update the validation throw to use a null
check instead of instanceof and localize the error string: replace the condition
"throw_if($available && !$startAvailability instanceof StartAvailability, ... ,
'...');" with a check like "throw_if($available && is_null($startAvailability),
InvalidArgumentException::class, __('profile.availability.start_required'))"
(use StartAvailability and $startAvailability to locate the code and add a
suitable translation key in your language files).

In `@app-modules/profile/tests/Feature/UpsertProfileTest.php`:
- Around line 57-100: Add two tests to UpsertProfileTest mirroring existing
patterns: one that creates a Profile, builds an UpsertProfileDTO with 'nickname'
=> str_repeat('a', 101) and asserts
resolve(UpsertProfile::class)->handle($profile, $dto) throws
InvalidArgumentException (same style as the headline/bio length tests), and a
second that creates a Profile with an existing nickname, calls
UpsertProfileDTO::fromArray(['nickname' => null]) and asserts the returned
$updated from resolve(UpsertProfile::class)->handle($profile, $dto) has
->nickname === null (nullable-clear assertion, matching the
social_links/years_experience test patterns); use the same expect(...) helpers
and exception/type checks as in the file.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Pro

Run ID: 584ef9fd-ad63-4e1f-bf61-91e8e565378a

📥 Commits

Reviewing files that changed from the base of the PR and between 193445f and 56da1d2.

📒 Files selected for processing (4)
  • app-modules/profile/src/Actions/ToggleAvailability.php
  • app-modules/profile/src/Actions/UpsertProfile.php
  • app-modules/profile/src/DTOs/UpsertProfileDTO.php
  • app-modules/profile/tests/Feature/UpsertProfileTest.php

Comment on lines +33 to +40
private function validate(UpsertProfileDTO $dto): void
{
throw_if($dto->about !== null && mb_strlen($dto->about) > 500, InvalidArgumentException::class, 'O campo "sobre" não pode ultrapassar 500 caracteres.');

throw_if($dto->headline !== null && mb_strlen($dto->headline) > 100, InvalidArgumentException::class, 'O título não pode ultrapassar 100 caracteres.');

throw_if($dto->yearsExperience !== null && ($dto->yearsExperience < 0 || $dto->yearsExperience > 50), InvalidArgumentException::class, 'Os anos de experiência devem estar entre 0 e 50.');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

nickname max-length validation is missing.

Line 35-40 validates about, headline, and yearsExperience, but not nickname (required max 100). This allows invalid profile data through this domain action.

Patch suggestion
     private function validate(UpsertProfileDTO $dto): void
     {
+        throw_if($dto->nickname !== null && mb_strlen($dto->nickname) > 100, InvalidArgumentException::class, 'O apelido não pode ultrapassar 100 caracteres.');
+
         throw_if($dto->about !== null && mb_strlen($dto->about) > 500, InvalidArgumentException::class, 'O campo "sobre" não pode ultrapassar 500 caracteres.');
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private function validate(UpsertProfileDTO $dto): void
{
throw_if($dto->about !== null && mb_strlen($dto->about) > 500, InvalidArgumentException::class, 'O campo "sobre" não pode ultrapassar 500 caracteres.');
throw_if($dto->headline !== null && mb_strlen($dto->headline) > 100, InvalidArgumentException::class, 'O título não pode ultrapassar 100 caracteres.');
throw_if($dto->yearsExperience !== null && ($dto->yearsExperience < 0 || $dto->yearsExperience > 50), InvalidArgumentException::class, 'Os anos de experiência devem estar entre 0 e 50.');
private function validate(UpsertProfileDTO $dto): void
{
throw_if($dto->nickname !== null && mb_strlen($dto->nickname) > 100, InvalidArgumentException::class, 'O apelido não pode ultrapassar 100 caracteres.');
throw_if($dto->about !== null && mb_strlen($dto->about) > 500, InvalidArgumentException::class, 'O campo "sobre" não pode ultrapassar 500 caracteres.');
throw_if($dto->headline !== null && mb_strlen($dto->headline) > 100, InvalidArgumentException::class, 'O título não pode ultrapassar 100 caracteres.');
throw_if($dto->yearsExperience !== null && ($dto->yearsExperience < 0 || $dto->yearsExperience > 50), InvalidArgumentException::class, 'Os anos de experiência devem estar entre 0 e 50.');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/profile/src/Actions/UpsertProfile.php` around lines 33 - 40, The
validate method is missing a max-length check for UpsertProfileDTO::nickname;
add a throw_if similar to the others inside validate() that checks
$dto->nickname !== null && mb_strlen($dto->nickname) > 100 and throws
InvalidArgumentException with a clear Portuguese message (e.g. 'O apelido não
pode ultrapassar 100 caracteres.') so nickname cannot exceed 100 chars.

Comment on lines +26 to +35
public static function fromArray(array $data): self
{
return new self(
nickname: $data['nickname'] ?? null,
birthdate: isset($data['birthdate']) ? Date::parse($data['birthdate']) : null,
about: $data['about'] ?? null,
headline: $data['headline'] ?? null,
seniorityLevel: isset($data['seniority_level']) ? SeniorityLevel::from($data['seniority_level']) : null,
yearsExperience: $data['years_experience'] ?? null,
socialLinks: $data['social_links'] ?? null,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Explicit null updates are not representable in the DTO mapping.

On Line 29-35, missing keys and explicitly provided null values collapse to the same DTO value. Combined with null filtering in the action, nullable fields cannot be cleared (for example about, headline, nickname, social_links).

Proposed direction
 final readonly class UpsertProfileDTO
 {
     public function __construct(
         public ?string $nickname = null,
         public ?Carbon $birthdate = null,
         public ?string $about = null,
         public ?string $headline = null,
         public ?SeniorityLevel $seniorityLevel = null,
         public ?int $yearsExperience = null,
         public ?array $socialLinks = null,
+        /** `@var` array<string, bool> */
+        public array $provided = [],
     ) {}

     public static function fromArray(array $data): self
     {
         return new self(
-            nickname: $data['nickname'] ?? null,
-            birthdate: isset($data['birthdate']) ? Date::parse($data['birthdate']) : null,
-            about: $data['about'] ?? null,
-            headline: $data['headline'] ?? null,
-            seniorityLevel: isset($data['seniority_level']) ? SeniorityLevel::from($data['seniority_level']) : null,
-            yearsExperience: $data['years_experience'] ?? null,
-            socialLinks: $data['social_links'] ?? null,
+            nickname: array_key_exists('nickname', $data) ? $data['nickname'] : null,
+            birthdate: array_key_exists('birthdate', $data) ? Date::parse($data['birthdate']) : null,
+            about: array_key_exists('about', $data) ? $data['about'] : null,
+            headline: array_key_exists('headline', $data) ? $data['headline'] : null,
+            seniorityLevel: array_key_exists('seniority_level', $data) ? SeniorityLevel::from($data['seniority_level']) : null,
+            yearsExperience: array_key_exists('years_experience', $data) ? $data['years_experience'] : null,
+            socialLinks: array_key_exists('social_links', $data) ? $data['social_links'] : null,
+            provided: array_fill_keys(array_keys($data), true),
         );
     }
 }

Then in He4rt\Profile\Actions\UpsertProfile, filter updates by $dto->provided keys instead of !is_null($value).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app-modules/profile/src/DTOs/UpsertProfileDTO.php` around lines 26 - 35, The
DTO factory method fromArray currently collapses missing keys and explicit nulls
into the same property values (see fromArray and fields like nickname, about,
headline, socialLinks, seniorityLevel), so updates cannot clear values; change
fromArray to record which keys were explicitly provided (e.g. populate a
$provided array/property on the DTO for keys present in the input) and preserve
explicit nulls (use isset/array_key_exists checks when building the DTO, calling
Date::parse or SeniorityLevel::from only when the key exists). Then update
He4rt\Profile\Actions\UpsertProfile to filter updates by the DTO’s provided keys
(the new $dto->provided) instead of !is_null($value) so explicit nulls can be
applied to clear fields.

@Hadzab Hadzab closed this May 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(profile): UpsertProfile and ToggleAvailability actions

1 participant