The Problem
OnlineMihna's public talent surfaces — the landing carousel and the employer hire page — listed jobseekers with no quality signal at all. Empty, unverified, half-filled profiles surfaced right beside complete ones, so employers' first impression was often the weakest talent. There was no single number for "how complete and trustworthy is this profile," and the listing query fetched 2,000 rows and tier-sorted them in memory on every request.
This feature adds a Profile Quality Score (0–100) and ranks everyone by it. The hard part isn't the arithmetic — it's guaranteeing that the score the database uses to order listings is exactly the score the app shows a jobseeker, with zero drift between the two.
The Approach
The score is twelve weighted signals — avatar (15), job title (10), a 100+ character bio (10), country (5), 3+ skills (10), experience (10), education (5), desired rate (5), phone (5), an uploaded CV (5), confirmed email (10), and graded ID verification (up to 10) — summing to 100.
The architectural problem: this rule must live in two places at once. The database needs it to order the public listing cheaply (a stored, indexed quality_score column); the app needs it live so a jobseeker editing their profile sees the ring move instantly. Implement it twice and they drift — a bio that counts as "filled" in TypeScript but "empty" in SQL means the ranking and the meter disagree, and no one can say which is right.
So the rule is written once in TypeScript (lib/profile-completeness.ts) as the single source of truth, then mirrored byte-for-byte in a PostgreSQL function. "Byte-for-byte" is literal: JS .trim() and \s strip the full ECMAScript whitespace set, but Postgres btrim/\s are ASCII-only — so the SQL helpers enumerate the exact Unicode whitespace set (U+00A0, U+2000–U+200A, U+FEFF, …) as explicit escapes and decode the same whitespace-entity list ( , ​, …) in the same order before stripping tags. The column is materialized on jobseekers and recomputed by AFTER triggers on every signal edit; a parity script then samples real rows and asserts TypeScript == the DB function == the stored column. The owner's ring computes live from the same TS rule via /api/profile/status — same rule, two read paths.
- 1
Define the rule once
12 weighted signals in one TypeScript SSoT (sum = 100)
- 2
Mirror it in PostgreSQL
compute function + whitespace-exact helpers, byte-for-byte
- 3
Materialize via triggers
stored quality_score, recomputed AFTER each signal edit
- 4
Order DB-side
featured (top 200) and search (uncapped) modes, indexed by score
- 5
Prove parity
verify-pqs-parity.ts asserts TS == DB function == stored column
- 6
Surface to the owner
live PQS ring + signals modal with deep-link CTAs to fix gaps
The Code
Four pieces carry the design: the TypeScript rule, its SQL mirror, the recompute triggers, and the owner-facing ring.
The rule, once, in TypeScript
The single source of truth: twelve weighted signals; the public score is just their clamped sum.
export function calculateJobseekerQualitySignals(
input: JobseekerQualityScoreInput,
): QualitySignal[] {
return [
{ key: "avatar", weight: 15, earned: isFieldFilled(input.avatar_url) ? 15 : 0 },
{ key: "job_title", weight: 10, earned: isFieldFilled(input.job_title) ? 10 : 0 },
{ key: "bio", weight: 10, earned: stripHtmlToText(input.description).length >= 100 ? 10 : 0 },
{ key: "country", weight: 5, earned: isFieldFilled(input.country) ? 5 : 0 },
{ key: "skills", weight: 10, earned: input.skillsCount >= 3 ? 10 : 0 },
{ key: "experience", weight: 10, earned: input.experienceCount >= 1 ? 10 : 0 },
{ key: "education", weight: 5, earned: input.educationCount >= 1 ? 5 : 0 },
{ key: "rate", weight: 5, earned: isFieldFilled(input.desired_compensation) ? 5 : 0 },
{ key: "phone", weight: 5, earned: isFieldFilled(input.phone) ? 5 : 0 },
{ key: "cv", weight: 5, earned: input.hasProfileCv ? 5 : 0 },
{ key: "email", weight: 10, earned: input.emailConfirmed ? 10 : 0 },
{ key: "verification", weight: 10, earned: Math.min(Math.floor((input.verificationPercentage ?? 0) / 10), 10) },
];
}
export function calculateJobseekerQualityScore(input: JobseekerQualityScoreInput): number {
const total = calculateJobseekerQualitySignals(input).reduce((sum, signal) => sum + signal.earned, 0);
return Math.max(0, Math.min(100, total));
}The same rule, mirrored in PostgreSQL
The parity-critical helpers. JS .trim() / \s strip the full Unicode whitespace set; Postgres btrim / \s are ASCII-only — so the JS set is enumerated as explicit escapes, and the same entity list is decoded in the same order before tags are stripped.
-- JS .trim() / \s strip the FULL ECMAScript whitespace set; Postgres btrim / \s are
-- ASCII-only. So we enumerate the JS set as explicit \uXXXX escapes to score identically.
create or replace function public.pqs_field_filled(v text)
returns boolean language sql immutable set search_path = pg_temp as $$
select coalesce(
btrim(v, E' \t\n\u000b\f\r\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff')
not in ('', '-'),
false
);
$$;
create or replace function public.pqs_strip_html(v text)
returns text language sql immutable set search_path = pg_temp as $$
select case when v is null then '' else btrim(
regexp_replace(
regexp_replace(
regexp_replace(v, '&(nbsp|#160|#xa0|#x00a0|#8203|#x200b|zwnj|#8204|#x200c);', ' ', 'gi'),
'<[^>]*>', ' ', 'g'),
E'[ \t\n\u000b\f\r\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+', ' ', 'g')
) end;
$$;Recompute via recursion-safe AFTER triggers
AFTER (not BEFORE) so the compute function can re-query the freshly-written row. quality_score is left out of the UPDATE OF list, so the recompute's own write never re-fires the trigger.
-- AFTER (not BEFORE): the compute fn re-queries the row (joins auth.users, counts skills),
-- so on BEFORE INSERT the row isn't in the table yet. quality_score is deliberately ABSENT
-- from the UPDATE OF list below, so the recompute's own UPDATE never re-fires the trigger.
create trigger trg_jobseekers_quality_score_upd
after update of
avatar_url, job_title, description, country, desired_compensation,
phone, profile_cv_id, experience_history, education_history, verification_percentage
on public.jobseekers
for each row execute function public.pqs_jobseekers_recompute();
create trigger trg_jobseeker_skills_quality_score
after insert or delete on public.jobseeker_skills
for each row execute function public.pqs_skills_recompute();The owner-facing ring
A presentational SVG ring. The progress arc's dash-offset is derived from the score, and the stroke color shifts by band (red < 40 ≤ amber < 70 ≤ emerald).
const RADIUS = 34;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
function bandStroke(score: number): string {
if (score >= 70) return "stroke-brand-emerald";
if (score >= 40) return "stroke-brand-amber";
return "stroke-brand-red";
}
export function ProfileQualityRing({ score, loading, onClick }: ProfileQualityRingProps) {
const value = score ?? 0;
const dashoffset = CIRCUMFERENCE * (1 - value / 100);
return (
<button type="button" onClick={onClick} aria-busy={loading} className="...">
<svg viewBox="0 0 80 80" className="h-20 w-20 -rotate-90">
<circle cx="40" cy="40" r={RADIUS} fill="none" strokeWidth="8" className="stroke-muted" />
{score !== null && (
<circle cx="40" cy="40" r={RADIUS} fill="none" strokeWidth="8" strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE} strokeDashoffset={dashoffset}
className={`${bandStroke(value)} transition-[stroke-dashoffset] duration-700 ease-out`} />
)}
</svg>
{/* centered score, band-colored */}
</button>
);
}Trade-offs
The interesting decisions were all about where the score lives and how to keep two implementations honest.
Chose
Materialized column + triggers over compute-on-read
Store quality_score and recompute via AFTER triggers, so the hot talent-listing path orders by an indexed column instead of recomputing 1,200+ scores per request. Cost: one extra indexed UPDATE per profile edit — negligible.
AFTER triggers, not BEFORE
The compute function re-queries the row (joins auth.users, counts jobseeker_skills); on BEFORE INSERT the row isn't in the table yet. AFTER lets one function serve the triggers, the backfill, and the parity script. Recursion is avoided by leaving quality_score out of the UPDATE OF column list.
Enumerate the ECMAScript whitespace set in SQL
Rather than accept that JS and Postgres trim differently, the SQL helpers pass the explicit Unicode whitespace set to btrim and the collapse regex, so a tab- or NBSP-padded field scores identically in both layers.
Live SSoT for the owner ring, stored column for public ordering
The ring reads a live computation from /api/profile/status (instant feedback on edits); public ordering reads the stored, indexed column (fast). Same rule, two read paths.
Skipped
Migrating desired_compensation from TEXT to numeric
The hourly-rate filter stays a bounded in-memory pass instead of a TEXT→numeric column migration — a deliberately bounded approximation, accepted because the long tail of low-score profiles wouldn't realistically surface in rate-filtered results anyway.
An auth.users trigger for email-confirmation changes
The email signal reads email_confirmed_at live inside the compute function; a dedicated auth.users trigger was deliberately avoided to stay off the auth landmine. Residual staleness measured at 0 rows.
Outcomes
Verified against real production data in an internal pre-merge review gate.
0 mismatches across 100 sampled rows
DB ↔ TS ↔ stored-column parity
spread across 25 bands (not all 0 or 100)
Score distribution
2000-row in-memory sort → indexed DB-side ordering
Talent listing query
0 (security + performance sweep)
New ERROR-level advisors
live ring, updates without a page reload
Owner score feedback