Files
CollectFlow/scripts/db-init.js
T
Claude e79d7e51da feat(api): API CollectFlow /api/v1 — lecture de la grille et recherche
Les données de la grille (ventes 12 mois, stock, marges, gammes, métriques
réseau Qlik) n'étaient accessibles par aucun moyen programmatique, et
getProductRows() exige un fournisseur : chercher un produit sans le connaître
était impossible.

Contrainte de conception : l'API ne recalcule jamais rien. La grille était
reconstruite en direct et gardée seulement 10 min en mémoire ; une API qui
appellerait getProductRows() serait lente et imprévisible. On persiste donc
le résultat d'un calcul qui a déjà lieu, et on le sert.

- Table grid_rows : colonnes scalaires (filtre/tri/recherche en SQL) +
  payload jsonb du ProductRow complet. Remplie en effet de bord NON bloquant
  par getProductRows(), purge des articles disparus via computed_at. Survit
  aux redémarrages, contrairement au cache mémoire.
- Endpoints /api/v1 : fournisseurs, grid, products/search (transversale, tous
  fournisseurs), products/:codein, network/:codeCentrale, openapi.json.
  Pagination, tri sur liste blanche, projection de champs, validation zod.
  202 not_ready si un fournisseur n'a pas encore d'instantané.
- Authentification double : clé d'API (X-API-Key ou Bearer, SHA-256 en base,
  révocable) ou session existante. Le middleware exempte /api/v1 — sans quoi
  un script recevait une redirection 302 vers /login au lieu d'un 401 JSON.
- Gestion des clés dans /settings (server actions, clé affichée une seule fois).

Vérifié contre une base PostgreSQL locale : 401 JSON sans clé, 401 sur clé
révoquée, recherche renvoyant plusieurs fournisseurs, upsert + purge, et
24 appels /api/v1 sans déclencher un seul recalcul (l'ancienne route
/api/grid/rows en déclenche un à chaque appel).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y26nRZxTR57K7h8yqsF675
2026-08-05 10:14:21 +00:00

189 lines
8.0 KiB
JavaScript

const { Pool } = require('pg');
const fs = require('fs');
const path = require('path');
const cwd = path.resolve(__dirname, '..'); // get app root when inside /scripts
async function main() {
console.log("[DB Init] Starting database migration & initialization...");
let connectionString = "";
try {
const CONFIG_PATH = path.join(cwd, "data", ".db-config.json");
if (fs.existsSync(CONFIG_PATH)) {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
if (config.url) {
connectionString = config.url;
console.log("[DB Init] Found connection string in data/.db-config.json");
}
}
} catch (e) {
console.error("[DB Init] Error reading .db-config.json", e);
}
// Use environment variable as default IF NOT already loaded from config
if (!connectionString && process.env.DATABASE_URL) {
connectionString = process.env.DATABASE_URL;
console.log("[DB Init] Using environment variable DATABASE_URL as fallback.");
}
if (!connectionString) {
console.warn("[DB Init] No DATABASE_URL found in env or config. Skipping push.");
process.exit(0);
}
try {
const tempPool = new Pool({ connectionString, max: 1 });
console.log("[DB Init] Connection established. Running migrations via raw SQL...");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "ai_supplier_context" (
"code_fournisseur" varchar(20) PRIMARY KEY NOT NULL,
"context" text NOT NULL,
"updated_at" timestamp DEFAULT now()
);
`);
console.log("[DB Init] Table ai_supplier_context is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "session_snapshots" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer,
"code_fournisseur" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"magasin" varchar(20) NOT NULL,
"changes" jsonb NOT NULL,
"summary_json" jsonb,
"label" text,
"type" varchar(20) DEFAULT 'snapshot',
"created_at" timestamp DEFAULT now()
);
`);
console.log("[DB Init] Table session_snapshots is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "users" (
"id" serial PRIMARY KEY NOT NULL,
"username" varchar(50) NOT NULL UNIQUE,
"password_hash" text NOT NULL,
"role" varchar(20) NOT NULL DEFAULT 'user',
"created_at" timestamp DEFAULT now()
);
`);
console.log("[DB Init] Table users is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "commande_cadences" (
"id" serial PRIMARY KEY NOT NULL,
"code_fournisseur" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"site" varchar(20) NOT NULL,
"intervalle_semaines" integer NOT NULL,
"actif" boolean NOT NULL DEFAULT true,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
);
`);
await tempPool.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "uq_cadence_fou_site"
ON "commande_cadences" ("code_fournisseur", "site");
`);
await tempPool.query(`
CREATE INDEX IF NOT EXISTS "idx_cadence_site"
ON "commande_cadences" ("site");
`);
console.log("[DB Init] Table commande_cadences is verified/created.");
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "qlik_network_metrics" (
"code_centrale" varchar(20) PRIMARY KEY NOT NULL,
"ca_reseau" numeric(16, 2),
"qte_reseau" numeric(14, 2),
"nb_magasins_reseau" integer,
"ca_par_magasin_reseau" numeric(16, 2),
"marge_pct_reseau" numeric(8, 4),
"periode" varchar(20),
"fetched_at" timestamp DEFAULT now()
);
`);
// Colonnes ajoutées après coup (DB existante) — CREATE IF NOT EXISTS ne les pose pas.
await tempPool.query(`
ALTER TABLE "qlik_network_metrics"
ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2),
ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4),
ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb;
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau";
ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau";
`);
console.log("[DB Init] Table qlik_network_metrics is verified/created.");
// Instantané persisté de la grille : lu par /api/v1 pour ne jamais
// déclencher de recalcul. Rempli en effet de bord par getProductRows().
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "grid_rows" (
"code_fournisseur" varchar(20) NOT NULL,
"codein" varchar(20) NOT NULL,
"nom_fournisseur" varchar(255),
"libelle1" varchar(500),
"gtin" varchar(30),
"reference" varchar(100),
"code_centrale" varchar(20),
"code1" varchar(20),
"code2" varchar(20),
"code3" varchar(20),
"code_gamme" varchar(20),
"code_gamme_init" varchar(20),
"total_ca" numeric(16, 2),
"total_quantite" numeric(14, 2),
"total_marge" numeric(16, 2),
"taux_marge" numeric(8, 4),
"stock_actuel" numeric(14, 2),
"ca_reseau" numeric(16, 2),
"qte_reseau" numeric(14, 2),
"nb_magasins_reseau" integer,
"ca_par_magasin_reseau" numeric(16, 2),
"marge_pct_reseau" numeric(8, 4),
"payload" jsonb NOT NULL,
"computed_at" timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein"
ON "grid_rows" ("code_fournisseur", "codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca");
CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1");
`);
console.log("[DB Init] Table grid_rows is verified/created.");
// Clés d'API : seul le hachage SHA-256 est stocké.
await tempPool.query(`
CREATE TABLE IF NOT EXISTS "api_keys" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"key_prefix" varchar(20) NOT NULL,
"key_hash" text NOT NULL UNIQUE,
"role" varchar(20) DEFAULT 'user' NOT NULL,
"created_by" varchar(50),
"created_at" timestamp DEFAULT now(),
"last_used_at" timestamp,
"revoked_at" timestamp
);
`);
console.log("[DB Init] Table api_keys is verified/created.");
await tempPool.end();
console.log("[DB Init] Initialization successful. Exiting.");
process.exit(0);
} catch (error) {
console.error("[DB Init] Initialization failed:", error);
// Do not crash the app, just let it run (it uses fallback JSON anyway)
process.exit(0);
}
}
main();