Initial local backup snapshot

This commit is contained in:
Selecta Keke 2026-06-24 00:02:55 -03:00
commit acd9e14ba5
367 changed files with 118038 additions and 0 deletions

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
HOST="${1:-ovh-vps}"
BASE_URL="${2:-https://www.portail-association973.com}"
echo "==> Conteneur OpenMapTiles sur ${HOST}"
ssh "${HOST}" 'docker ps --format "table {{.Names}}\t{{.Status}}" | grep -i openmaptiles || true'
echo
echo "==> Style serveur"
curl -I -L -s "${BASE_URL}/map-tiles/styles/OSM%20OpenMapTiles/style.json" | head -n 8
echo
echo "==> TileJSON"
curl -I -L -s "${BASE_URL}/data/openmaptiles.json" | head -n 8
echo
echo "==> Glyphes"
curl -I -L -s "${BASE_URL}/fonts/Open%20Sans%20Regular/0-255.pbf" | head -n 8
echo
echo "==> Style portail OSM Bright CCDS"
curl -I -L -s "${BASE_URL}/map-styles/openmaptiles-positron/style.json" | head -n 8

179
scripts/deploy-vps.sh Executable file
View file

@ -0,0 +1,179 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SSH_KEY_DEFAULT="${HOME}/.ssh/vps_ovh_cd035e9a_ed25519"
SSH_USER_DEFAULT="ubuntu"
SSH_HOST_DEFAULT="142.44.210.180"
usage() {
cat <<'EOF'
Usage:
./scripts/deploy-vps.sh <prod|preprod> [--skip-check] [--skip-build]
Examples:
./scripts/deploy-vps.sh preprod
./scripts/deploy-vps.sh prod
Behavior:
- synchronise les sources utiles vers le VPS
- exécute le check TypeScript local (sauf --skip-check)
- exécute le build local (sauf --skip-build)
- reconstruit et redémarre l'application distante
- utilise explicitement le bon fichier d'environnement par cible
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
TARGET="${1:-}"
if [[ -z "${TARGET}" ]]; then
usage
exit 1
fi
shift || true
RUN_CHECK=1
RUN_BUILD=1
USE_MAINTENANCE_FLAG="${USE_MAINTENANCE_FLAG:-0}"
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-check)
RUN_CHECK=0
;;
--skip-build)
RUN_BUILD=0
;;
-h|--help)
usage
exit 0
;;
*)
echo "Option inconnue: $1" >&2
usage
exit 1
;;
esac
shift
done
case "${TARGET}" in
prod)
REMOTE_DIR="/home/${SSH_USER_DEFAULT}/portail-associations"
REMOTE_DEPLOY_CMD='cd /home/ubuntu/portail-associations && docker compose --env-file .env up -d --build app'
POST_CHECK_URL='https://www.portail-association973.com'
;;
preprod)
REMOTE_DIR="/home/${SSH_USER_DEFAULT}/portail-associations-preprod"
REMOTE_DEPLOY_CMD='cd /home/ubuntu/portail-associations-preprod/deployments/preprod && docker compose --env-file .env up -d --build app'
POST_CHECK_URL='https://preprod.portail-association973.com'
;;
*)
echo "Cible invalide: ${TARGET}" >&2
usage
exit 1
;;
esac
SSH_KEY="${SSH_KEY:-${SSH_KEY_DEFAULT}}"
SSH_USER="${SSH_USER:-${SSH_USER_DEFAULT}}"
SSH_HOST="${SSH_HOST:-${SSH_HOST_DEFAULT}}"
SSH_TARGET="${SSH_USER}@${SSH_HOST}"
SSH_CMD=(ssh -i "${SSH_KEY}" -o BatchMode=yes -o ConnectTimeout=10 "${SSH_TARGET}")
RSYNC_SSH="ssh -i ${SSH_KEY} -o BatchMode=yes -o ConnectTimeout=10"
POST_CHECK_HEALTH_URL="${POST_CHECK_URL}/health/ready"
SYNC_ITEMS=(
"${ROOT_DIR}/client"
"${ROOT_DIR}/server"
"${ROOT_DIR}/shared"
"${ROOT_DIR}/drizzle"
"${ROOT_DIR}/docs"
"${ROOT_DIR}/deployments"
"${ROOT_DIR}/scripts"
"${ROOT_DIR}/patches"
"${ROOT_DIR}/package.json"
"${ROOT_DIR}/pnpm-lock.yaml"
"${ROOT_DIR}/Dockerfile"
"${ROOT_DIR}/docker-compose.yml"
"${ROOT_DIR}/vite.config.ts"
"${ROOT_DIR}/tsconfig.json"
"${ROOT_DIR}/components.json"
"${ROOT_DIR}/drizzle.config.ts"
"${ROOT_DIR}/MAIL_PROVIDERS.md"
)
echo "==> Vérification SSH (${SSH_TARGET})"
"${SSH_CMD[@]}" 'echo SSH_OK >/dev/null'
remote_maintenance() {
local mode="$1"
"${SSH_CMD[@]}" "cd ${REMOTE_DIR} && MAINTENANCE_FLAG_PATH=${REMOTE_DIR}/uploads/system/maintenance.flag ./scripts/maintenance-flag.sh ${mode}"
}
wait_for_http_200() {
local url="$1"
local label="$2"
local max_attempts="${3:-20}"
local delay_seconds="${4:-3}"
local attempt=1
while [[ "${attempt}" -le "${max_attempts}" ]]; do
local code
code="$(curl -k -s -o /dev/null -w "%{http_code}" -m 20 "${url}" || true)"
echo " - ${label} tentative ${attempt}/${max_attempts} -> ${code}"
if [[ "${code}" == "200" ]]; then
return 0
fi
sleep "${delay_seconds}"
attempt=$((attempt + 1))
done
return 1
}
if [[ "${RUN_CHECK}" -eq 1 ]]; then
echo "==> TypeScript check local"
(cd "${ROOT_DIR}" && corepack pnpm check)
fi
if [[ "${RUN_BUILD}" -eq 1 ]]; then
echo "==> Build local"
(cd "${ROOT_DIR}" && corepack pnpm build)
fi
echo "==> Synchronisation des sources vers ${REMOTE_DIR}"
rsync -az --delete \
--exclude '.git/' \
--exclude 'node_modules/' \
--exclude 'dist/' \
--exclude 'uploads/' \
--exclude '.env' \
-e "${RSYNC_SSH}" \
"${SYNC_ITEMS[@]}" \
"${SSH_TARGET}:${REMOTE_DIR}/"
if [[ "${USE_MAINTENANCE_FLAG}" == "1" ]]; then
echo "==> Activation du mode maintenance"
remote_maintenance on
fi
echo "==> Déploiement ${TARGET}"
"${SSH_CMD[@]}" "${REMOTE_DEPLOY_CMD}"
echo "==> Attente de disponibilité applicative (${POST_CHECK_HEALTH_URL})"
wait_for_http_200 "${POST_CHECK_HEALTH_URL}" "health" 25 3
if [[ "${USE_MAINTENANCE_FLAG}" == "1" ]]; then
echo "==> Désactivation du mode maintenance"
remote_maintenance off
fi
echo "==> Contrôle HTTP ${POST_CHECK_URL}"
wait_for_http_200 "${POST_CHECK_URL}" "home" 10 2
curl -I -k -m 20 "${POST_CHECK_HEALTH_URL}"
echo "==> Déploiement ${TARGET} terminé"

