← back to Omega Watches 2

src/app/api/entity-resolution/route.ts

120 lines

import { NextRequest, NextResponse } from "next/server";
import { query } from "@/lib/db";
import { getSession } from "@/lib/auth";

export const dynamic = "force-dynamic";

export async function GET(request: NextRequest) {
  const session = await getSession(request);
  if (!session) {
    return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
  }

  // Find market events whose reference_number_raw doesn't match any watch_reference
  const unmatched = await query(`
    SELECT me.event_id, me.source_name, me.source_listing_id, me.event_type,
           me.reference_number_raw, me.sale_date, me.price_amount, me.currency,
           me.condition_raw, me.condition_normalized, me.source_url,
           me.created_at
    FROM market_event me
    LEFT JOIN watch_reference wr ON me.reference_number_raw = wr.reference_number
    WHERE wr.id IS NULL AND me.reference_number_raw IS NOT NULL
    ORDER BY me.created_at DESC
    LIMIT 100
  `);

  // Get all known references for fuzzy matching suggestions
  const references = await query(`
    SELECT id, reference_number, collection, model_name
    FROM watch_reference
    ORDER BY collection, reference_number
  `);

  // Build match suggestions using simple similarity
  const suggestions: Record<string, any[]> = {};
  for (const event of unmatched) {
    const raw = event.reference_number_raw || "";
    const matches = references
      .map((ref: any) => ({
        ...ref,
        score: similarity(raw, ref.reference_number),
      }))
      .filter((m: any) => m.score > 0.3)
      .sort((a: any, b: any) => b.score - a.score)
      .slice(0, 5);
    suggestions[event.event_id] = matches;
  }

  // Summary stats
  const stats = await query(`
    SELECT
      COUNT(*) FILTER (WHERE wr.id IS NULL AND me.reference_number_raw IS NOT NULL) as unmatched,
      COUNT(*) FILTER (WHERE wr.id IS NOT NULL) as matched,
      COUNT(*) as total
    FROM market_event me
    LEFT JOIN watch_reference wr ON me.reference_number_raw = wr.reference_number
  `);

  return NextResponse.json({
    success: true,
    data: {
      unmatched,
      suggestions,
      references,
      stats: stats[0],
    },
  });
}

// Simple Levenshtein-based similarity score (0-1)
function similarity(a: string, b: string): number {
  if (!a || !b) return 0;
  a = a.toLowerCase().replace(/[^a-z0-9]/g, "");
  b = b.toLowerCase().replace(/[^a-z0-9]/g, "");
  if (a === b) return 1;

  const matrix: number[][] = [];
  for (let i = 0; i <= a.length; i++) {
    matrix[i] = [i];
    for (let j = 1; j <= b.length; j++) {
      if (i === 0) {
        matrix[i][j] = j;
      } else {
        const cost = a[i - 1] === b[j - 1] ? 0 : 1;
        matrix[i][j] = Math.min(
          matrix[i - 1][j] + 1,
          matrix[i][j - 1] + 1,
          matrix[i - 1][j - 1] + cost
        );
      }
    }
  }
  const maxLen = Math.max(a.length, b.length);
  return maxLen > 0 ? 1 - matrix[a.length][b.length] / maxLen : 0;
}

// POST: Link a market event to a reference
export async function POST(request: NextRequest) {
  const session = await getSession(request);
  if (!session) {
    return NextResponse.json({ success: false, error: { code: "UNAUTHORIZED", message: "Login required" } }, { status: 401 });
  }

  const { event_id, reference_number } = await request.json();

  if (!event_id || !reference_number) {
    return NextResponse.json({
      success: false,
      error: { code: "MISSING_PARAMS", message: "event_id and reference_number required" },
    }, { status: 400 });
  }

  // Update the market event's reference_number_raw
  await query(
    `UPDATE market_event SET reference_number_raw = $1 WHERE event_id = $2`,
    [reference_number, event_id]
  );

  return NextResponse.json({ success: true, data: { event_id, reference_number } });
}