MeetyyAPI
Documentation / API Reference / Onboarding โ€” response

Onboarding โ€” response

Reply to onboarding-api-request.md (2026-09-14). Read ยง3 first โ€” the four collections were being dropped during validation and never written anywhere. That is fixed, but the rows already lost are not recoverable.

Marker Meaning
โœ… Shipped this round
โ— A deviation from your spec, or a client action needed
๐Ÿ’ฌ An answer to a question, not a build
ยง Your ask Status
ยง1 What validates interests? An endpoint for the list ๐Ÿ’ฌ Free text, 3โ€“10 ยท โœ… GET /interests shipped ยท store labels
ยง2 Expertise catalogue is mentor-gated ๐Ÿ’ฌ It is not โ€” no role gate ยท โœ… GET /public/expertise-areas added anyway
ยง3 Does POST /onboarding persist the 5 keys? โ— No โ€” four of them were silently dropped. โœ… Fixed
ยง4 Widen social_links to 11 platforms โœ… Shipped โ€” and the read side had the same 4-key limit, ยง4
ยง5 Are how_did_you_hear / expectations required? ๐Ÿ’ฌ Both optional. So is every other questionnaire key
ยง6 Is a mentee submission with only interests accepted? ๐Ÿ’ฌ Yes, covered by a test
ยง7 Can a brand new mentor upload documents immediately? ๐Ÿ’ฌ Yes โ€” the check reads the database
ยง8 Mirror the client-side rules โœ… Shipped, with two deliberate differences โ€” ยง8

Nothing in your document is blocked. Tests live in tests/Feature/Auth/ProfileAndOnboardingTest.php, tests/Feature/Auth/UserCredentialsTest.php, tests/Feature/Auth/InterestCatalogueTest.php and tests/Feature/Auth/ExpertiseAreaCatalogueTest.php.


ยง3. POST /onboarding was dropping four of the five collections โ—

Confirmed, and worse than a service that ignores them: they never reached the service at all. responses is validated as an array with validated children, and Laravel's excludeUnvalidatedArrayKeys (on by default) strips every child of such an array that has no rule of its own. Only interests had one.

POST body   responses{ interests, expertise_areas, experiences, education, certifications }
validated() responses{ interests }

So expertise_areas, experiences, education and certifications were discarded before OnboardingService ran. They were not written to the user's rows and not kept in onboarding_questionnaires.responses โ€” there is no copy anywhere to backfill from.

This is older than the profile migration: responses has had validated children since the endpoint was written, so every submission that ever carried those four keys lost them, not only the ones since the migration. The only recovery is asking those users to re-enter the data on the profile screen.

Fixed. POST /onboarding now validates all five keys and writes the four collections through UserCredentialService โ€” the same code path PUT /profile uses โ€” inside the transaction that grants the mentor role. The questionnaire keeps its copy, as it already did for interests.

Semantics match PUT /profile exactly:

  • A key that is present is the complete list. Rows with an id are updated, rows without one are created, anything omitted is deleted.
  • A key that is absent is untouched. A questionnaire-only submission does not wipe collections saved by an earlier per-step save. There is a test for this precisely because it is the dangerous case.
  • expertise_areas[].id is the catalogue id and skills[] are skill ids, both checked against the catalogue.
  • Every id is scoped to the caller's own rows โ€” naming another user's certification is a 422, not an overwrite.

Validation errors are keyed under the nested path:

{ "status": "error", "message": "Validation failed",
  "data": { "responses.expertise_areas.0.id": ["The selected โ€ฆ is invalid."] } }

Action for you: you can ship the per-step saves as a normal follow-up rather than a hotfix. Both paths work and write the same rows, so moving a step from responses to PUT /profile is safe to do one step at a time.


ยง1. Interests: free text, 3โ€“10, and now with a catalogue โœ…๐Ÿ’ฌ

PUT /profile does not check interests against anything. The rules are array|min:3|max:10, each entry string|max:100. Your 52 hardcoded strings cannot 422 on content, only on count. users.interests is a JSON column of plain strings.

Store the label. name is what the column holds and what GET /me returns; slugs are for your own keys, not for us.

GET /api/v1/interests โ€” public, no token, exactly the shape you sketched:

{
  "status": "success",
  "message": "Interests retrieved successfully.",
  "data": { "groups": [
    { "label": "Design", "interests": [
      { "id": "9b1fโ€ฆ", "slug": "ux-design", "name": "UX design" }
    ]}
  ]}
}

Groups come back in catalogue order: Design, Engineering, Product and business, Marketing and content, Creative, Career and life โ€” 52 entries, seeded by Database\Seeders\Interest\InterestSeeder. It is part of db:seed and can be run on its own โ€” php artisan db:seed --class="Database\Seeders\Interest\InterestSeeder" โ€” which is what a deploy needs; it is idempotent on the slug, so re-running it updates rather than duplicates. Until it has run in an environment, groups is [] rather than an error, so keep your list as the fallback for one release.

Validation deliberately stays free text. A closed in: rule would mean every user who onboarded before the catalogue existed gets a 422 the next time they save an unrelated profile field, and it would make adding an interest a deploy. The catalogue is the suggested vocabulary; the column accepts what you send.

