feat-contacts-import
This commit is contained in:
@@ -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' }));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user