View file

@ -0,0 +1,171 @@
import { appRouter } from "../server/routers.ts";
import * as db from "../server/db.ts";
import { withEffectiveInternalAccess } from "../server/internalAccess.ts";
async function main() {
const baseUser = await db.getUserByEmail("selectakeke973@gmail.com");
if (!baseUser) {
throw new Error("super admin introuvable");
}
const user = await withEffectiveInternalAccess(baseUser);
const caller = appRouter.createCaller({
req: {
headers: {},
socket: { remoteAddress: "127.0.0.1" },
} as any,
res: {} as any,
user,
});
const reviewDate = "2026-06-12";
await caller.compliance.upsertSupplier({
id: "ovh-hosting",
supplierName: "OVHcloud",
service: "Hebergement VPS de production et preproduction, DNS et exposition web",
location: "Montreal, Quebec, Canada",
criticality: "critique",
dpaStatus: "pending",
reviewDate,
owner: "Referent interne a confirmer",
notes: "Hebergement observe sur OVHcloud via VPS avec volumes Docker distincts pour production et preproduction.",
});
await caller.compliance.upsertSupplier({
id: "smtp-ovh",
supplierName: "OVH Mail",
service: "SMTP transactionnel pour notifications, MFA email et messages systeme",
location: "A confirmer contractuellement",
criticality: "elevee",
dpaStatus: "pending",
reviewDate,
owner: "Referent interne a confirmer",
notes: "Configuration SMTP OVH detectee et active dans l administration du portail.",
});
await caller.compliance.upsertSupplier({
id: "google-oauth",
supplierName: "Google",
service: "Connexion OAuth Google pour comptes utilises sur le portail",
location: "A confirmer contractuellement",
criticality: "elevee",
dpaStatus: "pending",
reviewDate,
owner: "Referent interne a confirmer",
notes: "Client OAuth Google configure sur le portail.",
});
await caller.compliance.upsertSupplier({
id: "meta-oauth",
supplierName: "Meta / Facebook",
service: "Connexion OAuth Facebook pour comptes utilises sur le portail",
location: "A confirmer contractuellement",
criticality: "moyenne",
dpaStatus: "pending",
reviewDate,
owner: "Referent interne a confirmer",
notes: "Client OAuth Facebook configure sur le portail.",
});
await caller.compliance.upsertDpa({
id: "dpa-ovh-hosting",
supplierId: "ovh-hosting",
supplierName: "OVHcloud",
status: "pending",
signedAt: "",
expiresAt: "",
reviewDueAt: "2027-06-12",
owner: "Referent interne a confirmer",
notes: "DPA hebergeur a centraliser ou confirmer dans le centre de conformite.",
});
await caller.compliance.upsertDpa({
id: "dpa-smtp-ovh",
supplierId: "smtp-ovh",
supplierName: "OVH Mail",
status: "pending",
signedAt: "",
expiresAt: "",
reviewDueAt: "2027-06-12",
owner: "Referent interne a confirmer",
notes: "DPA ou clauses RGPD du service de messagerie a centraliser.",
});
await caller.compliance.upsertDpa({
id: "dpa-google-oauth",
supplierId: "google-oauth",
supplierName: "Google",
status: "pending",
signedAt: "",
expiresAt: "",
reviewDueAt: "2026-12-12",
owner: "Referent interne a confirmer",
notes: "Verifier le cadre contractuel lie a l authentification Google.",
});
await caller.compliance.upsertDpa({
id: "dpa-meta-oauth",
supplierId: "meta-oauth",
supplierName: "Meta / Facebook",
status: "pending",
signedAt: "",
expiresAt: "",
reviewDueAt: "2026-12-12",
owner: "Referent interne a confirmer",
notes: "Verifier le cadre contractuel lie a l authentification Facebook.",
});
await caller.compliance.upsertEvidence({
id: "evidence-backup-observed-vps",
category: "backup_report",
title: "Archives de sauvegarde code observees sur le VPS",
reference: "Observation infrastructure",
url: "",
description:
"Dernieres archives observees dans /home/ubuntu/backups le 2026-06-12 a 03:05 pour production et preproduction. Cette observation ne remplace pas encore une preuve complete de sauvegarde 3-2-1.",
updatedAt: "2026-06-12T03:05:00Z",
});
await caller.compliance.upsertEvidence({
id: "evidence-no-third-party-analytics",
category: "audit_report",
title: "Absence d analytics tiers detectee dans le code applicatif",
reference: "Revue technique",
url: "",
description:
"Aucun traceur tiers de type Google Analytics, Matomo, Plausible, Umami ou equivalent n a ete detecte dans le front et le serveur lors de la revue technique.",
updatedAt: new Date().toISOString(),
});
await caller.compliance.upsertEvidence({
id: "evidence-openmaptiles-healthcheck",
category: "audit_report",
title: "Diagnostic healthcheck OpenMapTiles",
reference: "Infrastructure cartographique",
url: "",
description:
"Le conteneur OpenMapTiles repond aux styles, tuiles et glyphes, mais son healthcheck /health retourne en erreur. Sujet a traiter a part du lot conformite.",
updatedAt: new Date().toISOString(),
});
const result = await caller.compliance.getOperations();
console.log(
JSON.stringify(
{
suppliers: result.suppliers.length,
dpas: result.dpas.length,
evidence: result.evidenceCenter.length,
backupRecords: result.backupRecords.length,
restoreTests: result.restoreTests.length,
},
null,
2,
),
);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View file

@ -0,0 +1,301 @@
import fs from "node:fs";
import path from "node:path";
import PDFDocument from "pdfkit";
const OUTPUT_DIR = path.resolve(process.cwd(), "exports/factures");
const OUTPUT_BASENAME = "facture-projet-ccds-portail-associations-6300-2026-06-11";
const OUTPUT_PDF = path.join(OUTPUT_DIR, `${OUTPUT_BASENAME}.pdf`);
const OUTPUT_JSON = path.join(OUTPUT_DIR, `${OUTPUT_BASENAME}.json`);
const LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
const invoice = {
status: "PROJET DE FACTURE A VERIFIER AVANT ENVOI",
invoiceNumber: "FA-CCDS-2026-06-11-6300",
issueDate: "11/06/2026",
dueDate: "A completer",
issuer: {
name: "Nom / raison sociale a completer",
addressLines: [
"Adresse a completer",
"Code postal et ville a completer",
],
siret: "SIRET a completer",
email: "Email a completer",
phone: "Telephone a completer",
vat: "Regime TVA a completer",
},
recipient: {
name: "Communaute de Communes des Savanes",
addressLines: [
"Quartier Cabalou",
"1 rue Raymond Cresson",
"97310 Kourou",
],
siret: "200 027 548 00029",
reference: "Facture adressee a la CCDS",
},
project: {
title: "Portail des associations CCDS",
url: "https://www.portail-association973.com",
},
lineItems: [
{
label: "Conception, developpement, integration et finalisation du portail web",
details: [
"Projet realise pour le portail des associations de la CCDS",
"Travail incluant structuration fonctionnelle, developpements, corrections, ajustements metier et mise en coherence generale du site",
],
quantity: "1",
unitPrice: "4 500,00 EUR",
total: "4 500,00 EUR",
},
{
label: "Deploiement, exploitation technique, optimisation continue et accompagnement a la mise en production",
details: [
"Mises a jour successives, stabilisation, finalisation des workflows, mise en place de la preproduction et du tour operationnel",
],
quantity: "1",
unitPrice: "1 500,00 EUR",
total: "1 500,00 EUR",
},
{
label: "Location / hebergement des serveurs OVH",
details: [
"Portail heberge sur infrastructure OVH",
"Periode de facturation a completer",
],
quantity: "1",
unitPrice: "300,00 EUR",
total: "300,00 EUR",
},
],
totals: {
subtotalHt: "6 300,00 EUR",
vatAmount: "A verifier selon regime",
totalTtc: "6 300,00 EUR si TVA non applicable",
},
notes: [
"Projet initie avec Manus IA puis repris et approfondi sous Codex jusqu'a un niveau de quasi finalisation.",
"La montee en competence technique a ete facilitee par William, ingenieur informatique, qui a accompagne l'usage de Manus IA et l'appropriation de termes techniques pour accelerer l'execution.",
"Par honnetete de facturation, cet accompagnement n'est pas facture ici comme ligne separee sauf accord explicite contraire.",
"Chiffrage retenu pour ce projet : 4 500,00 EUR pour la conception / developpement du portail, 1 500,00 EUR pour le deploiement / finalisation / accompagnement technique, 300,00 EUR pour l'hebergement OVH.",
"RIB / IBAN, regime TVA et modalites de reglement a joindre dans la version finale.",
],
};
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function drawLogo(doc, x, y, width, height) {
if (!fs.existsSync(LOGO_PATH)) return;
try {
doc.image(LOGO_PATH, x, y, { fit: [width, height], align: "left", valign: "center" });
} catch {
// Keep the invoice usable even if the logo cannot be drawn.
}
}
function drawMutedLabel(doc, text, x, y, width) {
doc.font("Helvetica-Bold").fontSize(9).fillColor("#64748b").text(text.toUpperCase(), x, y, { width });
}
function drawValue(doc, text, x, y, width, options = {}) {
doc.font("Helvetica").fontSize(10.5).fillColor("#0f172a").text(text, x, y, { width, ...options });
}
function drawBox(doc, x, y, width, height, fill = "#ffffff", stroke = "#cbd5e1") {
doc.save();
doc.roundedRect(x, y, width, height, 8).fillAndStroke(fill, stroke);
doc.restore();
}
function getTextHeight(doc, text, width, fontName, fontSize) {
doc.font(fontName).fontSize(fontSize);
return doc.heightOfString(text, { width });
}
function render() {
ensureDir(OUTPUT_DIR);
fs.writeFileSync(OUTPUT_JSON, JSON.stringify(invoice, null, 2));
const doc = new PDFDocument({
size: "A4",
margins: { top: 38, bottom: 42, left: 38, right: 38 },
info: {
Title: "Facture brouillon CCDS - Portail des associations",
Author: "Codex",
Subject: "Facture brouillon a completer pour la CCDS",
},
});
const chunks = [];
doc.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
const done = new Promise((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const pageLeft = doc.page.margins.left;
let y = doc.page.margins.top;
doc.save();
doc.rect(0, 0, doc.page.width, 26).fill("#fff7ed");
doc.restore();
doc.font("Helvetica-Bold").fontSize(10).fillColor("#c2410c").text(invoice.status, pageLeft, 8, {
width: pageWidth,
align: "center",
});
drawLogo(doc, pageLeft, y + 6, 88, 88);
doc.font("Helvetica-Bold").fontSize(24).fillColor("#0f172a").text("FACTURE", pageLeft + 100, y + 8, { width: 220 });
doc.font("Helvetica").fontSize(11).fillColor("#475569").text("Projet Portail des associations CCDS", pageLeft + 100, y + 40, { width: 260 });
doc.font("Helvetica-Bold").fontSize(11).fillColor("#1d4ed8").text(invoice.project.url, pageLeft + 100, y + 58, { width: 280 });
const headerBoxX = pageLeft + pageWidth - 220;
drawBox(doc, headerBoxX, y + 4, 220, 92, "#f8fafc");
drawMutedLabel(doc, "Numero", headerBoxX + 14, y + 16, 80);
drawValue(doc, invoice.invoiceNumber, headerBoxX + 14, y + 30, 192);
drawMutedLabel(doc, "Date", headerBoxX + 14, y + 52, 80);
drawValue(doc, invoice.issueDate, headerBoxX + 14, y + 66, 80);
drawMutedLabel(doc, "Echeance", headerBoxX + 112, y + 52, 80);
drawValue(doc, invoice.dueDate, headerBoxX + 112, y + 66, 94);
y += 116;
const halfGap = 16;
const halfWidth = (pageWidth - halfGap) / 2;
const leftBoxHeight = 112;
const rightBoxHeight = 112;
drawBox(doc, pageLeft, y, halfWidth, leftBoxHeight, "#ffffff");
drawBox(doc, pageLeft + halfWidth + halfGap, y, halfWidth, rightBoxHeight, "#f8fafc");
drawMutedLabel(doc, "Emetteur / prestataire", pageLeft + 14, y + 14, halfWidth - 28);
drawValue(doc, invoice.issuer.name, pageLeft + 14, y + 32, halfWidth - 28);
drawValue(doc, invoice.issuer.addressLines.join("\n"), pageLeft + 14, y + 48, halfWidth - 28);
drawValue(doc, `SIRET : ${invoice.issuer.siret}`, pageLeft + 14, y + 78, halfWidth - 28);
drawValue(doc, `${invoice.issuer.email}${invoice.issuer.phone}`, pageLeft + 14, y + 92, halfWidth - 28);
const recipientX = pageLeft + halfWidth + halfGap + 14;
drawMutedLabel(doc, "Destinataire", recipientX, y + 14, halfWidth - 28);
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text(invoice.recipient.name, recipientX, y + 32, {
width: halfWidth - 28,
});
drawValue(doc, invoice.recipient.addressLines.join("\n"), recipientX, y + 50, halfWidth - 28);
drawValue(doc, `SIRET : ${invoice.recipient.siret}`, recipientX, y + 92, halfWidth - 28);
y += 134;
drawBox(doc, pageLeft, y, pageWidth, 70, "#eff6ff", "#bfdbfe");
drawMutedLabel(doc, "Objet", pageLeft + 14, y + 12, 120);
doc.font("Helvetica-Bold").fontSize(12).fillColor("#1e3a8a").text(
"Facturation du travail realise sur le projet portail-association973.com et des frais d'hebergement OVH",
pageLeft + 14,
y + 28,
{ width: pageWidth - 28 }
);
y += 92;
doc.font("Helvetica-Bold").fontSize(12).fillColor("#0f172a").text("Lignes de facturation", pageLeft, y);
y += 18;
const columns = {
desc: pageWidth * 0.62,
qty: pageWidth * 0.08,
unit: pageWidth * 0.14,
total: pageWidth * 0.16,
};
const headerHeight = 32;
doc.save();
doc.rect(pageLeft, y, pageWidth, headerHeight).fill("#0f172a");
doc.restore();
doc.font("Helvetica-Bold").fontSize(9.5).fillColor("#ffffff");
doc.text("DESCRIPTION", pageLeft + 10, y + 11, { width: columns.desc - 20 });
doc.text("QTE", pageLeft + columns.desc, y + 11, { width: columns.qty, align: "center" });
doc.text("PRIX", pageLeft + columns.desc + columns.qty, y + 11, { width: columns.unit, align: "center" });
doc.text("TOTAL", pageLeft + columns.desc + columns.qty + columns.unit, y + 11, { width: columns.total, align: "center" });
y += headerHeight;
for (const item of invoice.lineItems) {
const detailsText = item.details.map((detail) => `- ${detail}`).join("\n");
const contentWidth = columns.desc - 20;
const labelHeight = getTextHeight(doc, item.label, contentWidth, "Helvetica-Bold", 10.5);
const detailsHeight = getTextHeight(doc, detailsText, contentWidth, "Helvetica", 9.5);
const detailsY = y + 12 + labelHeight + 6;
const descHeight = 12 + labelHeight + 6 + detailsHeight + 14;
const rowHeight = Math.max(78, descHeight);
[0, columns.desc, columns.desc + columns.qty, columns.desc + columns.qty + columns.unit].forEach((offset, index) => {
const width = index === 0
? columns.desc
: index === 1
? columns.qty
: index === 2
? columns.unit
: columns.total;
doc.rect(pageLeft + offset, y, width, rowHeight).stroke("#cbd5e1");
});
doc.font("Helvetica-Bold").fontSize(10.5).fillColor("#0f172a").text(item.label, pageLeft + 10, y + 10, {
width: contentWidth,
});
doc.font("Helvetica").fontSize(9.5).fillColor("#475569").text(detailsText, pageLeft + 10, detailsY, {
width: contentWidth,
});
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a")
.text(item.quantity, pageLeft + columns.desc, y + rowHeight / 2 - 7, { width: columns.qty, align: "center" })
.text(item.unitPrice, pageLeft + columns.desc + columns.qty + 4, y + rowHeight / 2 - 7, { width: columns.unit - 8, align: "center" })
.text(item.total, pageLeft + columns.desc + columns.qty + columns.unit + 4, y + rowHeight / 2 - 7, { width: columns.total - 8, align: "center" });
y += rowHeight;
}
y += 16;
const totalsBoxWidth = 220;
const totalsBoxX = pageLeft + pageWidth - totalsBoxWidth;
drawBox(doc, totalsBoxX, y, totalsBoxWidth, 92, "#f8fafc");
const totalRows = [
["Sous-total HT", invoice.totals.subtotalHt],
["TVA", invoice.totals.vatAmount],
["Total TTC", invoice.totals.totalTtc],
];
let totalY = y + 16;
totalRows.forEach(([label, value], index) => {
doc.font(index === 2 ? "Helvetica-Bold" : "Helvetica").fontSize(index === 2 ? 11.5 : 10).fillColor("#0f172a");
doc.text(label, totalsBoxX + 14, totalY, { width: 100 });
doc.text(value, totalsBoxX + 110, totalY, { width: 96, align: "right" });
totalY += 24;
});
y += 112;
drawBox(doc, pageLeft, y, pageWidth, 138, "#fff7ed", "#fdba74");
drawMutedLabel(doc, "Note de contexte et d'honnetete", pageLeft + 14, y + 14, pageWidth - 28);
doc.font("Helvetica").fontSize(10).fillColor("#7c2d12").text(
invoice.notes.join("\n\n"),
pageLeft + 14,
y + 32,
{ width: pageWidth - 28 }
);
y += 156;
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text(
"Document de travail genere pour preparation d'une facture finale. Les montants, coordonnees de l'emetteur, regime TVA et modalites de reglement doivent etre verifies avant envoi.",
pageLeft,
y,
{ width: pageWidth, align: "center" }
);
doc.end();
return done.then((buffer) => {
fs.writeFileSync(OUTPUT_PDF, buffer);
return { pdf: OUTPUT_PDF, json: OUTPUT_JSON };
});
}
render()
.then(({ pdf, json }) => {
process.stdout.write(JSON.stringify({ ok: true, pdf, json }, null, 2));
})
.catch((error) => {
process.stderr.write(String(error?.stack || error));
process.exitCode = 1;
});

View file

@ -0,0 +1,242 @@
import fs from "node:fs";
import path from "node:path";
import PDFDocument from "pdfkit";
const OUTPUT_DIR = path.resolve(process.cwd(), "exports/factures");
const OUTPUT_BASENAME = "facture-honnete-ccds-portail-ovh-2026-06-11";
const OUTPUT_PDF = path.join(OUTPUT_DIR, `${OUTPUT_BASENAME}.pdf`);
const OUTPUT_JSON = path.join(OUTPUT_DIR, `${OUTPUT_BASENAME}.json`);
const LOGO_PATH = path.resolve(process.cwd(), "client/src/assets/ccds.png");
const invoice = {
status: "FACTURE A COMPLETER AVANT ENVOI",
invoiceNumber: "FA-CCDS-2026-06-11-HONNETE",
issueDate: "11/06/2026",
dueDate: "A completer",
issuer: {
name: "Nom / raison sociale a completer",
addressLines: ["Adresse a completer", "Code postal et ville a completer"],
siret: "SIRET a completer",
email: "Email a completer",
phone: "Telephone a completer",
vat: "Regime TVA a completer",
},
recipient: {
name: "Communaute de Communes des Savanes",
addressLines: ["Quartier Cabalou", "1 rue Raymond Cresson", "97310 Kourou"],
siret: "200 027 548 00029",
},
project: {
title: "Portail des associations CCDS",
url: "https://www.portail-association973.com",
},
lineItems: [
{
label: "Realisation et finalisation du portail des associations CCDS",
details: [
"Conception, developpement, integration, corrections et mise en coherence generale",
"Projet concerne : https://www.portail-association973.com",
],
quantity: "1",
unitPrice: "6 000,00 EUR",
total: "6 000,00 EUR",
},
{
label: "Location / hebergement des serveurs OVH",
details: [
"Infrastructure OVH utilisee pour le projet",
"Periode de facturation a completer",
],
quantity: "1",
unitPrice: "300,00 EUR",
total: "300,00 EUR",
},
],
totals: {
subtotalHt: "6 300,00 EUR",
vatAmount: "A verifier selon regime",
totalTtc: "6 300,00 EUR si TVA non applicable",
},
notes: [
"Facture volontairement sobre et centree sur le travail reellement fourni pour le portail et l'hebergement OVH.",
"RIB / IBAN, regime TVA, echeance et periode exacte OVH a completer avant envoi.",
],
};
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function drawLogo(doc, x, y, width, height) {
if (!fs.existsSync(LOGO_PATH)) return;
try {
doc.image(LOGO_PATH, x, y, { fit: [width, height], align: "left", valign: "center" });
} catch {}
}
function drawBox(doc, x, y, width, height, fill = "#ffffff", stroke = "#cbd5e1") {
doc.save();
doc.roundedRect(x, y, width, height, 8).fillAndStroke(fill, stroke);
doc.restore();
}
function label(doc, text, x, y, width) {
doc.font("Helvetica-Bold").fontSize(9).fillColor("#64748b").text(text.toUpperCase(), x, y, { width });
}
function value(doc, text, x, y, width, options = {}) {
doc.font("Helvetica").fontSize(10.5).fillColor("#0f172a").text(text, x, y, { width, ...options });
}
function heightOf(doc, text, width, fontName, fontSize) {
doc.font(fontName).fontSize(fontSize);
return doc.heightOfString(text, { width });
}
async function render() {
ensureDir(OUTPUT_DIR);
fs.writeFileSync(OUTPUT_JSON, JSON.stringify(invoice, null, 2));
const doc = new PDFDocument({
size: "A4",
margins: { top: 38, bottom: 42, left: 38, right: 38 },
info: {
Title: "Facture honnête CCDS - portail et OVH",
Author: "Codex",
Subject: "Facture a completer pour la CCDS",
},
});
const chunks = [];
doc.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
const done = new Promise((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const left = doc.page.margins.left;
let y = doc.page.margins.top;
doc.save();
doc.rect(0, 0, doc.page.width, 26).fill("#fff7ed");
doc.restore();
doc.font("Helvetica-Bold").fontSize(10).fillColor("#c2410c").text(invoice.status, left, 8, { width: pageWidth, align: "center" });
drawLogo(doc, left, y + 8, 80, 80);
doc.font("Helvetica-Bold").fontSize(24).fillColor("#0f172a").text("FACTURE", left + 96, y + 8, { width: 180 });
doc.font("Helvetica").fontSize(11).fillColor("#475569").text(invoice.project.title, left + 96, y + 40, { width: 240 });
doc.font("Helvetica-Bold").fontSize(11).fillColor("#1d4ed8").text(invoice.project.url, left + 96, y + 58, { width: 280 });
const rightX = left + pageWidth - 220;
drawBox(doc, rightX, y + 4, 220, 92, "#f8fafc");
label(doc, "Numero", rightX + 14, y + 16, 80);
value(doc, invoice.invoiceNumber, rightX + 14, y + 30, 190);
label(doc, "Date", rightX + 14, y + 52, 80);
value(doc, invoice.issueDate, rightX + 14, y + 66, 70);
label(doc, "Echeance", rightX + 112, y + 52, 80);
value(doc, invoice.dueDate, rightX + 112, y + 66, 94);
y += 116;
const gap = 16;
const half = (pageWidth - gap) / 2;
drawBox(doc, left, y, half, 112);
drawBox(doc, left + half + gap, y, half, 112, "#f8fafc");
label(doc, "Emetteur / prestataire", left + 14, y + 14, half - 28);
value(doc, invoice.issuer.name, left + 14, y + 32, half - 28);
value(doc, invoice.issuer.addressLines.join("\n"), left + 14, y + 48, half - 28);
value(doc, `SIRET : ${invoice.issuer.siret}`, left + 14, y + 78, half - 28);
value(doc, `${invoice.issuer.email}${invoice.issuer.phone}`, left + 14, y + 92, half - 28);
const dx = left + half + gap + 14;
label(doc, "Destinataire", dx, y + 14, half - 28);
doc.font("Helvetica-Bold").fontSize(11).fillColor("#0f172a").text(invoice.recipient.name, dx, y + 32, { width: half - 28 });
value(doc, invoice.recipient.addressLines.join("\n"), dx, y + 50, half - 28);
value(doc, `SIRET : ${invoice.recipient.siret}`, dx, y + 92, half - 28);
y += 134;
drawBox(doc, left, y, pageWidth, 64, "#eff6ff", "#bfdbfe");
label(doc, "Objet", left + 14, y + 12, 120);
doc.font("Helvetica-Bold").fontSize(12).fillColor("#1e3a8a").text(
"Facturation du portail des associations CCDS et de la location des serveurs OVH",
left + 14,
y + 28,
{ width: pageWidth - 28 }
);
y += 88;
doc.font("Helvetica-Bold").fontSize(12).fillColor("#0f172a").text("Lignes de facturation", left, y);
y += 18;
const cols = { desc: pageWidth * 0.64, qty: pageWidth * 0.08, unit: pageWidth * 0.13, total: pageWidth * 0.15 };
doc.save();
doc.rect(left, y, pageWidth, 32).fill("#0f172a");
doc.restore();
doc.font("Helvetica-Bold").fontSize(9.5).fillColor("#ffffff");
doc.text("DESCRIPTION", left + 10, y + 11, { width: cols.desc - 20 });
doc.text("QTE", left + cols.desc, y + 11, { width: cols.qty, align: "center" });
doc.text("PRIX", left + cols.desc + cols.qty, y + 11, { width: cols.unit, align: "center" });
doc.text("TOTAL", left + cols.desc + cols.qty + cols.unit, y + 11, { width: cols.total, align: "center" });
y += 32;
for (const item of invoice.lineItems) {
const details = item.details.map((detail) => `- ${detail}`).join("\n");
const width = cols.desc - 20;
const titleH = heightOf(doc, item.label, width, "Helvetica-Bold", 10.5);
const detailsH = heightOf(doc, details, width, "Helvetica", 9.5);
const detailsY = y + 12 + titleH + 6;
const rowH = Math.max(78, 12 + titleH + 6 + detailsH + 14);
[0, cols.desc, cols.desc + cols.qty, cols.desc + cols.qty + cols.unit].forEach((offset, index) => {
const w = index === 0 ? cols.desc : index === 1 ? cols.qty : index === 2 ? cols.unit : cols.total;
doc.rect(left + offset, y, w, rowH).stroke("#cbd5e1");
});
doc.font("Helvetica-Bold").fontSize(10.5).fillColor("#0f172a").text(item.label, left + 10, y + 10, { width });
doc.font("Helvetica").fontSize(9.5).fillColor("#475569").text(details, left + 10, detailsY, { width });
doc.font("Helvetica-Bold").fontSize(10).fillColor("#0f172a")
.text(item.quantity, left + cols.desc, y + rowH / 2 - 7, { width: cols.qty, align: "center" })
.text(item.unitPrice, left + cols.desc + cols.qty + 4, y + rowH / 2 - 7, { width: cols.unit - 8, align: "center" })
.text(item.total, left + cols.desc + cols.qty + cols.unit + 4, y + rowH / 2 - 7, { width: cols.total - 8, align: "center" });
y += rowH;
}
y += 16;
const totalsW = 220;
const totalsX = left + pageWidth - totalsW;
drawBox(doc, totalsX, y, totalsW, 92, "#f8fafc");
[
["Sous-total HT", invoice.totals.subtotalHt],
["TVA", invoice.totals.vatAmount],
["Total TTC", invoice.totals.totalTtc],
].forEach(([l, v], i) => {
const ty = y + 16 + i * 24;
doc.font(i === 2 ? "Helvetica-Bold" : "Helvetica").fontSize(i === 2 ? 11.5 : 10).fillColor("#0f172a");
doc.text(l, totalsX + 14, ty, { width: 100 });
doc.text(v, totalsX + 110, ty, { width: 96, align: "right" });
});
y += 112;
drawBox(doc, left, y, pageWidth, 88, "#fff7ed", "#fdba74");
label(doc, "Notes", left + 14, y + 14, 100);
doc.font("Helvetica").fontSize(10).fillColor("#7c2d12").text(invoice.notes.join("\n\n"), left + 14, y + 32, { width: pageWidth - 28 });
y += 110;
doc.font("Helvetica").fontSize(9).fillColor("#64748b").text(
"Version preparee pour la CCDS. Verifier avant envoi les informations emetteur, la TVA, l'echeance et le RIB.",
left,
y,
{ width: pageWidth, align: "center" }
);
doc.end();
const buffer = await done;
fs.writeFileSync(OUTPUT_PDF, buffer);
process.stdout.write(JSON.stringify({ ok: true, pdf: OUTPUT_PDF, json: OUTPUT_JSON }, null, 2));
}
render().catch((error) => {
process.stderr.write(String(error?.stack || error));
process.exit(1);
});

30
scripts/maintenance-flag.sh Executable file
View file

@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET="${1:-status}"
FLAG_PATH="${MAINTENANCE_FLAG_PATH:-$(pwd)/uploads/system/maintenance.flag}"
mkdir -p "$(dirname "$FLAG_PATH")"
case "$TARGET" in
on|enable)
touch "$FLAG_PATH"
echo "maintenance=on ($FLAG_PATH)"
;;
off|disable)
rm -f "$FLAG_PATH"
echo "maintenance=off ($FLAG_PATH)"
;;
status)
if [[ -f "$FLAG_PATH" ]]; then
echo "maintenance=on ($FLAG_PATH)"
else
echo "maintenance=off ($FLAG_PATH)"
fi
;;
*)
echo "Usage: ./scripts/maintenance-flag.sh [on|off|status]" >&2
exit 1
;;
esac

29
scripts/monitor-http-health.sh Executable file
View file

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
URL="${1:-http://localhost:3000/health/ready}"
COUNT="${COUNT:-12}"
SLEEP_SECONDS="${SLEEP_SECONDS:-5}"
ok_count=0
warn_count=0
for ((i=1; i<=COUNT; i++)); do
code="$(curl -s -o /dev/null -w "%{http_code}" "$URL" || true)"
printf '[%02d/%02d] %s -> %s\n' "$i" "$COUNT" "$URL" "$code"
if [[ "$code" == "200" ]]; then
ok_count=$((ok_count + 1))
elif [[ "$code" == "502" || "$code" == "503" ]]; then
warn_count=$((warn_count + 1))
fi
if [[ "$i" -lt "$COUNT" ]]; then
sleep "$SLEEP_SECONDS"
fi
done
printf '\nOK=%d WARN_502_503=%d TOTAL=%d\n' "$ok_count" "$warn_count" "$COUNT"
if [[ "$warn_count" -gt 0 ]]; then
exit 1
fi

View file

@ -0,0 +1,124 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import { nanoid } from 'nanoid';
dotenv.config();
const FORGE_API_URL = process.env.BUILT_IN_FORGE_API_URL;
const FORGE_API_KEY = process.env.BUILT_IN_FORGE_API_KEY;
const BASE_URL = 'https://assoportail-eagcgspx.manus.space';
async function main() {
const conn = await mysql.createConnection(process.env.DATABASE_URL);
// 1. Submit request #2 (change status from brouillon to soumise)
console.log('📝 Soumission de la demande #2...');
await conn.execute(
`UPDATE requests SET status = 'soumise', dateSubmission = NOW() WHERE id = 2`
);
console.log(' ✅ Demande #2 soumise avec succès');
// 2. Get request details
const [requests] = await conn.execute(
`SELECT r.id, r.titre, r.type, r.status, r.montantDemande, r.description, r.dateSubmission,
a.nomAssociation, a.emailContact, a.ville
FROM requests r
LEFT JOIN associations a ON r.associationId = a.id
WHERE r.id = 2`
);
const req = requests[0];
console.log(`\n📨 Envoi de la notification pour: "${req.titre}"`);
const typeLabels = {
subvention_fonctionnement: 'Subvention de fonctionnement',
subvention_projet: 'Subvention de projet',
agrement_jeunesse_education: 'Agrément Jeunesse et Éducation Populaire',
agrement_sport: 'Agrément Sport',
autorisation_occupation: "Autorisation d'occupation",
demande_salle: 'Demande de salle',
autre: 'Autre demande',
};
// 3. Generate tokens
const validateToken = nanoid(48);
const refuseToken = nanoid(48);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await conn.execute(
`INSERT INTO emailActionTokens (token, requestId, action, expiresAt, createdAt) VALUES (?, ?, 'validee', ?, NOW())`,
[validateToken, req.id, expiresAt]
);
await conn.execute(
`INSERT INTO emailActionTokens (token, requestId, action, expiresAt, createdAt) VALUES (?, ?, 'refusee', ?, NOW())`,
[refuseToken, req.id, expiresAt]
);
console.log(` 🔑 Tokens créés (valides jusqu'au ${expiresAt.toLocaleDateString('fr-FR')})`);
// 4. Build URLs
const validateUrl = `${BASE_URL}/api/email-action/${validateToken}`;
const refuseUrl = `${BASE_URL}/api/email-action/${refuseToken}`;
const viewUrl = `${BASE_URL}/dashboard/requests/${req.id}`;
const typeLabel = typeLabels[req.type] || req.type;
// 5. Send notification
const title = `📨 Nouvelle demande: ${req.titre}`;
const content = `**Association:** ${req.nomAssociation || 'Non renseignée'}
**Ville:** ${req.ville || 'Non renseignée'}
**Type:** ${typeLabel}
**Description:** ${req.description || 'Non spécifiée'}
---
### Actions rapides
[**VALIDER la demande**](${validateUrl})
[**REFUSER la demande**](${refuseUrl})
🔍 [**Voir le détail**](${viewUrl})
---
_Ces liens sont valides pendant 7 jours (jusqu'au ${expiresAt.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })})._`;
const endpoint = FORGE_API_URL.endsWith('/')
? `${FORGE_API_URL}webdevtoken.v1.WebDevService/SendNotification`
: `${FORGE_API_URL}/webdevtoken.v1.WebDevService/SendNotification`;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'accept': 'application/json',
'authorization': `Bearer ${FORGE_API_KEY}`,
'content-type': 'application/json',
'connect-protocol-version': '1',
},
body: JSON.stringify({ title, content }),
});
if (response.ok) {
console.log(` ✅ Notification envoyée avec succès !`);
console.log(`\n📋 Récapitulatif:`);
console.log(` - Demande: ${req.titre}`);
console.log(` - Association: ${req.nomAssociation}`);
console.log(` - Type: ${typeLabel}`);
console.log(` - Lien Valider: ${validateUrl}`);
console.log(` - Lien Refuser: ${refuseUrl}`);
console.log(` - Lien Voir: ${viewUrl}`);
} else {
const detail = await response.text();
console.error(` ❌ Échec: ${response.status} - ${detail}`);
}
} catch (error) {
console.error(` ❌ Erreur:`, error.message);
}
await conn.end();
console.log('\n✅ Terminé !');
}
main().catch(console.error);