Data
Architectural

Profile Quality Score: deterministic talent ranking

A 0–100 quality score that reorders OnlineMihna's public talent surfaces so complete, verified profiles rank first. The same scoring rule runs in both TypeScript and PostgreSQL, kept byte-for-byte identical by a parity script so the app and the database can never disagree.

Shipped May 2026OnlineMihna
TypeScriptPostgreSQLNext.jsSupabaseTailwind

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. 1

    Define the rule once

    12 weighted signals in one TypeScript SSoT (sum = 100)

  2. 2

    Mirror it in PostgreSQL

    compute function + whitespace-exact helpers, byte-for-byte

  3. 3

    Materialize via triggers

    stored quality_score, recomputed AFTER each signal edit

  4. 4

    Order DB-side

    featured (top 200) and search (uncapped) modes, indexed by score

  5. 5

    Prove parity

    verify-pqs-parity.ts asserts TS == DB function == stored column

  6. 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