#!/usr/bin/env node /** * Serveur simple (Node HTTP) pour partager les devis via PostgreSQL. * Routes: * GET /api/saves -> liste des devis * POST /api/saves -> ajoute un devis { name, data, number?, clientName?, date?, total? } * DELETE /api/saves/:id -> supprime un devis * Sert aussi les fichiers statiques du dossier courant. */ require("dotenv").config(); const http = require("http"); const fs = require("fs"); const fsp = require("fs/promises"); const path = require("path"); const { Pool } = require("pg"); const PORT = Number(process.env.PORT || 3000); const HOST = process.env.HOST || "0.0.0.0"; const ROOT = __dirname; const pool = new Pool({ connectionString: process.env.DATABASE_URL, host: process.env.PGHOST, port: process.env.PGPORT ? Number(process.env.PGPORT) : undefined, user: process.env.PGUSER, password: process.env.PGPASSWORD, database: process.env.PGDATABASE, ssl: process.env.PGSSL === "true" || process.env.PGSSLMODE === "require" ? { rejectUnauthorized: false } : undefined, }); const MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", }; const ensureSchema = async () => { await pool.query(` CREATE TABLE IF NOT EXISTS quotes ( id TEXT PRIMARY KEY, name TEXT NOT NULL, number TEXT, client_name TEXT, date TEXT, total NUMERIC, saved_at TIMESTAMPTZ NOT NULL DEFAULT now(), data JSONB NOT NULL ); `); await pool.query(`CREATE INDEX IF NOT EXISTS quotes_saved_at_idx ON quotes (saved_at DESC);`); }; const readBodyJson = async (req) => { const chunks = []; for await (const chunk of req) { chunks.push(chunk); if (chunks.reduce((s, c) => s + c.length, 0) > 1e6) { throw new Error("Payload trop volumineux"); } } if (!chunks.length) return null; return JSON.parse(Buffer.concat(chunks).toString("utf8")); }; const quickTotal = (data) => { const items = Array.isArray(data?.items) ? data.items : []; const subtotal = items.reduce((acc, it) => { const qty = Number(it.days ?? it.qty ?? 0); const price = Number(it.unitPrice ?? it.unit_price ?? 0); return acc + qty * price; }, 0); const discountRate = Number(data?.discountRate || 0); const vatRate = Number(data?.vatRate || 0); const discount = subtotal * (discountRate / 100); const base = Math.max(0, subtotal - discount); const vat = base * (vatRate / 100); return base + vat; }; const sendJson = (res, status, payload) => { res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }); res.end(JSON.stringify(payload)); }; const notFound = (res) => { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("Not found"); }; const rowToEntry = (row) => ({ id: row.id, name: row.name || "", number: row.number || "", clientName: row.client_name || "", date: row.date || "", total: Number(row.total || 0), savedAt: row.saved_at instanceof Date ? row.saved_at.toISOString() : row.saved_at, data: row.data || {}, }); const handleApi = async (req, res, url) => { if (req.method === "OPTIONS") { res.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }); res.end(); return true; } if (url.pathname === "/api/saves" && req.method === "GET") { const { rows } = await pool.query( "SELECT id, name, number, client_name, date, total, saved_at, data FROM quotes ORDER BY saved_at DESC;" ); sendJson(res, 200, rows.map(rowToEntry)); return true; } if (url.pathname === "/api/saves" && req.method === "POST") { try { const body = await readBodyJson(req); if (!body || typeof body !== "object") { sendJson(res, 400, { error: "Corps vide" }); return true; } if (!body.data || typeof body.data !== "object") { sendJson(res, 400, { error: "Champ 'data' manquant" }); return true; } const id = body.id || Date.now().toString(36) + Math.random().toString(36).slice(2, 8); const total = Number.isFinite(body.total) ? Number(body.total) : quickTotal(body.data); const savedAt = new Date(); await pool.query( ` INSERT INTO quotes (id, name, number, client_name, date, total, saved_at, data) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, number = EXCLUDED.number, client_name = EXCLUDED.client_name, date = EXCLUDED.date, total = EXCLUDED.total, saved_at = EXCLUDED.saved_at, data = EXCLUDED.data; `, [ id, (body.name || "Devis sans titre").toString(), (body.number || "").toString(), (body.clientName || "").toString(), (body.date || "").toString(), total, savedAt, body.data, ] ); sendJson(res, 201, { id, name: body.name || "Devis sans titre", number: body.number || "", clientName: body.clientName || "", date: body.date || "", total, savedAt: savedAt.toISOString(), data: body.data, }); } catch (e) { console.error("POST /api/saves error:", e); sendJson(res, 400, { error: e.message || "Erreur" }); } return true; } if (url.pathname.startsWith("/api/saves/") && req.method === "DELETE") { const id = url.pathname.split("/").pop(); await pool.query("DELETE FROM quotes WHERE id = $1;", [id]); sendJson(res, 200, { ok: true }); return true; } return false; }; const serveStatic = async (req, res, url) => { let pathname = decodeURIComponent(url.pathname); if (pathname === "/") pathname = "/index.html"; const filePath = path.join(ROOT, pathname); if (!filePath.startsWith(ROOT)) { notFound(res); return; } try { const stat = await fsp.stat(filePath); if (stat.isDirectory()) { notFound(res); return; } const ext = path.extname(filePath); const type = MIME[ext] || "application/octet-stream"; res.writeHead(200, { "Content-Type": type }); fs.createReadStream(filePath).pipe(res); } catch { notFound(res); } }; const start = async () => { await ensureSchema(); const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); try { const handled = await handleApi(req, res, url); if (!handled) await serveStatic(req, res, url); } catch (e) { console.error("Erreur serveur:", e); res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); res.end("Erreur serveur"); } }); server.listen(PORT, HOST, () => { console.log(`Serveur sur http://${HOST}:${PORT}`); }); }; start().catch((e) => { console.error("Impossible de démarrer:", e); process.exit(1); });