Files
DEVIS-GENERATOR/server.js
2025-12-08 11:13:20 +01:00

250 lines
7.3 KiB
JavaScript

#!/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);
});