§ unswayed-backend · Profiles (Phase 3)
Employer profile & viewer
The employer side of Phase 3, plus the shared profile viewer that both user types reach. It lives in src/employer-profile/.
Models
Employergained 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_idFKs,questions/ratingsJSON,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 aPopularCompanyResource(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 anEmployerProfileResource, an applicant anApplicantProfileResource. Both addisFollowed(does the viewer follow this profile) and resolve the follow graph (top-5 followings + follower count) viaFollowsService. The employer viewer omits email/provider/phone/address/followers vs the self-resource; the applicant viewer renderscountry/state/city/gender/ethnicityas.namestrings (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.