Add PostgreSQL backend and config
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
|
||||
# OS / editor files
|
||||
.DS_Store
|
||||
102
app.js
102
app.js
@@ -60,6 +60,7 @@
|
||||
|
||||
const persistKey = "devis-generator:v1";
|
||||
const savedListKey = "devis-generator:saved:v1";
|
||||
const globalSavesEndpoint = "/api/saves";
|
||||
const save = () => {
|
||||
try {
|
||||
localStorage.setItem(persistKey, JSON.stringify(state));
|
||||
@@ -85,6 +86,35 @@
|
||||
localStorage.setItem(savedListKey, JSON.stringify(arr));
|
||||
} catch {}
|
||||
};
|
||||
const canUseGlobalSaves = () =>
|
||||
typeof fetch === "function" && (location?.protocol || "") !== "file:";
|
||||
const fetchGlobalSaves = async () => {
|
||||
if (!canUseGlobalSaves()) throw new Error("Sauvegardes globales indisponibles");
|
||||
const res = await fetch(globalSavesEndpoint, { credentials: "same-origin" });
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
const json = await res.json();
|
||||
if (!Array.isArray(json)) throw new Error("Réponse inattendue");
|
||||
return json;
|
||||
};
|
||||
const pushGlobalSave = async (entry) => {
|
||||
if (!canUseGlobalSaves()) throw new Error("Sauvegardes globales indisponibles");
|
||||
const res = await fetch(globalSavesEndpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify(entry),
|
||||
});
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
return res.json();
|
||||
};
|
||||
const deleteGlobalSave = async (id) => {
|
||||
if (!canUseGlobalSaves()) throw new Error("Sauvegardes globales indisponibles");
|
||||
const res = await fetch(`${globalSavesEndpoint}/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
};
|
||||
const computeQuickTotal = (data) => {
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
const subtotal = items.reduce(
|
||||
@@ -109,7 +139,7 @@
|
||||
num || [client, date].filter(Boolean).join(" - ") || "Devis sans titre"
|
||||
);
|
||||
};
|
||||
const saveCurrentQuote = () => {
|
||||
const saveCurrentQuote = async () => {
|
||||
const data = buildExportJson();
|
||||
const input = prompt("Nom du devis", buildDefaultSaveTitle());
|
||||
if (input === null) return; // Annulé => ne rien faire
|
||||
@@ -126,14 +156,43 @@
|
||||
const list = getSavedList();
|
||||
list.unshift(entry);
|
||||
setSavedList(list);
|
||||
alert("Devis enregistré.");
|
||||
let savedGlobally = false;
|
||||
try {
|
||||
if (canUseGlobalSaves()) {
|
||||
const saved = await pushGlobalSave(entry);
|
||||
if (saved && typeof saved === "object") {
|
||||
const cur = getSavedList();
|
||||
cur[0] = saved; // synchroniser id/horodatage renvoyés
|
||||
setSavedList(cur);
|
||||
}
|
||||
savedGlobally = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Sauvegarde globale impossible:", e);
|
||||
}
|
||||
alert(
|
||||
savedGlobally
|
||||
? "Devis enregistré pour tout le monde (et en local)."
|
||||
: "Devis enregistré uniquement sur cet appareil."
|
||||
);
|
||||
};
|
||||
const openLibrary = () => {
|
||||
const openLibrary = async () => {
|
||||
const modal = document.getElementById("libraryModal");
|
||||
const listEl = document.getElementById("libraryList");
|
||||
const emptyEl = document.getElementById("libraryEmpty");
|
||||
if (!modal || !listEl || !emptyEl) return;
|
||||
const list = getSavedList();
|
||||
listEl.innerHTML = `<div class="muted">Chargement...</div>`;
|
||||
let list = [];
|
||||
let usedGlobal = false;
|
||||
if (canUseGlobalSaves()) {
|
||||
try {
|
||||
list = await fetchGlobalSaves();
|
||||
usedGlobal = true;
|
||||
} catch (e) {
|
||||
console.warn("Lecture globale impossible, fallback local:", e);
|
||||
}
|
||||
}
|
||||
if (!usedGlobal) list = getSavedList();
|
||||
listEl.innerHTML = "";
|
||||
if (!list.length) {
|
||||
emptyEl.style.display = "";
|
||||
@@ -161,11 +220,23 @@
|
||||
const btnDel = document.createElement("button");
|
||||
btnDel.className = "btn btn-danger";
|
||||
btnDel.textContent = "Supprimer";
|
||||
btnDel.addEventListener("click", () => {
|
||||
btnDel.addEventListener("click", async () => {
|
||||
if (!confirm("Supprimer ce devis enregistré ?")) return;
|
||||
const cur = getSavedList().filter((x) => x.id !== e.id);
|
||||
setSavedList(cur);
|
||||
openLibrary();
|
||||
if (usedGlobal && canUseGlobalSaves()) {
|
||||
try {
|
||||
await deleteGlobalSave(e.id);
|
||||
} catch (err) {
|
||||
alert(
|
||||
"Suppression impossible côté serveur: " +
|
||||
(err.message || err)
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const cur = getSavedList().filter((x) => x.id !== e.id);
|
||||
setSavedList(cur);
|
||||
}
|
||||
await openLibrary();
|
||||
});
|
||||
row.appendChild(title);
|
||||
row.appendChild(price);
|
||||
@@ -865,9 +936,17 @@
|
||||
window.print();
|
||||
});
|
||||
// Save current quote
|
||||
$("#saveQuoteBtn")?.addEventListener("click", () => saveCurrentQuote());
|
||||
$("#saveQuoteBtn")?.addEventListener("click", () =>
|
||||
saveCurrentQuote().catch((e) =>
|
||||
alert("Impossible d'enregistrer: " + (e.message || e))
|
||||
)
|
||||
);
|
||||
// Library open/close
|
||||
$("#openLibraryBtn")?.addEventListener("click", () => openLibrary());
|
||||
$("#openLibraryBtn")?.addEventListener("click", () =>
|
||||
openLibrary().catch((e) =>
|
||||
alert("Impossible d'ouvrir la bibliothèque: " + (e.message || e))
|
||||
)
|
||||
);
|
||||
$("#closeLibraryBtn")?.addEventListener("click", () => closeLibrary());
|
||||
document
|
||||
.querySelector("#libraryModal .modal-backdrop")
|
||||
@@ -1113,6 +1192,9 @@
|
||||
};
|
||||
|
||||
const loadFromJson = (obj) => {
|
||||
if (obj && obj.data && typeof obj.data === "object") {
|
||||
obj = obj.data;
|
||||
}
|
||||
if (!obj || typeof obj !== "object") throw new Error("JSON invalide");
|
||||
// Merge top-level simple keys
|
||||
keysAllowed.forEach((k) => {
|
||||
|
||||
174
package-lock.json
generated
Normal file
174
package-lock.json
generated
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"name": "devis-generator",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "devis-generator",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
"pg": "^8.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "16.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.3",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
"pg-protocol": "^1.10.3",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.2.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "devis-generator",
|
||||
"version": "1.0.0",
|
||||
"description": "Générateur de devis avec sauvegardes partagées via PostgreSQL",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
"pg": "^8.12.0"
|
||||
}
|
||||
}
|
||||
249
server.js
Normal file
249
server.js
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user