โ— One thing to check: we do not have your interests.ts, so the 52 entries are our rendering of your six group names, not a copy of your list. Send the file (or diff it against GET /interests) and we will align the seed to it โ€” since storage is free text, a mismatch costs nothing until then, but the two should agree before you delete the hardcoded list.


ยง2. The expertise catalogue was never mentor-gated ๐Ÿ’ฌโœ…

GET /mentor/available-expertise-areas sits in a group with auth:sanctum only โ€” no role:mentor, no mentor-profile lookup. The controller returns a global catalogue. A mentee has always received a 200 here; if step 4 renders an empty picker, the cause is elsewhere (an empty catalogue in that environment, or the response shape โ€” the areas are at data.expertise_areas).

Added anyway, because a mentee calling a /mentor/ URL reads like a bug even when it is not:

GET /api/v1/public/expertise-areas      no token required, same payload

Both routes stay. Point step 4 at whichever you prefer.


ยง4. social_links: 11 platforms, and the read side too โœ…

Widened to exactly your list โ€” linkedin, x, instagram, facebook, github, youtube, dribbble, behance, medium, twitch, website โ€” full URLs, max:255, null to clear.

โ— The part you could not see from your side: validation was not the only 4-key limit. Every read surface resolves socials through one helper, which returned exactly those four keys regardless of what was stored. Widening the rules alone would have saved a GitHub link and still never shown it on prefill. Both halves are now driven by the same list, so a platform that can be written can always be read back.

GET /me, GET /user/{username} and the chat participant card now return all 11 keys, nulls included, in the order above. twitter remains accepted on input and always comes back as x.

One shape difference worth knowing, unchanged by this round: the data.user echoed by PUT /profile is the raw stored map โ€” only the keys that have a value โ€” while /me and the profile endpoints emit the normalised 11. Prefill from /me, not from the save response, if you want the full set of nulls.


ยง5 and ยง6. Nothing in the questionnaire is required ๐Ÿ’ฌ

how_did_you_hear and expectations are required: false โ€” both optional, confirmed. Hiding them cannot 422 the submit.

Going further than you asked: no questionnaire key is required server-side. The six you render are all sometimes too, so a mentor submit missing any of them is accepted. The "required": true flags in GET /onboarding/questions/{role} describe the intended UI, not an enforced rule; treat them as advisory. Tell us if you would rather they were enforced โ€” that is a one-line change per field, but it would turn a hidden step into a hard failure, so we have left it to you.

ยง6 โ€” a mentee submission of { "role": "mentee", "responses": { "interests": [โ€ฆ] } } is accepted, and there is a test asserting exactly that payload.


ยง7. A brand new mentor can upload immediately ๐Ÿ’ฌ

The role check reads the database, not a claim on the token. Sanctum tokens carry no roles, and POST /mentor/verification-documents does not even check the role โ€” it needs the mentor profile, which POST /onboarding creates in the same transaction that grants the role. The token issued before the grant keeps working.

There is a test for the exact sequence: onboard as mentor, then upload with the pre-existing token.

Unchanged: is_onboarded flips to true at POST /onboarding (it is computed from the completed questionnaire), and role: mentor grants the mentor role additively โ€” the mentee role is kept.


ยง8. Server-side rules, now mirrored โœ…โ—

Field Your rule Server Note
date_of_birth past, age 13โ€“120 โœ… same 422 outside the window
phone_number E.164 with + โœ… now accepted at all see below
bio โ‰ค 500 โ— โ‰ค 2000 deliberate
full_name 2โ€“60 โ— 2โ€“255 deliberate
interests โ‰ฅ 3 โœ… 3โ€“10 already enforced

โ— phone_number was not accepted by PUT /profile at all. It had no rule, so it was dropped exactly like the extra social links โ€” step 1 has been sending it into nothing. It now writes to the user.

  • Format: + then a non-zero digit then 6โ€“14 more. The + is optional on input, for compatibility with the clients already posting bare numbers to /phone/update; the value is stored exactly as sent, so send it with the + and it round-trips unchanged.
  • Writing it here does not send a verification SMS. POST /phone/update remains the verification flow.
  • Changing the number clears phone_verified_at. Re-sending the same number leaves it alone, so a step-1 save that does not touch the phone will not un-verify anyone.

bio stays at 2000 and full_name at 255 โ€” a server that is looser than your form is not a bug, and tightening them would reject profiles that already exist the next time their owner saves any unrelated field. Keep enforcing 500 and 60 in the wizard; we will not accept less than 2 characters of a name.


Appendix: what changed, by endpoint

GET  /interests                         NEW โ€” public, grouped catalogue
GET  /public/expertise-areas            NEW โ€” public alias of the mentor route

PUT  /profile                           + phone_number (was silently dropped)
                                        + social_links: 11 keys (was 4)
                                        + date_of_birth: age 13โ€“120
                                        + full_name: min 2
POST /onboarding                        + responses.{certifications, education,
                                          experiences, expertise_areas} now
                                          validated AND persisted (were dropped)

GET  /me, /user/{username}, chat cards  social_links now carries 11 keys

Unchanged: the envelope, snake_case, GET /mentor/available-expertise-areas, POST /mentor/verification-documents, and the is_onboarded / mentor-role behaviour you mirror locally.