|
|
|
|
@@ -1,6 +1,7 @@
|
|
|
|
|
import { Router } from 'express';
|
|
|
|
|
import { pool } from '../db/pool.js';
|
|
|
|
|
import { requireAuth } from '../auth/middleware.js';
|
|
|
|
|
import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai';
|
|
|
|
|
|
|
|
|
|
const router = Router();
|
|
|
|
|
router.use(requireAuth);
|
|
|
|
|
@@ -42,9 +43,9 @@ router.post('/', async (req, res) => {
|
|
|
|
|
$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25
|
|
|
|
|
) RETURNING *, percentage_full::float AS percentage_full, expiration_date::text AS expiration_date`,
|
|
|
|
|
[
|
|
|
|
|
req.user!.id, b.piFirstName, b.physicalState, b.chemicalName, b.bldgCode, b.lab,
|
|
|
|
|
b.storageLocation, b.storageDevice, b.numberOfContainers, b.amountPerContainer,
|
|
|
|
|
b.unitOfMeasure, b.casNumber,
|
|
|
|
|
req.user!.id, b.piFirstName || null, b.physicalState || null, b.chemicalName || null, b.bldgCode || null, b.lab || null,
|
|
|
|
|
b.storageLocation || null, b.storageDevice || null, b.numberOfContainers || null, b.amountPerContainer || null,
|
|
|
|
|
b.unitOfMeasure || null, b.casNumber || null,
|
|
|
|
|
b.chemicalFormula || null, b.molecularWeight || null, b.vendor || null,
|
|
|
|
|
b.catalogNumber || null, b.lotNumber || null,
|
|
|
|
|
b.expirationDate || null, b.concentration || null,
|
|
|
|
|
@@ -122,6 +123,25 @@ router.post('/import', async (req, res) => {
|
|
|
|
|
res.json({ imported, errors });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// POST /api/chemicals/bulk-delete
|
|
|
|
|
router.post('/bulk-delete', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { ids } = req.body;
|
|
|
|
|
if (!Array.isArray(ids) || ids.length === 0) {
|
|
|
|
|
return res.status(400).json({ error: 'No IDs provided' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await pool.query(
|
|
|
|
|
'DELETE FROM chemicals WHERE id = ANY($1) AND user_id = $2',
|
|
|
|
|
[ids, req.user!.id]
|
|
|
|
|
);
|
|
|
|
|
res.status(204).end();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error(err);
|
|
|
|
|
res.status(500).json({ error: 'Internal server error' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// DELETE /api/chemicals/:id
|
|
|
|
|
router.delete('/:id', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
@@ -143,4 +163,118 @@ function snakeToCamel(row: Record<string, unknown>): Record<string, unknown> {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// POST /api/chemicals/column-match
|
|
|
|
|
router.post('/column-match', async (req, res) => {
|
|
|
|
|
try {
|
|
|
|
|
const { headers, sampleRows } = req.body;
|
|
|
|
|
if (!headers || !Array.isArray(headers)) {
|
|
|
|
|
return res.status(400).json({ error: 'Headers must be an array' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const apiKey = process.env.GEMINI_API_KEY;
|
|
|
|
|
if (!apiKey) {
|
|
|
|
|
// Graceful fallback or error
|
|
|
|
|
return res.status(500).json({ error: 'GEMINI_API_KEY is not configured.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const genAI = new GoogleGenerativeAI(apiKey);
|
|
|
|
|
const model = genAI.getGenerativeModel({ model: 'gemini-2.5-flash' });
|
|
|
|
|
|
|
|
|
|
const canonicalColumns = [
|
|
|
|
|
{ label: "PI First Name", key: "piFirstName" },
|
|
|
|
|
{ label: "Physical State", key: "physicalState" },
|
|
|
|
|
{ label: "Chemical Name", key: "chemicalName" },
|
|
|
|
|
{ label: "Bldg Code", key: "bldgCode" },
|
|
|
|
|
{ label: "LAB", key: "lab" },
|
|
|
|
|
{ label: "Storage Location", key: "storageLocation" },
|
|
|
|
|
{ label: "Storage Device", key: "storageDevice" },
|
|
|
|
|
{ label: "# of Containers", key: "numberOfContainers" },
|
|
|
|
|
{ label: "Amount per Container", key: "amountPerContainer" },
|
|
|
|
|
{ label: "Unit of Measure", key: "unitOfMeasure" },
|
|
|
|
|
{ label: "CAS #", key: "casNumber" },
|
|
|
|
|
{ label: "Chemical Formula", key: "chemicalFormula" },
|
|
|
|
|
{ label: "Molecular Weight", key: "molecularWeight" },
|
|
|
|
|
{ label: "Vendor", key: "vendor" },
|
|
|
|
|
{ label: "Catalog #", key: "catalogNumber" },
|
|
|
|
|
{ label: "Found in Catalog", key: "foundInCatalog" },
|
|
|
|
|
{ label: "PO#", key: "poNumber" },
|
|
|
|
|
{ label: "Receipt Date", key: "receiptDate" },
|
|
|
|
|
{ label: "Open Date", key: "openDate" },
|
|
|
|
|
{ label: "MAX on Hand", key: "maxOnHand" },
|
|
|
|
|
{ label: "Expiration Date", key: "expirationDate" },
|
|
|
|
|
{ label: "Contact", key: "contact" },
|
|
|
|
|
{ label: "Comments", key: "comments" },
|
|
|
|
|
{ label: "Date Entered", key: "dateEntered" },
|
|
|
|
|
{ label: "Permit #", key: "permitNumber" },
|
|
|
|
|
{ label: "Barcode", key: "barcode" },
|
|
|
|
|
{ label: "LAST_CHANGED", key: "lastChanged" },
|
|
|
|
|
{ label: "Concentration", key: "concentration" },
|
|
|
|
|
{ label: "Chemical Number", key: "chemicalNumber" },
|
|
|
|
|
{ label: "Lot Number", key: "lotNumber" },
|
|
|
|
|
{ label: "Multiple CAS (comma delimited)", key: "multipleCAS" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const prompt = `You are a data migration assistant. Your task is to map a user's uploaded spreadsheet columns to a canonical database schema.
|
|
|
|
|
|
|
|
|
|
Here are the user's headers and a few sample rows:
|
|
|
|
|
Headers: ${JSON.stringify(headers)}
|
|
|
|
|
Samples: ${JSON.stringify(sampleRows || [])}
|
|
|
|
|
|
|
|
|
|
Here is our canonical schema:
|
|
|
|
|
${JSON.stringify(canonicalColumns, null, 2)}
|
|
|
|
|
|
|
|
|
|
For each user header, find the single best matching canonical 'key'. If there is no logical match, map it to "__skip__".
|
|
|
|
|
Constraints:
|
|
|
|
|
- You may only map a given canonical key ONCE across all headers. Do not assign two user headers to the same canonical key.
|
|
|
|
|
- Try to be accurate based on semantic meaning, typical lab inventory vocabulary, and the sample values.
|
|
|
|
|
|
|
|
|
|
Respond ONLY with a JSON object in this exact format, with no markdown code block fences and no other text:
|
|
|
|
|
{
|
|
|
|
|
"mappings": [
|
|
|
|
|
{
|
|
|
|
|
"header": "the exact user header string",
|
|
|
|
|
"key": "the canonical key or __skip__",
|
|
|
|
|
"confidence": 0.95,
|
|
|
|
|
"reason": "short explanation of why this matches or why it was skipped"
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
Ensure there is exactly one mapping per user header in the same order as the input headers.`;
|
|
|
|
|
|
|
|
|
|
const result = await model.generateContent({
|
|
|
|
|
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
|
|
|
generationConfig: {
|
|
|
|
|
responseMimeType: 'application/json',
|
|
|
|
|
responseSchema: {
|
|
|
|
|
type: SchemaType.OBJECT,
|
|
|
|
|
properties: {
|
|
|
|
|
mappings: {
|
|
|
|
|
type: SchemaType.ARRAY,
|
|
|
|
|
items: {
|
|
|
|
|
type: SchemaType.OBJECT,
|
|
|
|
|
properties: {
|
|
|
|
|
header: { type: SchemaType.STRING },
|
|
|
|
|
key: { type: SchemaType.STRING },
|
|
|
|
|
confidence: { type: SchemaType.NUMBER },
|
|
|
|
|
reason: { type: SchemaType.STRING },
|
|
|
|
|
},
|
|
|
|
|
required: ["header", "key", "confidence", "reason"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
required: ["mappings"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let text = result.response.text();
|
|
|
|
|
text = text.replace(/^```(?:json)?\n?/i, '').replace(/```\n?$/, '').trim();
|
|
|
|
|
const parsed = JSON.parse(text);
|
|
|
|
|
res.json(parsed);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Column match error:', err);
|
|
|
|
|
res.status(500).json({ error: 'Failed to process column mapping.' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
export default router;
|
|
|
|
|
|