Add PostgreSQL backend and config

This commit is contained in:
Matthieu
2025-12-08 11:13:20 +01:00
parent 15515a3512
commit ccdbf19fd8
5 changed files with 537 additions and 10 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
# Dependencies
node_modules
# Environment / secrets
.env
# OS / editor files
.DS_Store

102
app.js
View File

@@ -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
View 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
View 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
View 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);
});