table import works now
Some checks failed
Deploy to Server / deploy (push) Failing after 1m7s

This commit is contained in:
2026-04-24 18:19:54 -05:00
parent 4098453c97
commit 24d126eeb9
6 changed files with 524 additions and 118 deletions

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@better-auth/kysely-adapter": "^1.5.6",
"@google/generative-ai": "^0.24.1",
"@node-rs/argon2": "^2.0.2",
"better-auth": "^1.5.5",
"cors": "^2.8.5",

View File

@@ -2,10 +2,10 @@ import FormData from 'form-data';
import Mailgun from 'mailgun.js';
const mailgun = new (Mailgun as any)(FormData);
const mg = mailgun.client({
const mg = process.env.MAILGUN_API_KEY ? mailgun.client({
username: 'api',
key: process.env.MAILGUN_API_KEY || '',
});
key: process.env.MAILGUN_API_KEY,
}) : null;
const DOMAIN = process.env.MAILGUN_DOMAIN || 'sandbox06aa4efa8cc342878b7470a7c9113df3.mailgun.org';
const FROM = process.env.FROM_EMAIL || `LabWise <postmaster@${DOMAIN}>`;
@@ -19,6 +19,10 @@ export async function sendEmail({
subject: string;
html: string;
}) {
if (!mg) {
console.warn('[auth] MAILGUN_API_KEY is not set. Email not sent:', subject);
return;
}
await mg.messages.create(DOMAIN, {
from: FROM,
to: [to],

View File

@@ -128,3 +128,16 @@ ALTER TABLE user_profile ALTER COLUMN lab DROP NOT NULL;
-- Allow name to be cleared (set to null).
ALTER TABLE "user" ALTER COLUMN "name" DROP NOT NULL;
-- Allow incomplete chemical imports.
ALTER TABLE chemicals ALTER COLUMN pi_first_name DROP NOT NULL;
ALTER TABLE chemicals ALTER COLUMN physical_state DROP NOT NULL;
ALTER TABLE chemicals ALTER COLUMN chemical_name DROP NOT NULL;
ALTER TABLE chemicals ALTER COLUMN bldg_code DROP NOT NULL;
ALTER TABLE chemicals ALTER COLUMN lab DROP NOT NULL;
ALTER TABLE chemicals ALTER COLUMN storage_location DROP NOT NULL;
ALTER TABLE chemicals ALTER COLUMN storage_device DROP NOT NULL;
ALTER TABLE chemicals ALTER COLUMN number_of_containers DROP NOT NULL;
ALTER TABLE chemicals ALTER COLUMN amount_per_container DROP NOT NULL;
ALTER TABLE chemicals ALTER COLUMN unit_of_measure DROP NOT NULL;
ALTER TABLE chemicals ALTER COLUMN cas_number DROP NOT NULL;

View File

@@ -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;