250 lines
7.3 KiB
JavaScript
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);
|
|
});
|