CODEX // oaoisme wiki

§ unswayed-backend · Profiles (Phase 3)

Employer profile & viewer

updated 2026-06-05

The employer side of Phase 3, plus the shared profile viewer that both user types reach. It lives in src/employer-profile/.

Models

  • Employer gained the hidden Google-Calendar token columns (googleAccessToken/googleRefreshToken/googleTokenExpiresAt) — a Calendar-integration domain that is never serialized in any profile response.
  • EmployerReview (employer_id/applicant_id FKs, questions/ratings JSON, overallRatings, isAnonymous) lands here even though the review write surface (§11 add-review) is a later phase — because the §40/§14/§5 resources all expose review aggregates.

Self profile (§40)

GET /employer/profile and PATCH /employer/update-profile/{id} return the same EmployerResource. Update is one transaction (User + Employer upsert, is_completed = true) with the same 403 ownership + 422 username-uniqueness rules as the applicant side.

The frozen quirk: company_name is split on spaces into first_name/middle_name/last_name (word[0..2]; extra words dropped). Rather than persist a lossy split, the reimplementation stores the raw companyName and derives first/middle/last at the resource edge (splitCompanyName) — same bytes on the wire, no data loss. change-profile-pic returns the raw stored URL.

Review aggregates (the divide-by-zero fix)

The legacy Employer::ratings() did sum(overall_ratings) / reviews->count() with no guard — it threw DivisionByZeroError for an employer with no reviews. Here, computeReviewAggregates runs over the (deactivation-scoped) review rows in one query and returns averageRating = 0 and ratingCounts = {} on empty — never NaN/∞. The deactivated scope excludes reviews whose employer-user or applicant-user is deactivated (status = 2).

The public short profile (§5) and the shared viewer (§14)

  • GET /employerProfile/{id} is a guest route (the documented-but-unbuilt §5 gap, now built) returning a PopularCompanyResource (a teaser: names, photo, about, review aggregates — no email/phone/address). It 404s "user not found!" if the target is missing, incomplete, inactive, or an applicant.
  • GET /profile/{id} is authenticated and branches by the target's type: an employer gets an EmployerProfileResource, an applicant an ApplicantProfileResource. Both add isFollowed (does the viewer follow this profile) and resolve the follow graph (top-5 followings + follower count) via FollowsService. The employer viewer omits email/provider/phone/address/followers vs the self-resource; the applicant viewer renders country/state/city/gender/ethnicity as .name strings (legacy parity), where the self-resources use {id, name} objects.

The follow graph

UserFollower is a soft-deletable join table (following_user_id + follower_user_id, deleted_at). FollowsService reads it for every profile: the top-5 followers/followings (filtered to completed + active accounts), the unfiltered total counts, and per-entry isFollowed against the viewer. The follow toggle (POST /user-follow) is a later phase — Phase 3 only exposes the reads these resources need, with follow rows seeded directly in tests.