Skip to content

Pro Credentials & Trust Ranking — Design Spec

Pro Credentials & Trust Ranking — Design Spec

Section titled “Pro Credentials & Trust Ranking — Design Spec”

Date: 2026-04-19 Status: Draft — brainstormed, not yet reviewed Scope: Credential upload, verification pipeline, trust score, ranking impact, badge tiers Phase mapping: Schema + Screen 6 read-surface = Phase A. Upload UX + admin queue = Phase D. OCR + registry integrations = post-MVP.

Let professionals prove their skills by uploading official documents (P.IVA, F-GAS, insurance, manufacturer certs, albo numbers, training diplomas). Verified credentials feed a trustScore that drives ranking + surfaces visible tier badges in results + pro profile.

Why: ProntoPro pain point = “no way to know who’s legit” → key differentiator. Italian consumers expect P.IVA + albo proof for trades (legal + cultural). Ranking tied to verified credentials aligns incentives: pros w/ real skills rise; mill pros sink.

  • Automatic renewal reminders (Phase D)
  • Credential marketplace / training referrals (not in scope)
  • Background checks (separate product decision)
  • Public credential downloads by consumers (privacy — only badge tier shown)
enum CredentialType {
P_IVA // VAT number / business registration
ALBO // Professional register enrollment (Albo CCIAA)
F_GAS // Fluorinated gas handling certification
INSURANCE // Liability insurance
MANUFACTURER_CERT // Bosch, Vaillant, Riello, etc.
TRAINING_DIPLOMA // Vocational school diploma
ID_DOCUMENT // Carta d'identità / passport
OTHER
}
enum CredentialStatus {
PENDING // Uploaded, awaiting review
VERIFIED // Admin approved
REJECTED // Admin rejected (reason attached)
EXPIRED // Past expiresAt (cron job flips nightly)
}
model Credential {
id String @id @default(cuid())
professionalId String
professional Professional @relation(fields: [professionalId], references: [id], onDelete: Cascade)
type CredentialType
issuer String // "Camera di Commercio Milano", "Bosch", etc.
issuerReference String? // Albo number, cert serial
issuedAt DateTime
expiresAt DateTime? // Null = no expiry
documentUrl String // R2 private bucket signed URL (admin-only read)
documentMimeType String // application/pdf, image/jpeg, image/png
documentSizeBytes Int
status CredentialStatus @default(PENDING)
reviewedBy String? // Admin user id
reviewedAt DateTime?
rejectionReason String?
ocrMetadata Json? // Phase 2 AI extraction (issuer, numbers, dates)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([professionalId, status])
@@index([type, status])
@@index([expiresAt]) // For nightly expiry cron
}
// Added to Professional model:
model Professional {
// ... existing fields
trustScore Int @default(0) // Denormalized, recomputed on credential status change
trustTier TrustTier @default(BASIC)
credentials Credential[]
}
enum TrustTier {
BASIC // Default — no verified credentials
VERIFIED // P.IVA + ≥1 skill cert verified
ELITE // trustScore ≥ 150
}

Storage: Documents in Cloudflare R2 private bucket ideony-credentials — never public URLs. Signed URLs only issued to:

  • The uploading pro (read-own, 5min TTL)
  • Admin reviewers (read-any, 15min TTL)
type ReviewStats = {
count: number; // Total reviews (rating > 0)
avgRating: number; // Mean rating, 1-5 decimal
lastReviewAt: Date | null; // Most recent review timestamp
};
type BookingStats = {
completedCount: number; // Successfully completed bookings
firstBookingAt: Date | null;
};
function computeTrustScore(
credentials: Credential[],
reviewStats: ReviewStats,
bookingStats: BookingStats,
): number {
const verified = credentials.filter(c => c.status === 'VERIFIED');
let score = 0;
// Base requirements
if (verified.some(c => c.type === 'P_IVA')) score += 15;
if (verified.some(c => c.type === 'INSURANCE')) score += 20;
if (verified.some(c => c.type === 'ID_DOCUMENT')) score += 10;
// Skill certs (capped at 5 → +50)
const skillCerts = verified.filter(c =>
c.type === 'F_GAS' || c.type === 'ALBO' || c.type === 'TRAINING_DIPLOMA'
);
score += Math.min(skillCerts.length, 5) * 10;
// Manufacturer certs (capped at 3 → +15)
const mfgCerts = verified.filter(c => c.type === 'MANUFACTURER_CERT');
score += Math.min(mfgCerts.length, 3) * 5;
// Review signal
if (reviewStats.count >= 20 && reviewStats.avgRating >= 4.5) score += 25;
if (reviewStats.count >= 50 && reviewStats.avgRating >= 4.7) score += 15; // additive
// Penalties
const expired = credentials.filter(c => c.status === 'EXPIRED').length;
score -= expired * 30;
const rejected = credentials.filter(c => c.status === 'REJECTED').length;
if (rejected > 0) score -= 100; // Strong signal — attempted fraud or sloppy uploads
// Cold-start bonus — new pros get a leg up until they accumulate reviews
if (bookingStats.completedCount < 10 && reviewStats.count < 5) {
score += 20;
}
return Math.max(0, score);
}
function computeTrustTier(score: number, credentials: Credential[]): TrustTier {
const verified = credentials.filter(c => c.status === 'VERIFIED');
const hasPIva = verified.some(c => c.type === 'P_IVA');
const hasSkillCert = verified.some(c =>
c.type === 'F_GAS' || c.type === 'ALBO' || c.type === 'TRAINING_DIPLOMA'
);
if (score >= 150) return 'ELITE';
if (hasPIva && hasSkillCert) return 'VERIFIED';
return 'BASIC';
}

