§ unswayed-backend · Dashboards & auto-apply (Phase 17)
Frontend-brief dashboards & public feedback (Phase 21)
Phase 21's frontend brief asked for a handful of leaner, frontend-shaped read endpoints to back the new web surfaces. The catch: the original endpoints were already frozen wire contracts the mobile app depends on. So rather than reshape them (and break the app), Phase 21 adds new sibling routes alongside the existing ones. Nothing old changed; the new routes are purely additive.
This page covers three of those additions plus a small counter that one of them needed.
Why "alongside", not "instead of"
The existing dashboards (GET /api/v1/employer/dashboard,
GET /api/v1/applicant/dashboard) return rich camelCase payloads — match
scores, growth blocks, best-match disclosure, profile-completion math. The web
brief wanted something flatter and snake_case: a few headline counts and a short
recent list. Two different consumers, two different shapes. The rule on this repo
is never break a frozen contract, so each role gets a second route at
…/dashboard/overview that lives on the same controller prefix (NestJS routes
them apart by path) and is registered as its own controller + service. The
original files were not touched.
1. Public feedback listing — GET /api/feedbacks
The marketing/landing surface needs to render testimonials without a token.
The existing GET /api/feedback (singular) is auth-gated and returns the frozen
Laravel { data, links, meta } paginator with per-row { id, subject, message, rating, created_at, user:{ id, name, photo } }. We could not reuse it (auth, and
it leaks the author's id).
So there's a new plural route GET /api/feedbacks — no auth, no envelope —
returning the brief's exact shape:
{
"data": [
{ "id": 11, "rating": 5, "comment": "Loved it",
"created_at": "2026-06-01 08:09:10",
"user": { "name": "Jane Doe", "avatar": "https://…" } }
],
"total": 1, "current_page": 1, "last_page": 1
}
Key mappings vs the frozen resource: message → comment, photo → avatar,
and the author block is trimmed to { name, avatar } (no id/email leak).
Anonymous rows (or rows whose author was deleted) mask to
{ name: "Anonymous User", avatar: null } — identical trigger to the §27
resource. It pages at 20/row, newest first, reusing the same query and the
shared buildPagination helper; the controller is @BareResponse() so the body
is returned verbatim (the global success-envelope is skipped). The created_at
string and the name resolution reuse the §27 helpers byte-for-byte, so the two
feedback surfaces never drift.
Lives in src/feedback/feedback-public.controller.ts +
feedback-public.resource.ts, with FeedbackService.listPublic().
2. Employer dashboard overview — GET /api/v1/employer/dashboard/overview
JWT + employer-only, bare snake_case:
{
"total_jobs": 12, "active_jobs": 8, "draft_jobs": 2,
"total_applications": 140, "shortlisted": 17, "interviews_scheduled": 5,
"recent_applications": [
{ "application_id": 991, "applicant_name": "Jane Doe",
"job_title": "Backend Engineer", "status": "pending",
"applied_at": "2026-06-28T…" }
]
}
Job-status integers follow the existing JOB_STATUS map: active_jobs =
status === 1 (published), draft_jobs = status === 0. shortlisted counts
applications at-or-past the shortlist gate (the same status set the rich employer
dashboard uses for controlled disclosure). interviews_scheduled counts the
employer's JobInterview rows. recent_applications is the five newest
applications across the employer's jobs, each carrying the applicant's trimmed
name and the job title. Everything is read in a small, batched wave scoped to the
employer's jobIds — no per-job N+1 — and the empty-state (no jobs) short-circuits
to all-zeros with no further reads. The controller resolves the employer from the
auth user via EmployerContextService.require.
3. Applicant dashboard overview — GET /api/v1/applicant/dashboard/overview
JWT + applicant-only, bare snake_case:
{
"total_applications": 9, "shortlisted": 2, "interviews_scheduled": 1,
"profile_views": 34,
"recent_jobs": [
{ "application_id": 77, "job_title": "Frontend Engineer",
"company": "Acme", "status": "interview_invite",
"applied_at": "2026-06-28T…" }
]
}
The actor is resolved from the auth user (no path id, so it can only ever return
the caller's own data). profile_views reads the applicant's new
Applicant.profileViews column (see below). shortlisted mirrors the
shortlist-or-beyond status set; interviews_scheduled counts applications in
interview_invite / interview_accept. recent_jobs is the five newest
applications with their job title + company.
4. The profile_views counter source
profile_views only means something if something increments it. The natural
event is an employer opening a candidate's profile via the §14 viewer
(GET /api/profile/{id}). So PublicProfileService.getProfile, on the applicant
branch, now does a best-effort Applicant.profileViews += 1 — but only when the
viewer is a different user than the subject (a self-view never counts). The
increment is wrapped so a counter write failure is swallowed: the profile read is
the contract, the view tally is a side effect and must never break it. The schema
carries Applicant.profileViews Int @default(0).
Testing
All four pieces were built red→green with co-located *.spec.ts and land at
100% line/branch/function coverage on the changed files: the overview services
(counts, empty-state, 404, mapping with null company/title), the public feedback
resource + service (mapping, masking, paging, page-fallback), and the
profile-view increment (increments for a stranger, skips on self-view, skips on
the employer branch, survives a throwing update).