v1.0
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* AI Design Assistant routes
|
||||
* - POST /api/ai/design-tips → contextual design advice
|
||||
* - POST /api/ai/recommend-materials → material/color recommendations
|
||||
* - POST /api/ai/render → DALL-E 3 photorealistic render
|
||||
*/
|
||||
import { getDesignTip, recommendMaterials, generateRender } from '../services/designAdvisor.js';
|
||||
|
||||
/**
|
||||
* POST /api/ai/design-tips
|
||||
* Body: { config: { width, height, depth, columns, materialName, ... } }
|
||||
*/
|
||||
export async function designTipsRoute(req, res) {
|
||||
try {
|
||||
const { config } = req.body;
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Missing config in body' });
|
||||
}
|
||||
const result = await getDesignTip(config);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[AI design-tips]', err.message);
|
||||
if (err.message.includes('OPENAI_API_KEY')) {
|
||||
return res.status(503).json({ error: 'AI service not configured' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to generate design tip' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/recommend-materials
|
||||
* Body: { config, finishes: [{ id, name, collection }], roomAnalysis? }
|
||||
*/
|
||||
export async function recommendMaterialsRoute(req, res) {
|
||||
try {
|
||||
const { config, finishes, roomAnalysis } = req.body;
|
||||
if (!config || !finishes) {
|
||||
return res.status(400).json({ error: 'Missing config or finishes in body' });
|
||||
}
|
||||
const result = await recommendMaterials(config, finishes, roomAnalysis);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[AI recommend-materials]', err.message);
|
||||
if (err.message.includes('OPENAI_API_KEY')) {
|
||||
return res.status(503).json({ error: 'AI service not configured' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to generate recommendations' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/render
|
||||
* Body: { config, roomContext? }
|
||||
*/
|
||||
export async function renderRoute(req, res) {
|
||||
try {
|
||||
const { config, roomContext } = req.body;
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Missing config in body' });
|
||||
}
|
||||
const result = await generateRender(config, roomContext);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[AI render]', err.message);
|
||||
if (err.message.includes('OPENAI_API_KEY')) {
|
||||
return res.status(503).json({ error: 'AI service not configured' });
|
||||
}
|
||||
if (err.message.includes('billing') || err.message.includes('quota')) {
|
||||
return res.status(402).json({ error: 'AI quota exceeded' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to generate render' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import pool from '../db/index.js';
|
||||
|
||||
export async function getBlockTypes(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, code, label, base_price_chf, width_min_mm, width_max_mm FROM block_types WHERE active = true ORDER BY id'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import pool from '../db/index.js';
|
||||
|
||||
/**
|
||||
* GET /api/finishes
|
||||
* Returns active Formex materials as finishes for the configurator
|
||||
* Falls back to legacy finishes if no Formex materials exist
|
||||
*/
|
||||
export async function getFinishes(req, res) {
|
||||
try {
|
||||
// Check if we have active Formex materials
|
||||
const formexResult = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM formex_materials WHERE active = 1`
|
||||
);
|
||||
const hasFormexMaterials = (formexResult.rows[0]?.count || 0) > 0;
|
||||
|
||||
if (hasFormexMaterials) {
|
||||
// Return Formex materials as finishes
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
formex_code as code,
|
||||
name as label,
|
||||
name,
|
||||
collection,
|
||||
category,
|
||||
width_mm,
|
||||
height_mm,
|
||||
thickness_mm,
|
||||
image_url,
|
||||
formex_url,
|
||||
price_chf,
|
||||
dominant_color as hexColor,
|
||||
'formex' as source
|
||||
FROM formex_materials
|
||||
WHERE active = 1
|
||||
ORDER BY collection, name
|
||||
`);
|
||||
|
||||
// For each material, get its dimension variants
|
||||
for (const material of rows) {
|
||||
const dims = await pool.query(
|
||||
`SELECT id, width_mm, height_mm, thickness_mm, is_default
|
||||
FROM formex_dimensions WHERE material_id = ?
|
||||
ORDER BY is_default DESC, width_mm`,
|
||||
[material.id]
|
||||
);
|
||||
material.dimensions = dims.rows;
|
||||
}
|
||||
|
||||
res.json(rows);
|
||||
} else {
|
||||
// Fallback to legacy finishes
|
||||
const { rows } = await pool.query(`
|
||||
SELECT f.id, f.code, f.label, f.panel_format_id,
|
||||
pf.width_mm, pf.height_mm, pf.price_chf,
|
||||
'legacy' as source
|
||||
FROM finishes f
|
||||
JOIN panel_formats pf ON pf.id = f.panel_format_id
|
||||
WHERE f.active = 1
|
||||
ORDER BY f.id
|
||||
`);
|
||||
res.json(rows);
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Layout optimization routes
|
||||
*/
|
||||
import { optimizeLayout, quickOptimize, suggestInitialLayout } from '../services/layoutOptimizer.js';
|
||||
import pool from '../db/index.js';
|
||||
|
||||
/**
|
||||
* POST /api/layout/optimize
|
||||
* Optimize module layout using AI
|
||||
*/
|
||||
export async function optimizeLayoutRoute(req, res) {
|
||||
try {
|
||||
const { config, lockedModuleIds = [] } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
// Fetch available modules from catalog
|
||||
const { rows: availableModules } = await pool.query(
|
||||
'SELECT * FROM modules WHERE active = 1 ORDER BY category, width_mm'
|
||||
);
|
||||
|
||||
if (availableModules.length === 0) {
|
||||
return res.status(400).json({ error: 'Aucun module disponible dans le catalogue' });
|
||||
}
|
||||
|
||||
const result = await optimizeLayout(config, availableModules, lockedModuleIds);
|
||||
res.json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Layout optimization error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur d\'optimisation' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/layout/quick-optimize
|
||||
* Quick local optimization without AI
|
||||
*/
|
||||
export async function quickOptimizeRoute(req, res) {
|
||||
try {
|
||||
const { config, lockedModuleIds = [] } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
const result = quickOptimize(config, lockedModuleIds);
|
||||
res.json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Quick optimization error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur d\'optimisation' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/layout/suggest?width=2400
|
||||
* Suggest initial layout for given width
|
||||
*/
|
||||
export async function suggestLayoutRoute(req, res) {
|
||||
try {
|
||||
const totalWidth = parseInt(req.query.width) || 2400;
|
||||
|
||||
// Fetch available modules
|
||||
const { rows: availableModules } = await pool.query(
|
||||
'SELECT * FROM modules WHERE active = 1 ORDER BY category, width_mm'
|
||||
);
|
||||
|
||||
if (availableModules.length === 0) {
|
||||
return res.status(400).json({ error: 'Aucun module disponible' });
|
||||
}
|
||||
|
||||
const suggestions = suggestInitialLayout(totalWidth, availableModules);
|
||||
res.json({ success: true, modules: suggestions, totalWidth });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Layout suggestion error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur de suggestion' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Modules catalog and hardware routes
|
||||
*/
|
||||
import pool from '../db/index.js';
|
||||
|
||||
/**
|
||||
* GET /api/modules
|
||||
* Get all modules from catalog
|
||||
*/
|
||||
export async function getModules(req, res) {
|
||||
try {
|
||||
const { category, active } = req.query;
|
||||
|
||||
let sql = 'SELECT * FROM modules WHERE 1=1';
|
||||
const params = [];
|
||||
|
||||
if (category) {
|
||||
sql += ' AND category = ?';
|
||||
params.push(category);
|
||||
}
|
||||
|
||||
if (active !== undefined) {
|
||||
sql += ' AND active = ?';
|
||||
params.push(active === 'true' || active === '1' ? 1 : 0);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY category, width_mm, name';
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
res.json(rows);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get modules error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/modules/:id
|
||||
* Get single module by ID
|
||||
*/
|
||||
export async function getModule(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { rows } = await pool.query('SELECT * FROM modules WHERE id = ?', [id]);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Module non trouvé' });
|
||||
}
|
||||
|
||||
res.json(rows[0]);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get module error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/modules/categories
|
||||
* Get distinct module categories
|
||||
*/
|
||||
export async function getModuleCategories(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT category FROM modules WHERE active = 1 ORDER BY category'
|
||||
);
|
||||
res.json(rows.map(r => r.category));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get categories error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/hardware
|
||||
* Get all Blum hardware
|
||||
*/
|
||||
export async function getHardware(req, res) {
|
||||
try {
|
||||
const { type } = req.query;
|
||||
|
||||
let sql = 'SELECT * FROM blum_hardware WHERE active = 1';
|
||||
const params = [];
|
||||
|
||||
if (type) {
|
||||
sql += ' AND type = ?';
|
||||
params.push(type);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY type, name';
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
|
||||
// Parse specs_json for each item
|
||||
const hardware = rows.map(h => ({
|
||||
...h,
|
||||
specs: h.specs_json ? JSON.parse(h.specs_json) : null
|
||||
}));
|
||||
|
||||
res.json(hardware);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get hardware error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/hardware/types
|
||||
* Get distinct hardware types
|
||||
*/
|
||||
export async function getHardwareTypes(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT type FROM blum_hardware WHERE active = 1 ORDER BY type'
|
||||
);
|
||||
res.json(rows.map(r => r.type));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get hardware types error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/handles
|
||||
* Get all handle variants
|
||||
*/
|
||||
export async function getHandles(req, res) {
|
||||
try {
|
||||
const { style } = req.query;
|
||||
|
||||
let sql = 'SELECT * FROM handle_variants WHERE active = 1';
|
||||
const params = [];
|
||||
|
||||
if (style) {
|
||||
sql += ' AND style = ?';
|
||||
params.push(style);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY style, name';
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
res.json(rows);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get handles error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/hinges
|
||||
* Get all hinge variants
|
||||
*/
|
||||
export async function getHinges(req, res) {
|
||||
try {
|
||||
const { type } = req.query;
|
||||
|
||||
let sql = 'SELECT * FROM hinge_variants WHERE active = 1';
|
||||
const params = [];
|
||||
|
||||
if (type) {
|
||||
sql += ' AND type = ?';
|
||||
params.push(type);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY type, name';
|
||||
|
||||
const { rows } = await pool.query(sql, params);
|
||||
res.json(rows);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get hinges error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
export async function getOrders(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, config, total_chf, email, name, phone, notes, created_at FROM orders ORDER BY created_at DESC'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function postOrder(req, res) {
|
||||
try {
|
||||
const { config, totalChf, email, name, phone, notes } = req.body || {};
|
||||
await run(
|
||||
`INSERT INTO orders (config, total_chf, email, name, phone, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[JSON.stringify(config || {}), totalChf ?? null, email ?? null, name ?? null, phone ?? null, notes ?? null]
|
||||
);
|
||||
const { rows } = await pool.query('SELECT id, created_at FROM orders ORDER BY id DESC LIMIT 1');
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Plans and cutting list routes
|
||||
*/
|
||||
import { generateAllPlans, generatePDFHtml } from '../services/planGenerator.js';
|
||||
import { generateCuttingList, formatCuttingListForExport } from '../services/cuttingEngine.js';
|
||||
|
||||
/**
|
||||
* POST /api/plans/generate
|
||||
* Generate all 2D plans for a configuration
|
||||
*/
|
||||
export async function generatePlansRoute(req, res) {
|
||||
try {
|
||||
const { config, modules = [], colors = {} } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
const plans = generateAllPlans(config, modules, colors);
|
||||
res.json({ success: true, plans });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Plan generation error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur de génération des plans' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/cutting-list
|
||||
* Generate cutting list for a configuration
|
||||
*/
|
||||
export async function generateCuttingListRoute(req, res) {
|
||||
try {
|
||||
const { config, modules = [], colors = {} } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
res.json({ success: true, cuttingList });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cutting list error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur de génération de la liste de débitage' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/export-text
|
||||
* Export cutting list as text file
|
||||
*/
|
||||
export async function exportCuttingListText(req, res) {
|
||||
try {
|
||||
const { config, modules = [], colors = {} } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const textContent = formatCuttingListForExport(cuttingList);
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="liste-debitage.txt"');
|
||||
res.send(textContent);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Export text error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur d\'export' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/plans/export-pdf
|
||||
* Export plans and cutting list as HTML (for PDF conversion)
|
||||
*/
|
||||
export async function exportPDFHtml(req, res) {
|
||||
try {
|
||||
const { config, modules = [], colors = {} } = req.body;
|
||||
|
||||
if (!config) {
|
||||
return res.status(400).json({ error: 'Configuration requise' });
|
||||
}
|
||||
|
||||
const cuttingList = generateCuttingList(config, colors, modules);
|
||||
const html = generatePDFHtml(config, modules, cuttingList);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(html);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Export PDF error:', error);
|
||||
res.status(500).json({ error: error.message || 'Erreur d\'export PDF' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import pool from '../db/index.js';
|
||||
import { calculatePrice } from '../services/priceEngine.js';
|
||||
|
||||
export async function postPrice(req, res) {
|
||||
try {
|
||||
const config = req.body || {};
|
||||
|
||||
// Fetch all required data
|
||||
const [
|
||||
products,
|
||||
blockTypes,
|
||||
legacyFinishes,
|
||||
formexMaterials,
|
||||
serviceOptions,
|
||||
settingsRows,
|
||||
modulesData,
|
||||
handlesData,
|
||||
hingesData,
|
||||
blumData
|
||||
] = await Promise.all([
|
||||
pool.query('SELECT id, code, label FROM products WHERE active = true'),
|
||||
pool.query('SELECT id, code, label, base_price_chf, width_min_mm, width_max_mm FROM block_types WHERE active = true'),
|
||||
pool.query(`
|
||||
SELECT f.id, f.code, f.label, pf.width_mm, pf.height_mm, pf.price_chf
|
||||
FROM finishes f JOIN panel_formats pf ON pf.id = f.panel_format_id WHERE f.active = true
|
||||
`),
|
||||
pool.query(`
|
||||
SELECT id, formex_code as code, name as label, name, collection, category,
|
||||
width_mm, height_mm, thickness_mm, image_url, price_chf,
|
||||
dominant_color as hexColor
|
||||
FROM formex_materials WHERE active = 1
|
||||
`),
|
||||
pool.query('SELECT id, code, label, price_chf, price_type FROM service_options WHERE active = true'),
|
||||
pool.query('SELECT key, value FROM settings'),
|
||||
pool.query('SELECT id, code, name, category, width_mm, shelves_count, drawers_count, has_rod, base_price_chf FROM modules WHERE active = 1'),
|
||||
pool.query('SELECT id, code, name, style, price_chf FROM handle_variants WHERE active = 1'),
|
||||
pool.query('SELECT id, code, name, type, price_chf FROM hinge_variants WHERE active = 1'),
|
||||
pool.query('SELECT id, code, name, type, variant, price_chf, specs_json FROM blum_hardware WHERE active = 1')
|
||||
]);
|
||||
|
||||
const settings = settingsRows.rows.map((r) => ({ key: r.key, value: r.value }));
|
||||
|
||||
// Use Formex materials if available, otherwise fall back to legacy finishes
|
||||
let finishes = legacyFinishes.rows;
|
||||
if (formexMaterials.rows.length > 0) {
|
||||
// Fetch dimension variants for Formex materials
|
||||
const materialsWithDims = await Promise.all(
|
||||
formexMaterials.rows.map(async (mat) => {
|
||||
const dims = await pool.query(
|
||||
'SELECT id, width_mm, height_mm, thickness_mm, is_default FROM formex_dimensions WHERE material_id = ?',
|
||||
[mat.id]
|
||||
);
|
||||
return {
|
||||
...mat,
|
||||
dimensions: dims.rows
|
||||
};
|
||||
})
|
||||
);
|
||||
finishes = materialsWithDims;
|
||||
}
|
||||
|
||||
const data = {
|
||||
settings,
|
||||
blockTypes: blockTypes.rows,
|
||||
finishes,
|
||||
serviceOptions: serviceOptions.rows,
|
||||
modules: modulesData.rows,
|
||||
handles: handlesData.rows,
|
||||
hinges: hingesData.rows,
|
||||
blumHardware: blumData.rows
|
||||
};
|
||||
|
||||
const result = calculatePrice(config, data);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Price calculation error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import pool from '../db/index.js';
|
||||
|
||||
export async function getProducts(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, code, label, active FROM products WHERE active = true ORDER BY id'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Room analysis API routes
|
||||
* Handles image upload and OpenAI Vision analysis
|
||||
*/
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { analyzeRoomPhoto, analysisTo3DConfig } from '../services/roomAnalyzer.js';
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Configure multer for image uploads
|
||||
const storage = multer.memoryStorage();
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 // 10MB max
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Type de fichier non supporté. Utilisez JPEG, PNG ou WebP.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Directory for storing uploaded room photos
|
||||
const UPLOADS_DIR = path.join(__dirname, '..', '..', 'client', 'public', 'rooms');
|
||||
if (!fs.existsSync(UPLOADS_DIR)) {
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware for single image upload
|
||||
*/
|
||||
export const uploadMiddleware = upload.single('image');
|
||||
|
||||
/**
|
||||
* POST /api/room/analyze
|
||||
* Upload a room photo and analyze it with GPT-4 Vision
|
||||
*/
|
||||
export async function analyzeRoom(req, res) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Aucune image fournie' });
|
||||
}
|
||||
|
||||
// Convert buffer to base64
|
||||
const imageBase64 = req.file.buffer.toString('base64');
|
||||
const mimeType = req.file.mimetype;
|
||||
|
||||
// Analyze with OpenAI
|
||||
console.log('Analyzing room photo with GPT-4 Vision...');
|
||||
const result = await analyzeRoomPhoto(imageBase64, mimeType);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: 'Échec de l\'analyse' });
|
||||
}
|
||||
|
||||
// Save the image for future reference
|
||||
const filename = `room_${Date.now()}.${mimeType.split('/')[1]}`;
|
||||
const filepath = path.join(UPLOADS_DIR, filename);
|
||||
fs.writeFileSync(filepath, req.file.buffer);
|
||||
|
||||
// Convert to 3D config
|
||||
const room3DConfig = analysisTo3DConfig(result.analysis);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
imagePath: `/rooms/${filename}`,
|
||||
analysis: result.analysis,
|
||||
room3DConfig,
|
||||
tokensUsed: result.tokensUsed
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Room analysis error:', error);
|
||||
res.status(500).json({
|
||||
error: error.message || 'Erreur lors de l\'analyse de la pièce'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/room/save
|
||||
* Save room configuration to an order
|
||||
*/
|
||||
export async function saveRoomConfig(req, res) {
|
||||
try {
|
||||
const { orderId, imagePath, analysis, room3DConfig } = req.body;
|
||||
|
||||
if (!analysis) {
|
||||
return res.status(400).json({ error: 'Configuration de pièce requise' });
|
||||
}
|
||||
|
||||
const analysisJson = JSON.stringify({ analysis, room3DConfig });
|
||||
|
||||
if (orderId) {
|
||||
// Update existing order
|
||||
await run(
|
||||
`INSERT INTO room_configs (order_id, image_path, analysis_json)
|
||||
VALUES (?, ?, ?)`,
|
||||
[orderId, imagePath || null, analysisJson]
|
||||
);
|
||||
}
|
||||
|
||||
// Return the config ID for reference
|
||||
const { rows } = await pool.query(
|
||||
'SELECT last_insert_rowid() as id'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
roomConfigId: rows[0]?.id || null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Save room config error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/room/config/:orderId
|
||||
* Get room configuration for an order
|
||||
*/
|
||||
export async function getRoomConfig(req, res) {
|
||||
try {
|
||||
const { orderId } = req.params;
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM room_configs WHERE order_id = ? ORDER BY created_at DESC LIMIT 1',
|
||||
[orderId]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Configuration non trouvée' });
|
||||
}
|
||||
|
||||
const config = rows[0];
|
||||
const parsed = JSON.parse(config.analysis_json || '{}');
|
||||
|
||||
res.json({
|
||||
id: config.id,
|
||||
orderId: config.order_id,
|
||||
imagePath: config.image_path,
|
||||
analysis: parsed.analysis,
|
||||
room3DConfig: parsed.room3DConfig,
|
||||
createdAt: config.created_at
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get room config error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OpenAI API is configured
|
||||
*/
|
||||
export function checkApiStatus(req, res) {
|
||||
const isConfigured = !!process.env.OPENAI_API_KEY;
|
||||
res.json({
|
||||
openaiConfigured: isConfigured,
|
||||
features: {
|
||||
roomAnalysis: isConfigured
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import pool from '../db/index.js';
|
||||
|
||||
export async function getServiceOptions(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT id, code, label, price_chf, price_type FROM service_options WHERE active = true ORDER BY id'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import pool from '../db/index.js';
|
||||
|
||||
export async function getSettings(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query('SELECT key, value FROM settings');
|
||||
const obj = {};
|
||||
for (const r of rows) {
|
||||
let v = r.value;
|
||||
if (typeof v === 'string' && /^\d+$/.test(v)) v = Number(v);
|
||||
obj[r.key] = v;
|
||||
}
|
||||
res.json(obj);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Sync routes for Formex crawler
|
||||
*/
|
||||
import { sync, getSyncLogs, getMaterials } from '../services/formexCrawler.js';
|
||||
import { getSchedulerStatus } from '../services/scheduler.js';
|
||||
import pool, { run } from '../db/index.js';
|
||||
|
||||
/**
|
||||
* POST /api/sync/formex
|
||||
* Trigger a manual Formex sync (non-streaming)
|
||||
*/
|
||||
export async function triggerFormexSync(req, res) {
|
||||
try {
|
||||
// Run sync (this may take a while)
|
||||
const result = await sync();
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Synchronisation terminée: ${result.itemsCount} produits`,
|
||||
itemsCount: result.itemsCount,
|
||||
duration: result.duration
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: result.error,
|
||||
itemsCount: result.itemsCount
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sync/formex/stream
|
||||
* SSE endpoint for real-time sync progress
|
||||
*/
|
||||
export async function streamFormexSync(req, res) {
|
||||
// Set SSE headers
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
||||
res.flushHeaders();
|
||||
|
||||
// Emitter function that sends SSE events
|
||||
const emit = (data) => {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
// Handle client disconnect
|
||||
req.on('close', () => {
|
||||
// Client disconnected, cleanup if needed
|
||||
});
|
||||
|
||||
try {
|
||||
// Run sync with emitter
|
||||
await sync(emit);
|
||||
} catch (err) {
|
||||
emit({ type: 'error', error: err.message });
|
||||
}
|
||||
|
||||
// Close connection
|
||||
res.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sync/logs
|
||||
* Get sync history
|
||||
*/
|
||||
export async function getLogs(req, res) {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit || '20', 10);
|
||||
const logs = await getSyncLogs(limit);
|
||||
res.json(logs);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sync/status
|
||||
* Get scheduler status
|
||||
*/
|
||||
export function getStatus(req, res) {
|
||||
const status = getSchedulerStatus();
|
||||
res.json(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/formex/materials
|
||||
* Get Formex materials with filtering and pagination
|
||||
*/
|
||||
export async function getFormexMaterials(req, res) {
|
||||
try {
|
||||
const { search, collection, category, active, page = '1', limit = '25' } = req.query;
|
||||
const pageNum = Math.max(1, parseInt(page, 10));
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(limit, 10)));
|
||||
const offset = (pageNum - 1) * limitNum;
|
||||
|
||||
// Build query with optional filters
|
||||
let whereClauses = [];
|
||||
let params = [];
|
||||
|
||||
if (search) {
|
||||
whereClauses.push('(name LIKE ? OR formex_code LIKE ?)');
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
if (collection) {
|
||||
whereClauses.push('collection = ?');
|
||||
params.push(collection);
|
||||
}
|
||||
|
||||
if (category) {
|
||||
whereClauses.push('category = ?');
|
||||
params.push(category);
|
||||
}
|
||||
|
||||
// For configurator: only active materials
|
||||
if (active === '1' || active === 'true') {
|
||||
whereClauses.push('active = 1');
|
||||
}
|
||||
|
||||
const whereSQL = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '';
|
||||
|
||||
// Get total count
|
||||
const countResult = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM formex_materials ${whereSQL}`,
|
||||
params
|
||||
);
|
||||
const total = countResult.rows[0]?.total || 0;
|
||||
|
||||
// Get paginated results
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM formex_materials ${whereSQL} ORDER BY collection, name LIMIT ? OFFSET ?`,
|
||||
[...params, limitNum, offset]
|
||||
);
|
||||
|
||||
res.json({
|
||||
materials: rows,
|
||||
pagination: {
|
||||
page: pageNum,
|
||||
limit: limitNum,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limitNum)
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/formex/materials/:id
|
||||
* Get a single material by ID with dimensions and variants
|
||||
*/
|
||||
export async function getFormexMaterial(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get main material
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Matériau non trouvé' });
|
||||
}
|
||||
|
||||
const material = rows[0];
|
||||
|
||||
// Get dimension variants
|
||||
const dimensions = await pool.query(
|
||||
'SELECT id, width_mm, height_mm, thickness_mm, is_default FROM formex_dimensions WHERE material_id = ? ORDER BY is_default DESC, width_mm',
|
||||
[id]
|
||||
);
|
||||
material.dimensions = dimensions.rows;
|
||||
|
||||
// Get color/decor variants
|
||||
const variants = await pool.query(
|
||||
'SELECT id, variant_code, variant_name, variant_url, image_url FROM formex_variants WHERE material_id = ? ORDER BY variant_name',
|
||||
[id]
|
||||
);
|
||||
material.variants = variants.rows;
|
||||
|
||||
res.json(material);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/formex/materials
|
||||
* Create a new material
|
||||
*/
|
||||
export async function createFormexMaterial(req, res) {
|
||||
try {
|
||||
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!formex_code || !name || !collection) {
|
||||
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
|
||||
}
|
||||
|
||||
// Check for duplicate code
|
||||
const existing = await pool.query(
|
||||
'SELECT id FROM formex_materials WHERE formex_code = ?',
|
||||
[formex_code]
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
return res.status(409).json({ error: 'Un matériau avec ce code existe déjà' });
|
||||
}
|
||||
|
||||
await run(
|
||||
`INSERT INTO formex_materials (formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
|
||||
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1]
|
||||
);
|
||||
|
||||
// Get the created material
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE formex_code = ?',
|
||||
[formex_code]
|
||||
);
|
||||
|
||||
res.status(201).json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/formex/materials/:id
|
||||
* Update a material
|
||||
*/
|
||||
export async function updateFormexMaterial(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { formex_code, name, collection, category, width_mm, height_mm, thickness_mm, image_url, formex_url, price_chf, active } = req.body;
|
||||
|
||||
// Check if material exists
|
||||
const existing = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
if (existing.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Matériau non trouvé' });
|
||||
}
|
||||
|
||||
// Validation
|
||||
if (!formex_code || !name || !collection) {
|
||||
return res.status(400).json({ error: 'formex_code, name et collection sont requis' });
|
||||
}
|
||||
|
||||
// Check for duplicate code (if changed)
|
||||
if (formex_code !== existing.rows[0].formex_code) {
|
||||
const duplicate = await pool.query(
|
||||
'SELECT id FROM formex_materials WHERE formex_code = ? AND id != ?',
|
||||
[formex_code, id]
|
||||
);
|
||||
if (duplicate.rows.length > 0) {
|
||||
return res.status(409).json({ error: 'Un matériau avec ce code existe déjà' });
|
||||
}
|
||||
}
|
||||
|
||||
await run(
|
||||
`UPDATE formex_materials
|
||||
SET formex_code = ?, name = ?, collection = ?, category = ?, width_mm = ?, height_mm = ?, thickness_mm = ?,
|
||||
image_url = ?, formex_url = ?, price_chf = ?, active = ?
|
||||
WHERE id = ?`,
|
||||
[formex_code, name, collection, category || null, width_mm || null, height_mm || null, thickness_mm || null,
|
||||
image_url || null, formex_url || null, price_chf || null, active !== undefined ? (active ? 1 : 0) : 1, id]
|
||||
);
|
||||
|
||||
// Get the updated material
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/formex/materials/:id
|
||||
* Delete a material
|
||||
*/
|
||||
export async function deleteFormexMaterial(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if material exists
|
||||
const existing = await pool.query(
|
||||
'SELECT * FROM formex_materials WHERE id = ?',
|
||||
[id]
|
||||
);
|
||||
if (existing.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Matériau non trouvé' });
|
||||
}
|
||||
|
||||
await run('DELETE FROM formex_materials WHERE id = ?', [id]);
|
||||
|
||||
res.json({ success: true, message: 'Matériau supprimé' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/formex/collections
|
||||
* Get distinct collection names for filter dropdown
|
||||
*/
|
||||
export async function getFormexCollections(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT collection FROM formex_materials ORDER BY collection'
|
||||
);
|
||||
res.json(rows.map(r => r.collection));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/formex/categories
|
||||
* Get distinct category names for filter dropdown
|
||||
*/
|
||||
export async function getFormexCategories(req, res) {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT DISTINCT category FROM formex_materials WHERE category IS NOT NULL ORDER BY category'
|
||||
);
|
||||
res.json(rows.map(r => r.category));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user