Recomputation triggers:

  • Credential status changes (PENDING → VERIFIED | REJECTED | EXPIRED) — immediate
  • New review added (via BullMQ job, debounced 5min per pro)
  • Booking completed (triggers review prompt + debounced recompute)
  • Nightly cron: expire credentials where expiresAt < now() + status is VERIFIED
  • Manual admin override (score adjustment w/ audit log)

Concurrency: recompute worker acquires pg_advisory_xact_lock(hashtext('trust:' || professionalId)) inside a transaction before reading credentials + reviewStats + bookingStats and writing professional.trustScore. Serializes all recomputes for the same pro; independent pros run in parallel. Last-write-wins on race.

Two distinct ranking paths (see also Phase A spec → “Ranking — Two Paths”):

Search/Results ranking (apps/api/src/modules/professionals/ranking.ts):

searchScore =
(rating × 10) // 0-50
+ trustScore // 0-200 typical range
+ proximityBonus // (10 - distanceKm) × 2, capped [0, 20]
- responseLatencyPenalty // avgResponseMin / 10, capped at 20

Ties: lastActiveAt DESC, then stable hash of professionalId + userId.

SOS dispatch ranking (apps/api/src/modules/sos/candidate-ranker.ts):

sosScore =
proximityScore × 50 // (radiusKm - distanceKm) / radiusKm × 50
+ onlineBonus × 30 // 30 if pro is online + sos-enabled
+ trustScore × 0.5 // Trust matters but less than speed
- avgResponseMinutes // Linear penalty for slow historic responses

Ties: random stable hash (fairness across equally-ranked candidates).

Filters:

  • Default: no filter (show all)
  • Chip ”✓ Verificato” → trustTier >= VERIFIED
  • Chip ”⭐ Elite” → trustTier === ELITE
  • BASIC: no badge
  • VERIFIED: olive ✓ VERIFICATO pill (existing)
  • ELITE: terracotta-gold gradient ⭐ ELITE pill (reserved — one solid color per impeccable ban, so: solid terracotta bg + gold icon, NOT gradient text)
  • Badge shown inline w/ name (same pill as results)
  • New “Certificazioni verificate” section — grid of chips, each tappable
  • Chip tap → modal:
    • Credential type + issuer + issuedAt + expiresAt
    • Watermarked preview of document (pro’s name + “VERIFICATO DA IDEONY” overlay)
    • Full document NOT downloadable by consumer (privacy)

Onboarding — step 3 “Prove your skills”

Section titled “Onboarding — step 3 “Prove your skills””

Flow: identity (Clerk) → services/categories → credentials → payment setup (Stripe Connect) → go live.

Upload screen:

  • Header: “Più certificazioni = più clienti” (trust + self-interest framing)
  • Required: P.IVA (hard-gate — cannot publish profile without)
  • Recommended chips (greyed until uploaded): Assicurazione, Albo, F-GAS, ID
  • Upload button per type: native photo/document picker via expo-document-picker
  • Immediate upload to R2 via pre-signed URL (no server round-trip for bytes)
  • Pending state: “In revisione · 24-48h” badge
  • Post-upload form: issuer, issuedAt, expiresAt (date pickers)

Motion:

  • Upload progress bar fills as bytes stream
  • On 100%: checkmark pulse animation (terracotta) + haptic
  • Scorecard at bottom shows live trustScore preview: “Il tuo punteggio salirà a X dopo la verifica”
  • Credentials list w/ status dots (grey pending, green verified, red expired/rejected)
  • Add new / remove / renew expiring (60d before expiry → push notification “Certificazione F-GAS scade tra 60 giorni”)

Screen: /admin/credentials (web-only, Clerk org role admin)

Layout:

  • Left pane: queue list sorted by createdAt ASC (FIFO) + filter chips (by type, by age)
  • Right pane: doc viewer (PDF.js / image) + metadata form + approve/reject buttons
  • Keyboard shortcuts: J/K next/prev, A approve, R reject (opens reason modal)

Reject reasons (predefined):

  • Immagine illeggibile
  • Documento scaduto
  • Nome non corrisponde al profilo
  • Documento non valido / sospetto falso
  • Tipo di certificazione errato
  • Altro (free text)

AI pre-screening (Phase 2 — post-MVP):

  • On upload, Gemini Vision extracts: issuer, issue date, expiry date, document type, holder name
  • OCR result attached to ocrMetadata
  • Queue entries flagged: green (AI confident match) / yellow (partial match) / red (mismatch — likely fraud)
  • Admin still does final approve/reject — AI speeds, doesn’t replace
POST /professionals/me/credentials # Create pending credential + get R2 upload URL
PUT /professionals/me/credentials/:id # Update metadata (pre-submit)
GET /professionals/me/credentials # List own
DELETE /professionals/me/credentials/:id # Only if PENDING or REJECTED
GET /professionals/:id/credentials # Public — only returns VERIFIED, minimal fields
# { type, issuer, issuedAt, expiresAt, tier }
# Admin
GET /admin/credentials/queue # Paginated PENDING list
GET /admin/credentials/:id # Full details + signed doc URL
POST /admin/credentials/:id/approve
POST /admin/credentials/:id/reject # { reason, notes? }
# Internal
POST /internal/credentials/:id/ocr # Trigger OCR (Phase 2)
POST /internal/trustscore/recompute/:proId # BullMQ-consumed
  • Signed URL TTLs: 5min (pro read own), 15min (admin read), 10min (upload PUT)
  • Rate limit: 10 uploads / pro / day (abuse guard)
  • Doc mime-type validation BE-side via magic-number sniff (not just Content-Type header)
  • Max file size: 10MB (Fastify body limit)
  • Scan uploaded docs for malware via Cloudflare R2 event → ClamAV Worker (post-MVP)
  • GDPR: pro can delete own credentials → docs purged from R2 within 30 days (retention for fraud audit)
  1. Phase A (now): schema + GET /professionals/:id/credentials read endpoint + Screen 6 renders verified chips (hardcoded empty array for MVP until upload UX ships)
  2. Phase D: pro upload UX + admin review queue + BullMQ trustScore recompute worker + badge tiers in results
  3. Post-MVP: Gemini Vision OCR pre-screening + expiry reminders + CCIAA API for P.IVA auto-verification
  • Liability: verifying credentials creates implicit endorsement. Terms must clarify “Ideony verifies document authenticity, not pro’s skill or work quality”
  • Admin bottleneck: pre-MVP volume is tiny, scales w/ pro count. Bake AI pre-screening early if queue > 100/day
  • Fraud: photoshopped docs. Mitigations: watermarked originals required (pros photograph w/ app’s in-app camera using face overlay “your face + cert in same frame”), sample audits, public suspicion reports
  • Cold-start: new pros w/ no credentials = BASIC tier = bottom of results. Add “New pro” fast-track: first 10 bookings, bonus +20 to trustScore to let them compete until reviews arrive
  • Credential inflation: pros upload meaningless certs to pad score. Cap skill certs at 5, manufacturer certs at 3 (formula above)
  • Watermark format (text overlay vs stamp image)
  • Pro face-w/-cert photo required or just doc upload? (lean toward required for Italian market trust norms)
  • Admin team model: single admin bottleneck vs trusted contributor tier
  • Badge tier naming in IT: Base / Verificato / Elite → or Base / Qualificato / Certificato?
  • New-pro cold-start bonus: time-bound (30 days) vs booking-count-bound (first 10)

Minor additions to Phase A spec (2026-04-19-ux-phase-a-design.md):

  • Screen 5 results: ranking formula uses trustScore + trustTier fields (need migration before results live w/ real data)
  • Screen 6 pro profile: “Certificazioni” section reads GET /professionals/:id/credentials (returns empty [] until Phase D upload UX ships)
  • 2026-04-19 — Spec drafted as spin-off from Phase A Screen 6 brainstorm.
  • 2026-04-19 — Architect review fixes: added ReviewStats + BookingStats schemas, embedded cold-start bonus (<10 completed bookings + <5 reviews → +20), documented pg_advisory_xact_lock concurrency for recomputes, split single searchScore into two explicit ranking paths (search-weighted vs SOS-weighted).