CODEX // oaoisme wiki

§ unswayed-backend · Dashboards & auto-apply (Phase 17)

Frontend-brief dashboards & public feedback (Phase 21)

updated 2026-06-29

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: messagecomment, photoavatar, 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).