|
|
|
@@ -0,0 +1,150 @@
|
|
|
|
|
import { Router, RequestHandler } from 'express';
|
|
|
|
|
import multer from 'multer';
|
|
|
|
|
import { db } from '../db';
|
|
|
|
|
import { requireAuth } from '../middleware/auth';
|
|
|
|
|
import { parse as csvParse } from 'csv-parse/sync';
|
|
|
|
|
import * as XLSX from 'xlsx';
|
|
|
|
|
|
|
|
|
|
const router = Router();
|
|
|
|
|
router.use(requireAuth);
|
|
|
|
|
|
|
|
|
|
// Multer en mémoire (CSV/XLSX, max 5 Mo)
|
|
|
|
|
const upload = multer({
|
|
|
|
|
storage: multer.memoryStorage(),
|
|
|
|
|
limits: { fileSize: 5 * 1024 * 1024 },
|
|
|
|
|
fileFilter: (_req, file, cb) => {
|
|
|
|
|
const ok = [
|
|
|
|
|
'text/csv',
|
|
|
|
|
'application/vnd.ms-excel',
|
|
|
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
|
|
|
'application/octet-stream',
|
|
|
|
|
].includes(file.mimetype) || file.originalname.match(/\.(csv|xls|xlsx)$/i);
|
|
|
|
|
cb(null, !!ok);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function wrap(fn: RequestHandler): RequestHandler {
|
|
|
|
|
return (req, res, next) => (fn(req, res, next) as any).catch(next);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** Normalise une ligne brute en { name, company } */
|
|
|
|
|
function normalizeRow(row: Record<string, any>): { name: string; company: string | null } | null {
|
|
|
|
|
// Cherche les colonnes "nom"/"name" et "société"/"company" indépendamment de la casse
|
|
|
|
|
const keys = Object.keys(row);
|
|
|
|
|
|
|
|
|
|
const nameKey = keys.find((k) =>
|
|
|
|
|
/^(nom|name|prénom|prenom|contact)$/i.test(k.trim())
|
|
|
|
|
);
|
|
|
|
|
const companyKey = keys.find((k) =>
|
|
|
|
|
/^(soci[eé]t[eé]|company|entreprise|organisation|organization|employeur|employer)$/i.test(k.trim())
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Fallback : première colonne = nom, deuxième = société
|
|
|
|
|
const name = (nameKey ? row[nameKey] : row[keys[0]] ?? '').toString().trim();
|
|
|
|
|
const company = companyKey
|
|
|
|
|
? (row[companyKey] ?? '').toString().trim() || null
|
|
|
|
|
: keys[1]
|
|
|
|
|
? (row[keys[1]] ?? '').toString().trim() || null
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
if (!name) return null;
|
|
|
|
|
return { name, company };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Parse un buffer CSV → tableau de lignes normalisées */
|
|
|
|
|
function parseCSV(buf: Buffer): Array<{ name: string; company: string | null }> {
|
|
|
|
|
const rows = csvParse(buf, {
|
|
|
|
|
columns: true,
|
|
|
|
|
skip_empty_lines: true,
|
|
|
|
|
trim: true,
|
|
|
|
|
bom: true,
|
|
|
|
|
}) as Record<string, any>[];
|
|
|
|
|
return rows.map(normalizeRow).filter(Boolean) as Array<{ name: string; company: string | null }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Parse un buffer XLSX → tableau de lignes normalisées */
|
|
|
|
|
function parseXLSX(buf: Buffer): Array<{ name: string; company: string | null }> {
|
|
|
|
|
const wb = XLSX.read(buf, { type: 'buffer' });
|
|
|
|
|
const ws = wb.Sheets[wb.SheetNames[0]];
|
|
|
|
|
const rows = XLSX.utils.sheet_to_json<Record<string, any>>(ws, { defval: '' });
|
|
|
|
|
return rows.map(normalizeRow).filter(Boolean) as Array<{ name: string; company: string | null }>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Routes ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/** GET /api/contacts — liste tous les contacts triés */
|
|
|
|
|
router.get('/', wrap(async (_req, res) => {
|
|
|
|
|
const { rows } = await db.query(
|
|
|
|
|
'SELECT id, name, company, sort_order FROM contacts ORDER BY name ASC'
|
|
|
|
|
);
|
|
|
|
|
res.json(rows);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
/** POST /api/contacts — ajoute un contact manuel */
|
|
|
|
|
router.post('/', wrap(async (req, res) => {
|
|
|
|
|
const { name, company } = req.body as { name?: string; company?: string };
|
|
|
|
|
if (!name?.trim()) {
|
|
|
|
|
return res.status(400).json({ error: 'Le nom est requis.' });
|
|
|
|
|
}
|
|
|
|
|
const { rows } = await db.query(
|
|
|
|
|
'INSERT INTO contacts (name, company) VALUES ($1, $2) RETURNING id, name, company, sort_order',
|
|
|
|
|
[name.trim(), company?.trim() || null]
|
|
|
|
|
);
|
|
|
|
|
res.status(201).json(rows[0]);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
/** POST /api/contacts/import — import CSV ou XLSX */
|
|
|
|
|
router.post('/import', upload.single('file'), wrap(async (req, res) => {
|
|
|
|
|
if (!req.file) {
|
|
|
|
|
return res.status(400).json({ error: 'Aucun fichier fourni.' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isXLSX =
|
|
|
|
|
req.file.originalname.match(/\.(xls|xlsx)$/i) ||
|
|
|
|
|
req.file.mimetype.includes('spreadsheet') ||
|
|
|
|
|
req.file.mimetype.includes('ms-excel');
|
|
|
|
|
|
|
|
|
|
let contacts: Array<{ name: string; company: string | null }>;
|
|
|
|
|
try {
|
|
|
|
|
contacts = isXLSX ? parseXLSX(req.file.buffer) : parseCSV(req.file.buffer);
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
return res.status(422).json({ error: `Impossible de lire le fichier : ${err.message}` });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contacts.length === 0) {
|
|
|
|
|
return res.status(422).json({ error: 'Aucun contact trouvé dans le fichier. Vérifiez les colonnes (Nom, Société).' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Upsert : si (name, company) existe déjà, on ne duplique pas
|
|
|
|
|
let inserted = 0;
|
|
|
|
|
let skipped = 0;
|
|
|
|
|
|
|
|
|
|
for (const c of contacts) {
|
|
|
|
|
const exists = await db.query(
|
|
|
|
|
'SELECT id FROM contacts WHERE LOWER(name) = LOWER($1) AND (company IS NULL AND $2::TEXT IS NULL OR LOWER(company) = LOWER($2))',
|
|
|
|
|
[c.name, c.company]
|
|
|
|
|
);
|
|
|
|
|
if (exists.rowCount && exists.rowCount > 0) {
|
|
|
|
|
skipped++;
|
|
|
|
|
} else {
|
|
|
|
|
await db.query(
|
|
|
|
|
'INSERT INTO contacts (name, company) VALUES ($1, $2)',
|
|
|
|
|
[c.name, c.company]
|
|
|
|
|
);
|
|
|
|
|
inserted++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.json({ inserted, skipped, total: contacts.length });
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
/** DELETE /api/contacts/:id */
|
|
|
|
|
router.delete('/:id', wrap(async (req, res) => {
|
|
|
|
|
const { rowCount } = await db.query('DELETE FROM contacts WHERE id = $1', [req.params.id]);
|
|
|
|
|
if (!rowCount) return res.status(404).json({ error: 'Contact introuvable.' });
|
|
|
|
|
res.json({ success: true });
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
export default router;
|