feat-contacts-import

This commit is contained in:
deploy
2026-04-30 18:08:34 +02:00
parent 72bc349429
commit 8b8a145d52
8 changed files with 547 additions and 51 deletions
+3 -1
View File
@@ -20,7 +20,9 @@
"pdf-lib": "^1.17.1",
"pg": "^8.11.5",
"uuid": "^9.0.1",
"zod": "^3.23.8"
"zod": "^3.23.8",
"csv-parse": "^5.5.6",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
+2
View File
@@ -14,6 +14,7 @@ import invoicesRoutes from './routes/invoices';
import companiesRoutes from './routes/companies';
import categoriesRoutes from './routes/categories';
import settingsRoutes from './routes/settings';
import contactsRoutes from './routes/contacts';
const app = express();
@@ -33,6 +34,7 @@ app.use('/api/invoices', invoicesRoutes);
app.use('/api/companies', companiesRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/contacts', contactsRoutes);
// ─── Health check (accessible via /health ET /api/health) ──────
app.get('/health', (_req, res) => res.json({ status: 'ok', version: '1.0.0' }));
+13
View File
@@ -116,6 +116,18 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================================
-- CONTACTS (répertoire d'invités réutilisables)
-- Utilisés comme suggestions dans le formulaire de facture
-- =============================================================
CREATE TABLE IF NOT EXISTS contacts (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
company VARCHAR(255),
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================================
-- INDEX
-- =============================================================
@@ -128,6 +140,7 @@ CREATE INDEX IF NOT EXISTS idx_invoices_sent_at ON invoices(sent_at);
CREATE INDEX IF NOT EXISTS idx_guests_invoice_id ON guests(invoice_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
-- =============================================================
-- TRIGGER : mise à jour automatique de updated_at
+150
View File
@@ -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;