Initial local backup snapshot
This commit is contained in:
commit
acd9e14ba5
367 changed files with 118038 additions and 0 deletions
24
scripts/check-openmaptiles-vps.sh
Executable file
24
scripts/check-openmaptiles-vps.sh
Executable 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
179
scripts/deploy-vps.sh
Executable 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é"
|
||||
171
scripts/fill_compliance_observed.mts
Normal file
171
scripts/fill_compliance_observed.mts
Normal 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);
|
||||
});
|
||||
301
scripts/generate_ccds_invoice_draft.mjs
Normal file
301
scripts/generate_ccds_invoice_draft.mjs
Normal 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;
|
||||
});
|
||||
242
scripts/generate_ccds_invoice_simple.mjs
Normal file
242
scripts/generate_ccds_invoice_simple.mjs
Normal 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
30
scripts/maintenance-flag.sh
Executable 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
29
scripts/monitor-http-health.sh
Executable 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
|
||||
124
scripts/submit-and-notify.mjs
Normal file
124
scripts/submit-and-notify.mjs
Normal 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue