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.
Objective
Section titled “Objective”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.
Non-goals
Section titled “Non-goals”- 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)
Data Model
Section titled “Data Model”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)
Trust Score Formula
Section titled “Trust Score Formula”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.
Ranking Impact
Section titled “Ranking Impact”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 20Ties: 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 responsesTies: random stable hash (fairness across equally-ranked candidates).
Filters:
- Default: no filter (show all)
- Chip ”✓ Verificato” →
trustTier >= VERIFIED - Chip ”⭐ Elite” →
trustTier === ELITE
Badge Surfaces
Section titled “Badge Surfaces”Results list (Screen 5)
Section titled “Results list (Screen 5)”BASIC: no badgeVERIFIED: olive✓ VERIFICATOpill (existing)ELITE: terracotta-gold gradient⭐ ELITEpill (reserved — one solid color per impeccable ban, so: solid terracotta bg + gold icon, NOT gradient text)
Pro profile (Screen 6)
Section titled “Pro profile (Screen 6)”- 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)
Pro Upload UX (Phase D)
Section titled “Pro Upload UX (Phase D)”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”
Existing-pro management (settings)
Section titled “Existing-pro management (settings)”- 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”)
Admin Verification Queue (Phase D)
Section titled “Admin Verification Queue (Phase D)”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/Knext/prev,Aapprove,Rreject (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 URLPUT /professionals/me/credentials/:id # Update metadata (pre-submit)GET /professionals/me/credentials # List ownDELETE /professionals/me/credentials/:id # Only if PENDING or REJECTED
GET /professionals/:id/credentials # Public — only returns VERIFIED, minimal fields # { type, issuer, issuedAt, expiresAt, tier }
# AdminGET /admin/credentials/queue # Paginated PENDING listGET /admin/credentials/:id # Full details + signed doc URLPOST /admin/credentials/:id/approvePOST /admin/credentials/:id/reject # { reason, notes? }
# InternalPOST /internal/credentials/:id/ocr # Trigger OCR (Phase 2)POST /internal/trustscore/recompute/:proId # BullMQ-consumedSecurity
Section titled “Security”- 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)
Rollout
Section titled “Rollout”- Phase A (now): schema +
GET /professionals/:id/credentialsread endpoint + Screen 6 renders verified chips (hardcoded empty array for MVP until upload UX ships) - Phase D: pro upload UX + admin review queue + BullMQ trustScore recompute worker + badge tiers in results
- 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)
Open Decisions
Section titled “Open Decisions”- 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)
Dependencies on Existing Spec
Section titled “Dependencies on Existing Spec”Minor additions to Phase A spec (2026-04-19-ux-phase-a-design.md):
- Screen 5 results: ranking formula uses
trustScore + trustTierfields (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)
Change Log
Section titled “Change Log”- 2026-04-19 — Spec drafted as spin-off from Phase A Screen 6 brainstorm.
- 2026-04-19 — Architect review fixes: added
ReviewStats+BookingStatsschemas, embedded cold-start bonus (<10 completed bookings + <5 reviews → +20), documentedpg_advisory_xact_lockconcurrency for recomputes, split singlesearchScoreinto two explicit ranking paths (search-weighted vs SOS-weighted).