SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
4.2 KB · 79 lines typescript
Raw Blame History
1import { eq, sql } from 'drizzle-orm';2import { certificateSightings, certificates } from '@rareindex/database';3import { getGrader } from '@rareindex/taxonomy';4import { deterministicId, newId } from '@rareindex/shared';5import { db } from '../lib/db.ts';67/**8 * Certification-number tracking (SPEC §22). Every sale/listing/lot carrying a slab cert number is9 * recorded as a sighting of one (grader, cert) row. Over time the same cert appearing on several10 * marketplaces builds a provenance graph; cert_lookup connectors fill `verification`.11 */12export interface CertSighting {13  grader: string;14  certNumber: string;15  assetId: string | null;16  variantId: string | null;17  grade: string | null;18  qualifier?: string | null;19  kind: 'sale' | 'listing' | 'auction_lot' | 'lookup';20  targetId: string | null;21  sourceId: string;22  connectorId: string | null;23  sourceUrl: string | null;24  priceUsd: number | null;25  price?: number | null;26  currency?: string | null;27  observedAt: Date;28}2930/** Normalise a raw cert string: digits and letters only, uppercase, no separators. Returns null when implausible. */31export function normalizeCertNumber(grader: string | null | undefined, raw: string | null | undefined): string | null {32  if (!raw) return null;33  const s = String(raw).trim().toUpperCase().replace(/^(CERT|CERT\.|#|NO\.?|№)\s*/i, '').replace(/[\s.\-_/]/g, '');34  if (!/^[A-Z0-9]{5,20}$/.test(s)) return null;35  // grader-specific sanity: PSA/SGC/CGC/BGS certs are numeric; PCGS/NGC are numeric (NGC may carry -NNN suffix already stripped)36  if (grader && /^(psa|sgc|cgc|bgs|pcgs|ngc|pmg|cbcs|tag|ace|wata|vga)$/.test(grader) && !/^\d{5,12}$/.test(s)) return null;37  return s;38}3940export function verifyUrlFor(grader: string, cert: string): string | null {41  const g = getGrader(grader);42  if (!g?.verifyUrl) return null;43  return g.verifyUrl.includes('{cert}') ? g.verifyUrl.replace('{cert}', encodeURIComponent(cert)) : g.verifyUrl;44}4546export async function recordCertificate(s: CertSighting): Promise<string | null> {47  const grader = s.grader.toLowerCase();48  const cert = normalizeCertNumber(grader, s.certNumber);49  if (!cert) return null;50  const id = deterministicId('cert', `${grader}|${cert}`);51  await db()52    .insert(certificates)53    .values({ id, grader, certNumber: cert, assetId: s.assetId, variantId: s.variantId, grade: s.grade, qualifier: s.qualifier ?? null, verifyUrl: verifyUrlFor(grader, cert), firstSeenAt: s.observedAt, lastSeenAt: s.observedAt, sightings: 1, sourceIds: [s.sourceId], lastSourceUrl: s.sourceUrl, lastPriceUsd: s.priceUsd })54    .onConflictDoUpdate({55      target: [certificates.grader, certificates.certNumber],56      set: {57        assetId: sql`coalesce(${certificates.assetId}, ${s.assetId})`,58        variantId: sql`coalesce(${certificates.variantId}, ${s.variantId})`,59        grade: sql`coalesce(${certificates.grade}, ${s.grade})`,60        firstSeenAt: sql`least(${certificates.firstSeenAt}, ${s.observedAt})`,61        lastSeenAt: sql`greatest(${certificates.lastSeenAt}, ${s.observedAt})`,62        sightings: sql`${certificates.sightings} + 1`,63        sourceIds: sql`(select array_agg(distinct x) from unnest(${certificates.sourceIds} || ${sql.raw(`'{${s.sourceId.replace(/[^a-z0-9_-]/gi, '')}}'::text[]`)}) x)`,64        lastSourceUrl: sql`case when ${s.observedAt} >= ${certificates.lastSeenAt} then ${s.sourceUrl} else ${certificates.lastSourceUrl} end`,65        lastPriceUsd: sql`case when ${s.priceUsd} is not null and ${s.observedAt} >= ${certificates.lastSeenAt} then ${s.priceUsd} else ${certificates.lastPriceUsd} end`,66      },67    });68  await db()69    .insert(certificateSightings)70    .values({ id: newId('event'), certificateId: id, kind: s.kind, targetId: s.targetId, sourceId: s.sourceId, connectorId: s.connectorId, sourceUrl: s.sourceUrl, priceUsd: s.priceUsd, currency: s.currency ?? null, price: s.price ?? null, observedAt: s.observedAt })71    .onConflictDoNothing();72  return id;73}7475/** Cert history for an asset or a cert id (provenance timeline). */76export async function certificateTimeline(certificateId: string) {77  return db().select().from(certificateSightings).where(eq(certificateSightings.certificateId, certificateId)).orderBy(certificateSightings.observedAt);78}79