← back to AbramsOS

routes/documents.js

97 lines

// Document operations — currently: parse a PDF and (optionally) create a purchase from it.

const express = require('express');
const fs = require('fs');
const path = require('path');
const db = require('../lib/db');
const audit = require('../lib/audit');
const { id } = require('../lib/ids');
const pdf = require('../lib/pdf-parser');
const extractor = require('../lib/receipt-extractor');

const router = express.Router();
const DEV_USER_ID = 'user_steve';

router.get('/api/documents', async (_req, res) => {
  const r = await db.query(
    `SELECT id, kind, mime, object_path, parsed_status, created_at
       FROM document WHERE user_id = $1 ORDER BY created_at DESC LIMIT 200`,
    [DEV_USER_ID]
  );
  res.json(r.rows);
});

router.post('/api/documents/:id/parse', async (req, res) => {
  const docId = req.params.id;
  const r = await db.query(`SELECT * FROM document WHERE id = $1 AND user_id = $2`, [docId, DEV_USER_ID]);
  if (!r.rows.length) return res.status(404).json({ error: 'document not found' });
  const doc = r.rows[0];

  if (doc.mime !== 'application/pdf') {
    return res.status(400).json({ error: 'only application/pdf supported in tick 4 (image OCR is a later tick)' });
  }

  const filePath = pdf.resolveDocumentPath(doc.object_path, doc.mime);
  if (!filePath || !fs.existsSync(filePath)) {
    return res.status(404).json({ error: `file not found on disk: ${filePath}` });
  }

  try {
    const parsed = await pdf.parseFile(filePath);
    await db.query(`UPDATE document SET parsed_status = 'parsed' WHERE id = $1`, [docId]);

    // Build a synthetic gmail-summary so the existing extractor can run unchanged.
    const synthSummary = {
      headers: { subject: path.basename(filePath), from: '', date: new Date(doc.created_at).toISOString() },
      body: { text: parsed.text, html: '' },
    };
    let extracted = extractor.extract(synthSummary);
    if (extracted && extracted.confidence < extractor.LLM_THRESHOLD) {
      try {
        extracted = await extractor.extractWithFallback(synthSummary, { llm: true, timeoutMs: 25_000 });
      } catch (_) { /* keep heuristic */ }
    }

    let purchaseId = null;
    if (extracted) {
      purchaseId = id('purchase');
      await db.query(
        `INSERT INTO purchase
           (id, user_id, source_message_id, merchant_name, merchant_domain, order_number, purchase_date, total_amount, currency, confidence, raw_extract)
         VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, $10)`,
        [purchaseId, DEV_USER_ID, extracted.merchant, extracted.merchantDomain, extracted.orderNumber, extracted.purchaseDate, extracted.total, extracted.currency, extracted.confidence, extracted]
      );
      await audit.log({
        actorType: 'system',
        objectType: 'purchase',
        objectId: purchaseId,
        eventType: 'purchase_extracted',
        metadata: { source: 'pdf', document_id: docId, merchant: extracted.merchant, total: extracted.total, confidence: extracted.confidence, source_tier: extracted.source },
      });
    }

    await audit.log({
      actorType: 'user',
      actorId: DEV_USER_ID,
      objectType: 'document',
      objectId: docId,
      eventType: 'document_parsed',
      metadata: { pages: parsed.pages, chars: parsed.text.length, purchase_id: purchaseId },
    });

    res.json({
      ok: true,
      pages: parsed.pages,
      chars: parsed.text.length,
      preview: parsed.text.slice(0, 400),
      purchase: extracted ? { id: purchaseId, ...extracted } : null,
    });
  } catch (err) {
    console.error('[document parse]', err);
    await db.query(`UPDATE document SET parsed_status = 'failed' WHERE id = $1`, [docId]);
    res.status(500).json({ error: err.message });
  }
});

module.exports = router;