import cors from 'cors'; import express from 'express'; import axios from 'axios'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; import cookieParser from 'cookie-parser'; import { and, desc, eq, inArray, sql } from 'drizzle-orm'; import { orderCreateSchema, scanImeiSchema, skuMappingSchema, apiKeyCreateSchema, apiKeyUpdateSchema, loginSchema, registerSchema, changePasswordSchema } from '@localiztoi/shared'; import { getDb, getPool } from './db/index.js'; import { orderImeis, orderItems, orders, skuMappings, apiKeys, amazonPollLog, users } from './db/schema/index.js'; const app = express(); // CORS avec credentials (cookies) const CORS_ORIGIN = process.env.CORS_ORIGIN ?? 'http://localhost:3000'; const allowedOrigins = CORS_ORIGIN.split(',').map((o) => o.trim()); app.use(cors({ origin: (origin, callback) => { if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, credentials: true })); app.use(express.json()); app.use(cookieParser()); // --- JWT helpers --- const JWT_SECRET = process.env.JWT_SECRET; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN ?? '7d'; const COOKIE_SECURE = process.env.COOKIE_SECURE === 'true'; const signToken = (user) => { if (!JWT_SECRET) throw new Error('JWT_SECRET is not configured'); return jwt.sign({ sub: user.id, username: user.username, displayName: user.displayName }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN }); }; const setAuthCookie = (res, token) => { res.cookie('auth_token', token, { httpOnly: true, secure: COOKIE_SECURE, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours path: '/' }); }; const verifyToken = (token) => { if (!JWT_SECRET) return null; try { const payload = jwt.verify(token, JWT_SECRET); return { id: payload.sub, username: payload.username, displayName: payload.displayName }; } catch { return null; } }; const teltonikaBaseUrl = 'https://api.teltonika.lt'; const getTeltonikaAuthHeaders = () => { const token = process.env.TELTONIKA_FOTA_API_TOKEN; if (!token) { throw new Error('TELTONIKA_FOTA_API_TOKEN is required'); } const userAgent = process.env.TELTONIKA_FOTA_USER_AGENT; if (!userAgent) { throw new Error('TELTONIKA_FOTA_USER_AGENT is required'); } return { Authorization: `Bearer ${token}`, 'User-Agent': userAgent, Accept: 'application/json', 'Content-Type': 'application/json' }; }; const teltonikaClient = axios.create({ baseURL: teltonikaBaseUrl, timeout: 15000 }); const toMaybeInt64 = (value) => { const cleaned = value.trim(); if (!cleaned) return null; const n = Number(cleaned); if (!Number.isFinite(n)) return null; if (!Number.isInteger(n)) return null; return n; }; const isMaybeImei = (value) => { const cleaned = value.trim(); if (!cleaned) return false; if (!/^[0-9]+$/.test(cleaned)) return false; return cleaned.length === 15; }; const normalizeFotaActivityStatus = (value) => { if (value === null || typeof value === 'undefined') { return null; } if (typeof value === 'number' && Number.isFinite(value)) { return value; } if (typeof value === 'string') { const cleaned = value.trim().toLowerCase(); if (cleaned === 'online') return 2; if (cleaned === 'offline') return 1; if (cleaned === 'inactive') return 0; const asNumber = Number(cleaned); if (Number.isFinite(asNumber) && Number.isInteger(asNumber)) { return asNumber; } } return null; }; const lookupTeltonikaDeviceByImei = async (imei) => { const numericImei = toMaybeInt64(imei); if (numericImei === null) { throw new Error('invalid_imei'); } const res = await teltonikaClient.get(`/devices/${numericImei}`, { headers: getTeltonikaAuthHeaders() }); return res.data; }; const extractFirstDeviceFromPagedResult = (payload) => { if (!payload) return null; if (Array.isArray(payload)) return payload[0] ?? null; if (Array.isArray(payload.data)) return payload.data[0] ?? null; if (Array.isArray(payload.items)) return payload.items[0] ?? null; if (Array.isArray(payload.results)) return payload.results[0] ?? null; return null; }; const resolveTeltonikaDeviceByIdentifier = async (identifier) => { const cleaned = identifier.trim(); if (!cleaned) { throw new Error('invalid_identifier'); } if (isMaybeImei(cleaned)) { const numericImei = toMaybeInt64(cleaned); if (numericImei === null) { throw new Error('invalid_imei'); } const device = await lookupTeltonikaDeviceByImei(cleaned); return { imei: String(numericImei), device }; } const res = await teltonikaClient.get('/devices', { headers: getTeltonikaAuthHeaders(), params: { query: cleaned, query_field: 'serial', per_page: 1, page: 1 } }); const device = extractFirstDeviceFromPagedResult(res.data); const deviceImei = device?.imei ?? device?.device_imei; const numeric = typeof deviceImei === 'number' ? deviceImei : toMaybeInt64(String(deviceImei ?? '')); if (numeric === null) { throw new Error('device_not_found'); } return { imei: String(numeric), device }; }; app.get('/health', (_req, res) => { res.json({ ok: true }); }); // --- Auth routes --- app.post('/auth/login', async (req, res) => { const parsed = loginSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ message: 'invalid_body', issues: parsed.error.issues }); } try { const db = getDb(); const [user] = await db .select() .from(users) .where(eq(users.username, parsed.data.username)); if (!user) { return res.status(401).json({ message: 'invalid_credentials' }); } const validPassword = await bcrypt.compare(parsed.data.password, user.passwordHash); if (!validPassword) { return res.status(401).json({ message: 'invalid_credentials' }); } const authUser = { id: user.id, username: user.username, displayName: user.displayName }; const token = signToken(authUser); setAuthCookie(res, token); return res.json({ user: authUser }); } catch (err) { console.error('POST /auth/login failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.post('/auth/register', async (req, res) => { const parsed = registerSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ message: 'invalid_body', issues: parsed.error.issues }); } try { const db = getDb(); // Vérifier s'il y a déjà des utilisateurs const [{ count }] = await db.select({ count: sql `count(*)::int` }).from(users); const isFirstUser = count === 0; // Si ce n'est pas le premier utilisateur, il faut être authentifié if (!isFirstUser) { const token = req.cookies?.auth_token; const currentUser = token ? verifyToken(token) : null; if (!currentUser) { return res.status(401).json({ message: 'auth_required' }); } } const passwordHash = await bcrypt.hash(parsed.data.password, 12); const [created] = await db .insert(users) .values({ username: parsed.data.username, passwordHash, displayName: parsed.data.displayName }) .returning(); const authUser = { id: created.id, username: created.username, displayName: created.displayName }; // Si c'est le premier utilisateur, le connecter automatiquement if (isFirstUser) { const token = signToken(authUser); setAuthCookie(res, token); } return res.status(201).json({ user: authUser }); } catch (err) { const maybePgCode = err?.code; if (maybePgCode === '23505') { return res.status(409).json({ message: 'username_taken' }); } console.error('POST /auth/register failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.post('/auth/logout', (_req, res) => { res.clearCookie('auth_token', { path: '/' }); return res.json({ message: 'logged_out' }); }); app.get('/auth/me', (req, res) => { const token = req.cookies?.auth_token; if (!token) { return res.status(401).json({ message: 'not_authenticated' }); } const user = verifyToken(token); if (!user) { res.clearCookie('auth_token', { path: '/' }); return res.status(401).json({ message: 'invalid_token' }); } return res.json({ user }); }); // --- Auth middleware for /admin/* --- app.use('/admin', (req, res, next) => { const token = req.cookies?.auth_token; if (!token) { return res.status(401).json({ message: 'not_authenticated' }); } const user = verifyToken(token); if (!user) { res.clearCookie('auth_token', { path: '/' }); return res.status(401).json({ message: 'invalid_token' }); } req.user = user; next(); }); app.get('/admin/fota/filter-list', async (req, res) => { const field = String(req.query.field ?? ''); const allowedFields = new Set(['company_id', 'group_id']); if (!allowedFields.has(field)) { return res.status(400).json({ message: 'invalid_field' }); } try { const result = await teltonikaClient.get('/devices/filterList', { headers: getTeltonikaAuthHeaders(), params: { field } }); return res.json(result.data); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; const status = error?.response?.status; const data = error?.response?.data; console.error('GET /admin/fota/filter-list failed', { message, status, data }); if (message.includes('TELTONIKA_FOTA_API_TOKEN is required')) { return res.status(500).json({ message: 'missing_teltonika_token' }); } if (message.includes('TELTONIKA_FOTA_USER_AGENT is required')) { return res.status(500).json({ message: 'missing_teltonika_user_agent' }); } if (typeof status === 'number') { return res.status(502).json({ message: 'teltonika_error', status, data }); } return res.status(500).json({ message: 'internal_error', detail: message }); } }); app.get('/admin/fota/lookup', async (req, res) => { const identifier = String(req.query.identifier ?? '').trim(); if (!identifier) { return res.status(400).json({ message: 'identifier_required' }); } try { const resolved = await resolveTeltonikaDeviceByIdentifier(identifier); const device = resolved.device; return res.json({ imei: resolved.imei, fotaModel: device?.model ?? null, fotaSerial: device?.serial ?? null, fotaCurrentFirmware: device?.current_firmware ?? null, fotaActivityStatus: normalizeFotaActivityStatus(device?.activity_status), fotaSeenAt: device?.seen_at ?? null, fotaCompanyName: device?.company_name ?? null, fotaGroupName: device?.group_name ?? null }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; const status = error?.response?.status; const data = error?.response?.data; console.error('GET /admin/fota/lookup failed', { identifier, message, status, data }); if (message === 'invalid_identifier' || message === 'invalid_imei') { return res.status(400).json({ message }); } if (message === 'device_not_found') { return res.status(404).json({ message }); } if (message.includes('TELTONIKA_FOTA_API_TOKEN is required')) { return res.status(500).json({ message: 'missing_teltonika_token' }); } if (message.includes('TELTONIKA_FOTA_USER_AGENT is required')) { return res.status(500).json({ message: 'missing_teltonika_user_agent' }); } if (typeof status === 'number') { return res.status(502).json({ message: 'teltonika_error', status, data }); } return res.status(500).json({ message: 'internal_error', detail: message }); } }); app.get('/admin/sku-mappings', async (_req, res) => { try { const db = getDb(); const rows = await db.select().from(skuMappings).orderBy(skuMappings.createdAt); res.json(rows); } catch (err) { // eslint-disable-next-line no-console console.error('GET /admin/sku-mappings failed', err); res.status(500).json({ message: 'internal_error' }); } }); app.post('/admin/sku-mappings', async (req, res) => { const parsed = skuMappingSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ message: 'invalid_body', issues: parsed.error.issues }); } try { const db = getDb(); const [created] = await db .insert(skuMappings) .values({ amazonSku: parsed.data.amazonSku, enterpriseSku: parsed.data.enterpriseSku, expectedFotaModel: parsed.data.expectedFotaModel, axonautProductInternalId: parsed.data.axonautProductInternalId }) .returning(); return res.status(201).json(created); } catch (err) { // eslint-disable-next-line no-console console.error('POST /admin/sku-mappings failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.patch('/admin/sku-mappings/:id', async (req, res) => { const { id } = req.params; const patchSchema = skuMappingSchema.partial(); const parsed = patchSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ message: 'invalid_body', issues: parsed.error.issues }); } try { const db = getDb(); const [updated] = await db .update(skuMappings) .set({ ...parsed.data, updatedAt: new Date() }) .where(eq(skuMappings.id, id)) .returning(); if (!updated) { return res.status(404).json({ message: 'not_found' }); } return res.json(updated); } catch (err) { // eslint-disable-next-line no-console console.error('PATCH /admin/sku-mappings failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.delete('/admin/sku-mappings/:id', async (req, res) => { const { id } = req.params; try { const db = getDb(); const deleted = await db .delete(skuMappings) .where(eq(skuMappings.id, id)) .returning({ id: skuMappings.id }); if (deleted.length === 0) { return res.status(404).json({ message: 'not_found' }); } return res.status(204).send(); } catch (err) { // eslint-disable-next-line no-console console.error('DELETE /admin/sku-mappings failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.get('/admin/orders', async (_req, res) => { try { const db = getDb(); const rows = await db.select().from(orders).orderBy(desc(orders.createdAt)); res.json(rows); } catch (err) { // eslint-disable-next-line no-console console.error('GET /admin/orders failed', err); res.status(500).json({ message: 'internal_error' }); } }); app.post('/admin/orders', async (req, res) => { const parsed = orderCreateSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ message: 'invalid_body', issues: parsed.error.issues }); } try { const db = getDb(); const created = await db.transaction(async (tx) => { const [order] = await tx .insert(orders) .values({ orderRef: parsed.data.orderRef, status: 'new', updatedAt: new Date() }) .returning(); await tx.insert(orderItems).values(parsed.data.items.map((it) => ({ orderId: order.id, amazonSku: it.amazonSku, quantity: it.quantity, title: it.title ?? null }))); return order; }); return res.status(201).json({ id: created.id }); } catch (err) { // eslint-disable-next-line no-console console.error('POST /admin/orders failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.get('/admin/orders/:id', async (req, res) => { const { id } = req.params; try { const db = getDb(); const [order] = await db.select().from(orders).where(eq(orders.id, id)); if (!order) { return res.status(404).json({ message: 'not_found' }); } const items = await db .select() .from(orderItems) .where(and(eq(orderItems.orderId, id))) .orderBy(orderItems.createdAt); const skuList = Array.from(new Set(items.map((it) => it.amazonSku))); const mappings = skuList.length ? await db .select({ amazonSku: skuMappings.amazonSku, expectedFotaModel: skuMappings.expectedFotaModel }) .from(skuMappings) .where(inArray(skuMappings.amazonSku, skuList)) : []; const expectedModelBySku = new Map(mappings.map((m) => [m.amazonSku, m.expectedFotaModel ?? null])); const expectedFotaModels = Array.from(new Set(mappings.map((m) => (m.expectedFotaModel ?? '').trim()).filter(Boolean))); const imeis = await db .select() .from(orderImeis) .where(and(eq(orderImeis.orderId, id))) .orderBy(orderImeis.createdAt); return res.json({ ...order, items: items.map((it) => ({ ...it, expectedFotaModel: expectedModelBySku.get(it.amazonSku) ?? null })), expectedFotaModels, imeis }); } catch (err) { // eslint-disable-next-line no-console console.error('GET /admin/orders/:id failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.post('/admin/orders/:id/scan-imei', async (req, res) => { const { id } = req.params; const parsed = scanImeiSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ message: 'invalid_body', issues: parsed.error.issues }); } const identifier = parsed.data.imei.trim(); try { const db = getDb(); const [order] = await db.select({ id: orders.id }).from(orders).where(eq(orders.id, id)); if (!order) { return res.status(404).json({ message: 'not_found' }); } let resolved; try { resolved = await resolveTeltonikaDeviceByIdentifier(identifier); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; const status = error?.response?.status; const data = error?.response?.data; console.error('resolveTeltonikaDeviceByIdentifier failed', { message, status, data, identifier }); if (message.includes('TELTONIKA_FOTA_API_TOKEN is required')) { return res.status(500).json({ message: 'missing_teltonika_token' }); } if (message.includes('TELTONIKA_FOTA_USER_AGENT is required')) { return res.status(500).json({ message: 'missing_teltonika_user_agent' }); } if (message === 'invalid_identifier' || message === 'invalid_imei') { return res.status(400).json({ message }); } if (message === 'device_not_found') { return res.status(404).json({ message }); } if (typeof status === 'number') { return res.status(502).json({ message: 'teltonika_error', status, data }); } return res.status(500).json({ message: 'internal_error', detail: message }); } const { imei } = resolved; let device = resolved.device; const needsDeviceHydration = !device || typeof device !== 'object' || typeof device.model === 'undefined' || typeof device.serial === 'undefined' || typeof device.current_firmware === 'undefined' || typeof device.activity_status === 'undefined'; if (needsDeviceHydration) { try { device = await lookupTeltonikaDeviceByImei(imei); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; const status = error?.response?.status; const data = error?.response?.data; console.error('lookupTeltonikaDeviceByImei failed after resolve', { message, status, data, imei, identifier }); } } try { const [created] = await db .insert(orderImeis) .values({ orderId: id, imei }) .returning(); try { const activityStatus = normalizeFotaActivityStatus(device?.activity_status); await db .update(orderImeis) .set({ fotaModel: device?.model ?? null, fotaSerial: device?.serial ?? null, fotaCurrentFirmware: device?.current_firmware ?? null, fotaActivityStatus: activityStatus, fotaSeenAt: device?.seen_at ?? null, fotaCompanyId: device?.company_id ?? null, fotaCompanyName: device?.company_name ?? null, fotaGroupId: device?.group_id ?? null, fotaGroupName: device?.group_name ?? null, fotaLookupError: null, fotaLastLookupAt: new Date() }) .where(eq(orderImeis.id, created.id)); } catch (err) { const rawMsg = err instanceof Error ? err.message : String(err); const msg = rawMsg.includes('Failed query:') ? 'fota_lookup_failed' : rawMsg; try { await db .update(orderImeis) .set({ fotaLookupError: msg, fotaLastLookupAt: new Date() }) .where(eq(orderImeis.id, created.id)); } catch (writeErr) { console.error('Failed to persist fotaLookupError', writeErr); } } // Relire l'IMEI depuis la DB pour inclure les données FOTA mises à jour const [fresh] = await db.select().from(orderImeis).where(eq(orderImeis.id, created.id)); return res.status(201).json(fresh ?? created); } catch (err) { const maybePgCode = err?.code; if (maybePgCode === '23505') { return res.status(409).json({ message: 'already_scanned' }); } const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; console.error('Insert order imei failed', { message, code: error?.code }); return res.status(500).json({ message: 'internal_error', detail: message }); } } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; const status = error?.response?.status; const data = error?.response?.data; console.error('POST /admin/orders/:id/scan-imei failed', { message, status, data }); if (typeof status === 'number') { return res.status(502).json({ message: 'teltonika_error', status, data }); } return res.status(500).json({ message: 'internal_error', detail: message }); } }); app.post('/admin/orders/:id/fota-move', async (req, res) => { const { id } = req.params; const targetCompanyId = toMaybeInt64(process.env.TELTONIKA_FOTA_TARGET_COMPANY_ID ?? ''); const targetGroupId = toMaybeInt64(process.env.TELTONIKA_FOTA_TARGET_GROUP_ID ?? ''); if (targetCompanyId === null || targetGroupId === null) { return res.status(500).json({ message: 'missing_fota_target_ids' }); } try { const db = getDb(); const [order] = await db.select({ id: orders.id }).from(orders).where(eq(orders.id, id)); if (!order) { return res.status(404).json({ message: 'not_found' }); } const items = await db .select({ amazonSku: orderItems.amazonSku, quantity: orderItems.quantity }) .from(orderItems) .where(eq(orderItems.orderId, id)); const totalExpected = items.reduce((sum, it) => sum + it.quantity, 0); const skuList = Array.from(new Set(items.map((it) => it.amazonSku))); const mappings = skuList.length ? await db .select({ amazonSku: skuMappings.amazonSku, expectedFotaModel: skuMappings.expectedFotaModel }) .from(skuMappings) .where(inArray(skuMappings.amazonSku, skuList)) : []; const expectedModels = new Set(mappings.map((m) => (m.expectedFotaModel ?? '').trim()).filter(Boolean)); const imeis = await db .select({ id: orderImeis.id, imei: orderImeis.imei, fotaModel: orderImeis.fotaModel, fotaLookupError: orderImeis.fotaLookupError }) .from(orderImeis) .where(eq(orderImeis.orderId, id)) .orderBy(orderImeis.createdAt); if (imeis.length === 0) { return res.status(400).json({ message: 'no_imeis' }); } if (imeis.length !== totalExpected) { return res.status(400).json({ message: 'imei_count_mismatch', scanned: imeis.length, expected: totalExpected }); } const missingFotaInfo = imeis .filter((it) => { const model = (it.fotaModel ?? '').trim(); if (!model) return true; if (it.fotaLookupError) return true; return false; }) .map((it) => ({ imei: it.imei, fotaModel: it.fotaModel ?? null, fotaLookupError: it.fotaLookupError ?? null })); if (missingFotaInfo.length > 0) { return res.status(400).json({ message: 'missing_fota_info', missing: missingFotaInfo }); } if (expectedModels.size > 0) { const mismatches = imeis .filter((it) => { const model = (it.fotaModel ?? '').trim(); return !expectedModels.has(model); }) .map((it) => ({ imei: it.imei, fotaModel: it.fotaModel ?? null })); if (mismatches.length > 0) { return res.status(400).json({ message: 'fota_model_mismatch', expectedModels: Array.from(expectedModels), mismatches }); } } const idList = imeis .map((x) => toMaybeInt64(x.imei)) .filter((x) => x !== null); if (idList.length === 0) { return res.status(400).json({ message: 'invalid_imeis' }); } const result = await teltonikaClient.post('/devices/bulkUpdate', { source: 'id_list', id_list: idList, data: { company_id: targetCompanyId, group_id: targetGroupId } }, { headers: getTeltonikaAuthHeaders() }); await db .update(orderImeis) .set({ fotaMoveError: null, fotaMovedAt: new Date() }) .where(eq(orderImeis.orderId, id)); await db .update(orders) .set({ status: 'ready', updatedAt: new Date() }) .where(eq(orders.id, id)); return res.json({ ...result.data, targetCompanyId, targetGroupId, movedImeisCount: idList.length, orderStatus: 'ready' }); } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error('POST /admin/orders/:id/fota-move failed', err); try { const db = getDb(); await db .update(orderImeis) .set({ fotaMoveError: msg }) .where(eq(orderImeis.orderId, id)); } catch { // ignore } return res.status(500).json({ message: 'internal_error' }); } }); // --- Axonaut --- const getAxonautApiKey = async () => { const db = getDb(); const [row] = await db .select({ value: apiKeys.value }) .from(apiKeys) .where(and(eq(apiKeys.provider, 'axonaut'), eq(apiKeys.label, 'api_key'))); if (!row) { throw new Error('axonaut_api_key_not_configured'); } return row.value; }; const axonautClient = axios.create({ baseURL: 'https://axonaut.com/api/v2', timeout: 15000, headers: { Accept: 'application/json', 'Content-Type': 'application/json' } }); app.get('/admin/axonaut/products', async (req, res) => { try { const apiKey = await getAxonautApiKey(); const params = {}; if (req.query.product_code) params.product_code = String(req.query.product_code); if (req.query.internal_id) params.internal_id = String(req.query.internal_id); if (req.query.name) params.name = String(req.query.name); const result = await axonautClient.get('/products', { headers: { userApiKey: apiKey }, params }); return res.json(result.data); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; const status = error?.response?.status; const data = error?.response?.data; console.error('GET /admin/axonaut/products failed', { message, status, data }); if (message.includes('axonaut_api_key_not_configured')) { return res.status(500).json({ message: 'axonaut_api_key_not_configured' }); } if (typeof status === 'number') { return res.status(502).json({ message: 'axonaut_error', status, data }); } return res.status(500).json({ message: 'internal_error', detail: message }); } }); app.post('/admin/orders/:id/axonaut-destock', async (req, res) => { const { id } = req.params; try { const db = getDb(); const [order] = await db.select().from(orders).where(eq(orders.id, id)); if (!order) { return res.status(404).json({ message: 'not_found' }); } if (order.axonautDestockedAt) { return res.status(409).json({ message: 'already_destocked', destockedAt: order.axonautDestockedAt }); } let apiKey; try { apiKey = await getAxonautApiKey(); } catch { return res.status(500).json({ message: 'axonaut_api_key_not_configured' }); } const items = await db .select({ amazonSku: orderItems.amazonSku, quantity: orderItems.quantity, title: orderItems.title }) .from(orderItems) .where(eq(orderItems.orderId, id)); const skuList = Array.from(new Set(items.map((it) => it.amazonSku))); const mappings = skuList.length ? await db .select({ amazonSku: skuMappings.amazonSku, axonautProductInternalId: skuMappings.axonautProductInternalId, enterpriseSku: skuMappings.enterpriseSku }) .from(skuMappings) .where(inArray(skuMappings.amazonSku, skuList)) : []; const mappingBySku = new Map(mappings.map((m) => [m.amazonSku, m])); const missingMappings = skuList.filter((sku) => { const m = mappingBySku.get(sku); return !m || !m.axonautProductInternalId.trim(); }); if (missingMappings.length > 0) { return res.status(400).json({ message: 'missing_axonaut_product_id', skus: missingMappings }); } const imeis = await db .select({ imei: orderImeis.imei, fotaModel: orderImeis.fotaModel }) .from(orderImeis) .where(eq(orderImeis.orderId, id)) .orderBy(orderImeis.createdAt); const axonautHeaders = { userApiKey: apiKey }; // Regrouper les quantités par axonaut product internal_id const qtyByAxonautId = new Map(); for (const item of items) { const mapping = mappingBySku.get(item.amazonSku); if (!mapping) continue; const axonautId = mapping.axonautProductInternalId.trim(); const existing = qtyByAxonautId.get(axonautId); if (existing) { existing.quantity += item.quantity; existing.skus.push(item.amazonSku); } else { qtyByAxonautId.set(axonautId, { quantity: item.quantity, skus: [item.amazonSku] }); } } const stockResults = []; // Destocker chaque produit Axonaut for (const [axonautInternalId, { quantity, skus }] of qtyByAxonautId) { const productId = axonautInternalId; // Vérifier que le produit existe try { const productRes = await axonautClient.get(`/products/${productId}`, { headers: axonautHeaders }); console.log(`[axonaut] GET /products/${productId} → ${productRes.status}`); } catch (checkErr) { console.error(`[axonaut] GET /products/${productId} FAILED`, checkErr?.response?.status, checkErr?.response?.data); if (checkErr?.response?.status === 404) { await db .update(orders) .set({ axonautDestockError: `Produit Axonaut introuvable: id=${productId}`, updatedAt: new Date() }) .where(eq(orders.id, id)); return res.status(400).json({ message: 'axonaut_product_not_found', axonautInternalId }); } throw checkErr; } // Récupérer le stock actuel const stockRes = await axonautClient.get(`/products/${productId}/stock`, { headers: axonautHeaders }); const stockData = stockRes.data; console.log(`[axonaut] GET /products/${productId}/stock → `, JSON.stringify(stockData)); // Supporter les deux noms de champ possibles let currentStock; if (typeof stockData?.current_stock === 'number') { currentStock = stockData.current_stock; } else if (typeof stockData?.quantity === 'number') { currentStock = stockData.quantity; } else if (typeof stockData?.stock === 'number') { currentStock = stockData.stock; } else { // Fallback : chercher n'importe quel champ numérique qui ressemble à un stock console.error(`[axonaut] Impossible de déterminer le stock actuel. Données brutes:`, stockData); await db .update(orders) .set({ axonautDestockError: `Stock Axonaut illisible pour produit ${productId}: ${JSON.stringify(stockData)}`, updatedAt: new Date() }) .where(eq(orders.id, id)); return res.status(400).json({ message: 'axonaut_stock_unreadable', axonautInternalId, rawStockData: stockData }); } const newStock = currentStock - quantity; // Mettre à jour le stock (valeur absolue) — Axonaut attend "stock" dans le PATCH const patchBody = { stock: newStock }; console.log(`[axonaut] PATCH /products/${productId}/stock body:`, JSON.stringify(patchBody)); try { const patchRes = await axonautClient.patch(`/products/${productId}/stock`, patchBody, { headers: axonautHeaders }); console.log(`[axonaut] PATCH /products/${productId}/stock → ${patchRes.status}`, JSON.stringify(patchRes.data)); } catch (patchErr) { console.error(`[axonaut] PATCH /products/${productId}/stock FAILED`, patchErr?.response?.status, JSON.stringify(patchErr?.response?.data)); throw patchErr; } stockResults.push({ axonautInternalId, skus, previousStock: currentStock, newStock, destocked: quantity }); } // Créer la note avec les IMEI (non-bloquant : si ça échoue, le destockage reste valide) let noteCreated = false; try { const imeiLines = imeis.map((it) => { const model = it.fotaModel ? ` (${it.fotaModel})` : ''; return `- ${it.imei}${model}`; }).join('\n'); const stockSummary = stockResults.map((r) => { return `${r.skus.join(', ')}: ${r.previousStock} → ${r.newStock} (-${r.destocked})`; }).join('\n'); const noteContent = [ `Destockage commande Amazon #${order.orderRef}`, '', 'Stock modifié :', stockSummary, '', `IMEI (${imeis.length}) :`, imeiLines ].join('\n'); // company_id : body override > société Amazon par défaut const AXONAUT_AMAZON_COMPANY_ID = 45114290; const companyId = req.body?.axonaut_company_id ? Number(req.body.axonaut_company_id) : AXONAUT_AMAZON_COMPANY_ID; const eventPayload = { company_id: companyId, title: `Vente Amazon - ${order.orderRef}`, content: noteContent, nature: 6, date: new Date().toISOString(), is_done: true }; console.log(`[axonaut] POST /events body:`, JSON.stringify(eventPayload)); const eventRes = await axonautClient.post('/events', eventPayload, { headers: axonautHeaders }); console.log(`[axonaut] POST /events → ${eventRes.status}`); noteCreated = true; } catch (eventErr) { console.error(`[axonaut] POST /events FAILED (non-bloquant)`, eventErr?.response?.status, JSON.stringify(eventErr?.response?.data)); } // Marquer la commande comme destockée await db .update(orders) .set({ axonautDestockedAt: new Date(), axonautDestockError: null, updatedAt: new Date() }) .where(eq(orders.id, id)); return res.json({ message: 'destocked', stockResults, imeiCount: imeis.length, noteCreated }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; const status = error?.response?.status; const data = error?.response?.data; console.error('POST /admin/orders/:id/axonaut-destock failed', { message, status, data }); try { const db = getDb(); await db .update(orders) .set({ axonautDestockError: message, updatedAt: new Date() }) .where(eq(orders.id, id)); } catch { // ignore } if (typeof status === 'number') { return res.status(502).json({ message: 'axonaut_error', status, data }); } return res.status(500).json({ message: 'internal_error', detail: message }); } }); app.delete('/admin/orders/:orderId/imeis/:imeiId', async (req, res) => { const { orderId, imeiId } = req.params; try { const db = getDb(); const deleted = await db .delete(orderImeis) .where(and(eq(orderImeis.id, imeiId), eq(orderImeis.orderId, orderId))) .returning({ id: orderImeis.id }); if (deleted.length === 0) { return res.status(404).json({ message: 'not_found' }); } return res.status(204).send(); } catch (err) { // eslint-disable-next-line no-console console.error('DELETE /admin/orders/:orderId/imeis/:imeiId failed', err); return res.status(500).json({ message: 'internal_error' }); } }); // --- API Keys --- const maskValue = (value) => { if (value.length <= 4) return '****'; return '****' + value.slice(-4); }; app.get('/admin/api-keys', async (_req, res) => { try { const db = getDb(); const rows = await db.select().from(apiKeys).orderBy(apiKeys.provider, apiKeys.label); const masked = rows.map((row) => ({ ...row, value: maskValue(row.value) })); res.json(masked); } catch (err) { console.error('GET /admin/api-keys failed', err); res.status(500).json({ message: 'internal_error' }); } }); app.post('/admin/api-keys', async (req, res) => { const parsed = apiKeyCreateSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ message: 'invalid_body', issues: parsed.error.issues }); } try { const db = getDb(); const [created] = await db .insert(apiKeys) .values({ provider: parsed.data.provider, label: parsed.data.label, value: parsed.data.value }) .returning(); return res.status(201).json({ ...created, value: maskValue(created.value) }); } catch (err) { const maybePgCode = err?.code; if (maybePgCode === '23505') { return res.status(409).json({ message: 'duplicate_key' }); } console.error('POST /admin/api-keys failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.patch('/admin/api-keys/:id', async (req, res) => { const { id } = req.params; const parsed = apiKeyUpdateSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ message: 'invalid_body', issues: parsed.error.issues }); } try { const db = getDb(); const [updated] = await db .update(apiKeys) .set({ value: parsed.data.value, updatedAt: new Date() }) .where(eq(apiKeys.id, id)) .returning(); if (!updated) { return res.status(404).json({ message: 'not_found' }); } return res.json({ ...updated, value: maskValue(updated.value) }); } catch (err) { console.error('PATCH /admin/api-keys failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.delete('/admin/api-keys/:id', async (req, res) => { const { id } = req.params; try { const db = getDb(); const deleted = await db .delete(apiKeys) .where(eq(apiKeys.id, id)) .returning({ id: apiKeys.id }); if (deleted.length === 0) { return res.status(404).json({ message: 'not_found' }); } return res.status(204).send(); } catch (err) { console.error('DELETE /admin/api-keys failed', err); return res.status(500).json({ message: 'internal_error' }); } }); // --- Users --- app.get('/admin/users', async (_req, res) => { try { const db = getDb(); const rows = await db .select({ id: users.id, username: users.username, displayName: users.displayName, createdAt: users.createdAt }) .from(users) .orderBy(users.createdAt); return res.json(rows); } catch (err) { console.error('GET /admin/users failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.post('/admin/users', async (req, res) => { const parsed = registerSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ message: 'invalid_body', issues: parsed.error.issues }); } try { const db = getDb(); const passwordHash = await bcrypt.hash(parsed.data.password, 12); const [created] = await db .insert(users) .values({ username: parsed.data.username, passwordHash, displayName: parsed.data.displayName }) .returning({ id: users.id, username: users.username, displayName: users.displayName, createdAt: users.createdAt }); return res.status(201).json(created); } catch (err) { const maybePgCode = err?.code; if (maybePgCode === '23505') { return res.status(409).json({ message: 'username_taken' }); } console.error('POST /admin/users failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.patch('/admin/users/:id/password', async (req, res) => { const { id } = req.params; const parsed = changePasswordSchema.safeParse(req.body); if (!parsed.success) { return res.status(400).json({ message: 'invalid_body', issues: parsed.error.issues }); } try { const db = getDb(); const passwordHash = await bcrypt.hash(parsed.data.password, 12); const [updated] = await db .update(users) .set({ passwordHash, updatedAt: new Date() }) .where(eq(users.id, id)) .returning({ id: users.id }); if (!updated) { return res.status(404).json({ message: 'not_found' }); } return res.json({ message: 'password_changed' }); } catch (err) { console.error('PATCH /admin/users/:id/password failed', err); return res.status(500).json({ message: 'internal_error' }); } }); app.delete('/admin/users/:id', async (req, res) => { const { id } = req.params; if (req.user?.id === id) { return res.status(400).json({ message: 'cannot_delete_self' }); } try { const db = getDb(); const deleted = await db .delete(users) .where(eq(users.id, id)) .returning({ id: users.id }); if (deleted.length === 0) { return res.status(404).json({ message: 'not_found' }); } return res.status(204).send(); } catch (err) { console.error('DELETE /admin/users/:id failed', err); return res.status(500).json({ message: 'internal_error' }); } }); // --- Amazon SP-API --- const getAmazonCredentials = async () => { const db = getDb(); const rows = await db .select({ label: apiKeys.label, value: apiKeys.value }) .from(apiKeys) .where(eq(apiKeys.provider, 'amazon')); const map = new Map(rows.map((r) => [r.label, r.value])); const clientId = map.get('client_id'); const clientSecret = map.get('client_secret'); const refreshToken = map.get('refresh_token'); if (!clientId || !clientSecret || !refreshToken) { const missing = ['client_id', 'client_secret', 'refresh_token'].filter((k) => !map.get(k)); throw new Error(`amazon_credentials_missing: ${missing.join(', ')}`); } return { clientId, clientSecret, refreshToken }; }; const AMAZON_MARKETPLACE_FR = 'A13V1IB3VIYZZH'; const createAmazonClient = async () => { const { clientId, clientSecret, refreshToken } = await getAmazonCredentials(); const { SellingPartner } = await import('amazon-sp-api'); return new SellingPartner({ region: 'eu', refresh_token: refreshToken, credentials: { SELLING_PARTNER_APP_CLIENT_ID: clientId, SELLING_PARTNER_APP_CLIENT_SECRET: clientSecret }, options: { auto_request_throttled: true } }); }; // --- Amazon shipping address via RDT --- const splitAmazonName = (name) => { const parts = name.trim().split(/\s+/); if (parts.length <= 1) { return { firstName: '', lastName: parts[0] ?? '' }; } return { firstName: parts[0], lastName: parts.slice(1).join(' ') }; }; const fetchAmazonShippingAddress = async (sp, amazonOrderId) => { try { const rdtRes = await sp.callAPI({ operation: 'createRestrictedDataToken', endpoint: 'tokens', body: { restrictedResources: [{ method: 'GET', path: `/orders/v0/orders/${amazonOrderId}/address`, dataElements: ['shippingAddress'] }] } }); const rdt = rdtRes?.restrictedDataToken ?? rdtRes?.payload?.restrictedDataToken; if (!rdt) { console.error(`[amazon-address] No RDT returned for ${amazonOrderId}`); return null; } const addrRes = await sp.callAPI({ operation: 'getOrderAddress', endpoint: 'orders', path: { orderId: amazonOrderId }, restricted_data_token: rdt }); const address = addrRes?.payload?.ShippingAddress ?? addrRes?.ShippingAddress; return address ?? null; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[amazon-address] Failed for ${amazonOrderId}:`, msg); return null; } }; const fetchAndStoreShippingAddress = async (sp, dbOrderId, amazonOrderId) => { try { const address = await fetchAmazonShippingAddress(sp, amazonOrderId); const db = getDb(); if (!address || !address.Name) { await db .update(orders) .set({ shippingFetchError: 'no_address_returned', updatedAt: new Date() }) .where(eq(orders.id, dbOrderId)); return false; } const { firstName, lastName } = splitAmazonName(address.Name); await db .update(orders) .set({ shippingName: address.Name, shippingFirstName: firstName, shippingLastName: lastName, shippingLine1: address.AddressLine1 ?? null, shippingLine2: address.AddressLine2 ?? null, shippingLine3: address.AddressLine3 ?? null, shippingCity: address.City ?? null, shippingZipCode: address.PostalCode ?? null, shippingCountryCode: address.CountryCode ?? address.Country ?? null, shippingState: address.StateOrRegion ?? null, shippingPhone: address.Phone ?? null, shippingAddressType: address.AddressType ?? null, shippingFetchedAt: new Date(), shippingFetchError: null, updatedAt: new Date() }) .where(eq(orders.id, dbOrderId)); return true; } catch (err) { const msg = err instanceof Error ? err.message : String(err); console.error(`[amazon-address] Store failed for order ${dbOrderId}:`, msg); try { const db = getDb(); await db .update(orders) .set({ shippingFetchError: msg, updatedAt: new Date() }) .where(eq(orders.id, dbOrderId)); } catch { // ignore } return false; } }; // Test de connexion Amazon SP-API app.get('/admin/amazon/test', async (req, res) => { try { // Étape 1 : vérifier les credentials const { clientId, clientSecret, refreshToken } = await getAmazonCredentials(); // Étape 2 : tester le token LWA (obtenir un access_token) let accessTokenOk = false; let lwaError = null; try { const tokenRes = await axios.post('https://api.amazon.com/auth/o2/token', new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, client_secret: clientSecret }).toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 10000 }); accessTokenOk = !!tokenRes.data?.access_token; } catch (err) { const e = err; lwaError = e?.response?.data?.error_description ?? e?.response?.data?.error ?? e?.message ?? 'unknown'; console.error('LWA token test failed', { status: e?.response?.status, data: e?.response?.data }); } if (!accessTokenOk) { return res.status(401).json({ connected: false, step: 'lwa_token', message: 'lwa_authentication_failed', detail: lwaError }); } // Étape 3 : tester l'appel SP-API const sp = await createAmazonClient(); const response = await sp.callAPI({ operation: 'getOrders', endpoint: 'orders', query: { MarketplaceIds: [AMAZON_MARKETPLACE_FR], CreatedAfter: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() } }); const orderCount = (response?.Orders ?? response?.payload?.Orders ?? []).length; return res.json({ connected: true, marketplace: 'Amazon.fr (A13V1IB3VIYZZH)', ordersLast24h: orderCount }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; const code = error?.code ?? error?.statusCode; const body = error?.response?.data ?? error?.details; console.error('GET /admin/amazon/test failed', { message, code, body, keys: Object.keys(error ?? {}) }); if (message.includes('amazon_credentials_missing')) { return res.status(500).json({ connected: false, step: 'credentials', message }); } return res.status(502).json({ connected: false, step: 'sp_api_call', message: 'amazon_api_error', detail: message, code, body }); } }); // Liste les commandes Amazon (Unshipped par défaut) app.get('/admin/amazon/orders', async (req, res) => { try { const sp = await createAmazonClient(); const daysBack = Number(req.query.days) || 7; const createdAfter = new Date(Date.now() - daysBack * 24 * 60 * 60 * 1000).toISOString(); const statusParam = req.query.statuses; const orderStatuses = typeof statusParam === 'string' && statusParam.trim() ? statusParam.split(',').map((s) => s.trim()) : ['Unshipped', 'PartiallyShipped']; const response = await sp.callAPI({ operation: 'getOrders', endpoint: 'orders', query: { MarketplaceIds: [AMAZON_MARKETPLACE_FR], CreatedAfter: createdAfter, OrderStatuses: orderStatuses } }); const amazonOrders = response?.Orders ?? response?.payload?.Orders ?? []; return res.json({ orders: amazonOrders, count: amazonOrders.length }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; console.error('GET /admin/amazon/orders failed', { message, status: error?.statusCode, details: error?.details }); if (message.includes('amazon_credentials_missing')) { return res.status(500).json({ message }); } return res.status(502).json({ message: 'amazon_api_error', detail: message }); } }); // Récupère les articles d'une commande Amazon app.get('/admin/amazon/orders/:amazonOrderId/items', async (req, res) => { try { const sp = await createAmazonClient(); const response = await sp.callAPI({ operation: 'getOrderItems', endpoint: 'orders', path: { orderId: req.params.amazonOrderId } }); const items = response?.OrderItems ?? response?.payload?.OrderItems ?? []; return res.json({ items }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; console.error('GET /admin/amazon/orders/:id/items failed', { message }); return res.status(502).json({ message: 'amazon_api_error', detail: message }); } }); // Importe une commande Amazon dans notre base app.post('/admin/amazon/import', async (req, res) => { const { amazonOrderId } = req.body ?? {}; if (!amazonOrderId || typeof amazonOrderId !== 'string') { return res.status(400).json({ message: 'missing_amazon_order_id' }); } try { const db = getDb(); // Vérifier si la commande existe déjà const [existing] = await db .select({ id: orders.id }) .from(orders) .where(eq(orders.orderRef, amazonOrderId)); if (existing) { return res.status(409).json({ message: 'order_already_exists', orderId: existing.id }); } // Récupérer les articles depuis Amazon const sp = await createAmazonClient(); const itemsResponse = await sp.callAPI({ operation: 'getOrderItems', endpoint: 'orders', path: { orderId: amazonOrderId } }); const amazonItems = itemsResponse?.OrderItems ?? itemsResponse?.payload?.OrderItems ?? []; if (amazonItems.length === 0) { return res.status(400).json({ message: 'no_items_found' }); } // Créer la commande const [newOrder] = await db .insert(orders) .values({ orderRef: amazonOrderId, status: 'new' }) .returning(); // Créer les articles const itemValues = amazonItems.map((item) => ({ orderId: newOrder.id, amazonSku: item.SellerSKU ?? 'UNKNOWN', amazonOrderItemId: item.OrderItemId ?? null, quantity: item.QuantityOrdered ?? 1, title: item.Title ?? null })); await db.insert(orderItems).values(itemValues); // Récupérer l'adresse de livraison via RDT let addressFetched = false; try { addressFetched = await fetchAndStoreShippingAddress(sp, newOrder.id, amazonOrderId); } catch (err) { console.error(`[amazon-import] Address fetch failed for ${amazonOrderId}:`, err); } return res.status(201).json({ message: 'imported', orderId: newOrder.id, orderRef: amazonOrderId, itemCount: itemValues.length, addressFetched }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; console.error('POST /admin/amazon/import failed', { message }); if (message.includes('amazon_credentials_missing')) { return res.status(500).json({ message }); } return res.status(502).json({ message: 'amazon_api_error', detail: message }); } }); // Associer un numéro de suivi Colissimo manuellement app.post('/admin/orders/:id/tracking', async (req, res) => { const { id } = req.params; const { trackingNumber } = req.body ?? {}; if (!trackingNumber || typeof trackingNumber !== 'string' || !trackingNumber.trim()) { return res.status(400).json({ message: 'missing_tracking_number' }); } try { const db = getDb(); const [order] = await db.select({ id: orders.id }).from(orders).where(eq(orders.id, id)); if (!order) { return res.status(404).json({ message: 'not_found' }); } await db .update(orders) .set({ colissimoTrackingNumber: trackingNumber.trim(), colissimoShippedAt: new Date(), colissimoError: null, status: 'shipped', updatedAt: new Date() }) .where(eq(orders.id, id)); return res.json({ message: 'tracking_saved', trackingNumber: trackingNumber.trim() }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; console.error('POST /admin/orders/:id/tracking failed', { message }); return res.status(500).json({ message: 'internal_error', detail: message }); } }); app.delete('/admin/orders/:id/tracking', async (req, res) => { const { id } = req.params; try { const db = getDb(); const [order] = await db .select({ id: orders.id, colissimoTrackingNumber: orders.colissimoTrackingNumber, amazonTrackingConfirmedAt: orders.amazonTrackingConfirmedAt }) .from(orders) .where(eq(orders.id, id)); if (!order) { return res.status(404).json({ message: 'not_found' }); } if (!order.colissimoTrackingNumber) { return res.status(400).json({ message: 'no_tracking_to_delete' }); } if (order.amazonTrackingConfirmedAt) { return res.status(409).json({ message: 'tracking_already_confirmed_on_amazon' }); } await db .update(orders) .set({ colissimoTrackingNumber: null, colissimoShippedAt: null, colissimoLabelUrl: null, colissimoError: null, status: 'ready', updatedAt: new Date() }) .where(eq(orders.id, id)); return res.json({ message: 'tracking_deleted' }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; console.error('DELETE /admin/orders/:id/tracking failed', { message }); return res.status(500).json({ message: 'internal_error', detail: message }); } }); // Récupérer manuellement l'adresse Amazon pour une commande app.post('/admin/orders/:id/fetch-address', async (req, res) => { const { id } = req.params; try { const db = getDb(); const [order] = await db.select({ id: orders.id, orderRef: orders.orderRef }).from(orders).where(eq(orders.id, id)); if (!order) { return res.status(404).json({ message: 'not_found' }); } let sp; try { sp = await createAmazonClient(); } catch (err) { const msg = err instanceof Error ? err.message : String(err); return res.status(500).json({ message: msg }); } const success = await fetchAndStoreShippingAddress(sp, order.id, order.orderRef); if (!success) { return res.status(502).json({ message: 'address_fetch_failed' }); } // Relire la commande pour renvoyer l'adresse const [updated] = await db.select().from(orders).where(eq(orders.id, id)); return res.json({ message: 'address_fetched', shippingName: updated?.shippingName ?? null, shippingFirstName: updated?.shippingFirstName ?? null, shippingLastName: updated?.shippingLastName ?? null, shippingLine1: updated?.shippingLine1 ?? null, shippingLine2: updated?.shippingLine2 ?? null, shippingLine3: updated?.shippingLine3 ?? null, shippingCity: updated?.shippingCity ?? null, shippingZipCode: updated?.shippingZipCode ?? null, shippingCountryCode: updated?.shippingCountryCode ?? null, shippingPhone: updated?.shippingPhone ?? null }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; console.error('POST /admin/orders/:id/fetch-address failed', { message }); return res.status(500).json({ message: 'internal_error', detail: message }); } }); // Confirmer l'expédition sur Amazon (transmet le tracking Colissimo) app.post('/admin/orders/:id/amazon-confirm-shipment', async (req, res) => { const { id } = req.params; try { const db = getDb(); const [order] = await db.select().from(orders).where(eq(orders.id, id)); if (!order) { return res.status(404).json({ message: 'not_found' }); } if (!order.colissimoTrackingNumber) { return res.status(400).json({ message: 'no_tracking_number', detail: 'Générez d\'abord une étiquette Colissimo' }); } if (order.amazonTrackingConfirmedAt) { return res.status(409).json({ message: 'already_confirmed', confirmedAt: order.amazonTrackingConfirmedAt }); } // Récupérer les articles avec leurs OrderItemId Amazon const items = await db .select({ amazonOrderItemId: orderItems.amazonOrderItemId, quantity: orderItems.quantity }) .from(orderItems) .where(eq(orderItems.orderId, id)); const orderItemsForAmazon = items .filter((it) => it.amazonOrderItemId) .map((it) => ({ orderItemId: it.amazonOrderItemId, quantity: it.quantity })); if (orderItemsForAmazon.length === 0) { return res.status(400).json({ message: 'no_amazon_order_item_ids', detail: 'Les articles n\'ont pas d\'OrderItemId Amazon. Réimportez la commande.' }); } const sp = await createAmazonClient(); await sp.callAPI({ operation: 'confirmShipment', endpoint: 'orders', path: { orderId: order.orderRef }, body: { marketplaceId: AMAZON_MARKETPLACE_FR, packageDetail: { packageReferenceId: '1', carrierCode: 'Colissimo', carrierName: 'Colissimo', shippingMethod: 'Standard', trackingNumber: order.colissimoTrackingNumber, shipDate: (order.colissimoShippedAt ?? new Date()).toISOString(), orderItems: orderItemsForAmazon } } }); await db .update(orders) .set({ amazonTrackingConfirmedAt: new Date(), amazonTrackingError: null, status: 'done', updatedAt: new Date() }) .where(eq(orders.id, id)); return res.json({ message: 'shipment_confirmed', trackingNumber: order.colissimoTrackingNumber }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; console.error('POST /admin/orders/:id/amazon-confirm-shipment failed', { message }); try { const db = getDb(); await db .update(orders) .set({ amazonTrackingError: message, updatedAt: new Date() }) .where(eq(orders.id, id)); } catch { // ignore } if (message.includes('amazon_credentials_missing')) { return res.status(500).json({ message }); } return res.status(502).json({ message: 'amazon_confirm_error', detail: message }); } }); // --- Colissimo --- const COLISSIMO_BASE_URL = 'https://ws.colissimo.fr/sls-ws/SlsServiceWSRest/3.1'; const getColissimoCredentials = async () => { const db = getDb(); const rows = await db .select({ label: apiKeys.label, value: apiKeys.value }) .from(apiKeys) .where(eq(apiKeys.provider, 'laposte')); const map = new Map(rows.map((r) => [r.label, r.value])); const contractNumber = map.get('contract_number'); const password = map.get('password'); if (!contractNumber || !password) { const missing = ['contract_number', 'password'].filter((k) => !map.get(k)); throw new Error(`colissimo_credentials_missing: ${missing.join(', ')}`); } return { contractNumber, password }; }; // Parse une réponse MTOM multipart pour extraire le JSON et le PDF const parseColissimoMtomResponse = (buffer, contentType) => { const boundaryMatch = contentType.match(/boundary="?([^";]+)"?/); if (!boundaryMatch) { // Peut-être une réponse JSON simple (erreur) const text = buffer.toString('utf-8'); try { return { json: JSON.parse(text), pdf: null }; } catch { throw new Error('colissimo_invalid_response: no boundary and not JSON'); } } const boundary = boundaryMatch[1]; const raw = buffer.toString('binary'); const parts = raw.split('--' + boundary).filter((p) => p.trim() && p.trim() !== '--'); let json = null; let pdf = null; for (const part of parts) { const headerEnd = part.indexOf('\r\n\r\n'); if (headerEnd === -1) continue; const headers = part.substring(0, headerEnd).toLowerCase(); const body = part.substring(headerEnd + 4); // Retirer le trailing \r\n const cleanBody = body.replace(/\r\n$/, ''); if (headers.includes('application/json')) { try { json = JSON.parse(cleanBody); } catch { // ignore } } else if (headers.includes('application/pdf') || headers.includes('application/octet-stream')) { pdf = Buffer.from(cleanBody, 'binary'); } } return { json, pdf }; }; // Adresse expéditeur par défaut (à configurer selon votre entrepôt) const SENDER_ADDRESS = { companyName: 'Géolock', line2: '13 allée des cabanes', countryCode: 'FR', city: 'Gujan-Mestras', zipCode: '33470' }; // Génère une étiquette Colissimo pour une commande app.post('/admin/orders/:id/colissimo-label', async (req, res) => { const { id } = req.params; try { const db = getDb(); const [order] = await db.select().from(orders).where(eq(orders.id, id)); if (!order) { return res.status(404).json({ message: 'not_found' }); } if (order.colissimoTrackingNumber) { return res.status(409).json({ message: 'label_already_generated', trackingNumber: order.colissimoTrackingNumber }); } const { contractNumber, password } = await getColissimoCredentials(); // Données du destinataire : body prioritaire, fallback sur adresse stockée en DB const body = req.body ?? {}; const lastName = body.lastName || order.shippingLastName || ''; const firstName = body.firstName || order.shippingFirstName || ''; const line2 = body.line2 || order.shippingLine1 || ''; const line3 = body.line3 || order.shippingLine2 || undefined; const countryCode = body.countryCode || order.shippingCountryCode || 'FR'; const city = body.city || order.shippingCity || ''; const zipCode = body.zipCode || order.shippingZipCode || ''; const weight = body.weight ?? 0.5; const productCode = body.productCode ?? 'DOM'; if (!lastName || !line2 || !city || !zipCode) { return res.status(400).json({ message: 'missing_address_fields', required: ['lastName', 'line2', 'city', 'zipCode'] }); } const depositDate = new Date().toISOString().split('T')[0]; const colissimoPayload = { contractNumber, password, outputFormat: { x: 0, y: 0, outputPrintingType: 'PDF_10x15_300dpi' }, letter: { service: { productCode, depositDate, orderNumber: order.orderRef, commercialName: 'Localiztoi' }, parcel: { weight: Number(weight) }, sender: { address: SENDER_ADDRESS }, addressee: { address: { lastName, firstName: firstName ?? '', line2, ...(line3 ? { line3 } : {}), countryCode, city, zipCode } } } }; const response = await axios.post(`${COLISSIMO_BASE_URL}/generateLabel`, colissimoPayload, { headers: { 'Content-Type': 'application/json', Accept: '*/*' }, responseType: 'arraybuffer', timeout: 30000, validateStatus: () => true }); // Si erreur HTTP brute de Colissimo if (response.status >= 400) { const bodyText = Buffer.from(response.data).toString('utf-8').substring(0, 2000); let parsed = null; try { parsed = JSON.parse(bodyText); } catch { /* ignore */ } const errorDetail = parsed?.messages?.[0]?.messageContent ?? parsed?.message ?? bodyText; await db .update(orders) .set({ colissimoError: `HTTP ${response.status}: ${errorDetail}`, updatedAt: new Date() }) .where(eq(orders.id, id)); return res.status(response.status).json({ message: 'colissimo_label_error', httpStatus: response.status, detail: parsed ?? bodyText }); } const responseContentType = response.headers['content-type'] ?? ''; const { json: labelJson, pdf } = parseColissimoMtomResponse(Buffer.from(response.data), responseContentType); // Vérifier les erreurs const messages = labelJson?.messages ?? []; const errorMsg = messages.find((m) => m.type === 'ERROR'); if (errorMsg) { await db .update(orders) .set({ colissimoError: `${errorMsg.id}: ${errorMsg.messageContent ?? errorMsg.id}`, updatedAt: new Date() }) .where(eq(orders.id, id)); return res.status(400).json({ message: 'colissimo_label_error', error: errorMsg }); } const trackingNumber = labelJson?.labelResponse?.parcelNumber ?? null; // Sauvegarder le PDF sur le disque let labelUrl = null; if (pdf && trackingNumber) { const fs = await import('fs/promises'); const path = await import('path'); const labelsDir = path.join(process.cwd(), 'labels'); await fs.mkdir(labelsDir, { recursive: true }); const filename = `${trackingNumber}.pdf`; await fs.writeFile(path.join(labelsDir, filename), pdf); labelUrl = `/labels/${filename}`; } // Mettre à jour la commande await db .update(orders) .set({ colissimoTrackingNumber: trackingNumber, colissimoLabelUrl: labelUrl, colissimoShippedAt: new Date(), colissimoError: null, status: 'shipped', updatedAt: new Date() }) .where(eq(orders.id, id)); return res.json({ message: 'label_generated', trackingNumber, labelUrl }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; console.error('POST /admin/orders/:id/colissimo-label failed', { message, status: error?.response?.status, data: error?.response?.data?.toString?.()?.substring(0, 500) }); if (message.includes('colissimo_credentials_missing')) { return res.status(500).json({ message }); } try { const db = getDb(); await db .update(orders) .set({ colissimoError: message, updatedAt: new Date() }) .where(eq(orders.id, id)); } catch { // ignore } return res.status(502).json({ message: 'colissimo_error', detail: message }); } }); // Télécharger l'étiquette PDF app.get('/labels/:filename', async (req, res) => { try { const fs = await import('fs/promises'); const path = await import('path'); // Sécurité : empêcher path traversal const filename = path.basename(req.params.filename); if (!filename || filename !== req.params.filename || filename.includes('..')) { return res.status(400).json({ message: 'invalid_filename' }); } const labelsDir = path.resolve(process.cwd(), 'labels'); const filePath = path.join(labelsDir, filename); // Vérifier que le chemin résolu est bien dans le dossier labels if (!filePath.startsWith(labelsDir + path.sep)) { return res.status(400).json({ message: 'invalid_filename' }); } const data = await fs.readFile(filePath); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `inline; filename="${filename}"`); return res.send(data); } catch { return res.status(404).json({ message: 'label_not_found' }); } }); // Test connexion Colissimo app.get('/admin/colissimo/test', async (req, res) => { try { const { contractNumber, password } = await getColissimoCredentials(); // checkGenerateLabel = test sans générer de vrai colis const testPayload = { contractNumber, password, outputFormat: { x: 0, y: 0, outputPrintingType: 'PDF_10x15_300dpi' }, letter: { service: { productCode: 'DOM', depositDate: new Date().toISOString().split('T')[0] }, parcel: { weight: 0.5 }, sender: { address: SENDER_ADDRESS }, addressee: { address: { lastName: 'Test', line2: '1 Rue de Rivoli', countryCode: 'FR', city: 'Paris', zipCode: '75001' } } } }; const response = await axios.post(`${COLISSIMO_BASE_URL}/checkGenerateLabel`, testPayload, { headers: { 'Content-Type': 'application/json', Accept: '*/*' }, responseType: 'arraybuffer', timeout: 15000, validateStatus: () => true }); const responseContentType = response.headers['content-type'] ?? ''; const responseBuffer = Buffer.from(response.data); // Si erreur HTTP, tenter de lire le body brut if (response.status >= 400) { let bodyText = responseBuffer.toString('utf-8').substring(0, 2000); let parsed = null; try { parsed = JSON.parse(bodyText); } catch { /* ignore */ } return res.status(response.status).json({ connected: false, httpStatus: response.status, detail: parsed ?? bodyText }); } const { json: labelJson } = parseColissimoMtomResponse(responseBuffer, responseContentType); const messages = labelJson?.messages ?? []; const hasError = messages.some((m) => m.type === 'ERROR'); if (hasError) { return res.status(400).json({ connected: false, messages }); } return res.json({ connected: true, messages }); } catch (err) { const error = err; const message = error?.message ? String(error.message) : 'unknown_error'; console.error('GET /admin/colissimo/test failed', { message }); if (message.includes('colissimo_credentials_missing')) { return res.status(500).json({ connected: false, message }); } return res.status(502).json({ connected: false, message: 'colissimo_error', detail: message }); } }); // --- SSE (Server-Sent Events) --- const sseClients = new Set(); app.get('/admin/events', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders(); res.write('data: {"type":"connected"}\n\n'); sseClients.add(res); req.on('close', () => { sseClients.delete(res); }); }); const broadcastSSE = (eventType, data) => { const payload = JSON.stringify({ type: eventType, ...data }); for (const client of sseClients) { try { client.write(`data: ${payload}\n\n`); } catch { sseClients.delete(client); } } }; // --- Amazon Polling --- const POLL_INTERVAL_MS = Number(process.env.AMAZON_POLL_INTERVAL_MS) || 5 * 60 * 1000; let lastPollAt = null; let pollRunning = false; const pollAmazonOrders = async () => { if (pollRunning) { console.log('[amazon-poll] Skipping, previous poll still running'); return; } pollRunning = true; const startedAt = new Date(); let ordersFound = 0; let ordersImported = 0; let pollError = null; try { const db = getDb(); let sp; try { sp = await createAmazonClient(); } catch { // Pas de credentials configurées — skip silencieux pollRunning = false; return; } const createdAfter = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); const response = await sp.callAPI({ operation: 'getOrders', endpoint: 'orders', query: { MarketplaceIds: [AMAZON_MARKETPLACE_FR], CreatedAfter: createdAfter, OrderStatuses: ['Unshipped', 'PartiallyShipped'] } }); const amazonOrders = response?.Orders ?? response?.payload?.Orders ?? []; ordersFound = amazonOrders.length; for (const amazonOrder of amazonOrders) { const amazonOrderId = amazonOrder.AmazonOrderId; if (!amazonOrderId) continue; const [existing] = await db .select({ id: orders.id }) .from(orders) .where(eq(orders.orderRef, amazonOrderId)); if (existing) continue; try { const itemsResponse = await sp.callAPI({ operation: 'getOrderItems', endpoint: 'orders', path: { orderId: amazonOrderId } }); const amazonItems = itemsResponse?.OrderItems ?? itemsResponse?.payload?.OrderItems ?? []; if (amazonItems.length === 0) continue; const [newOrder] = await db .insert(orders) .values({ orderRef: amazonOrderId, status: 'new' }) .returning(); await db.insert(orderItems).values(amazonItems.map((item) => ({ orderId: newOrder.id, amazonSku: item.SellerSKU ?? 'UNKNOWN', amazonOrderItemId: item.OrderItemId ?? null, quantity: item.QuantityOrdered ?? 1, title: item.Title ?? null }))); ordersImported++; console.log(`[amazon-poll] Imported order ${amazonOrderId} -> ${newOrder.id}`); broadcastSSE('amazon-order-imported', { orderId: newOrder.id, orderRef: amazonOrderId, itemCount: amazonItems.length }); // Récupérer l'adresse de livraison try { await fetchAndStoreShippingAddress(sp, newOrder.id, amazonOrderId); } catch (addrErr) { console.error(`[amazon-poll] Address fetch failed for ${amazonOrderId}:`, addrErr); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); // Doublon concurrent (import manuel + poll simultané) if (err?.code === '23505') continue; console.error(`[amazon-poll] Failed to import ${amazonOrderId}:`, msg); } } lastPollAt = new Date(); if (ordersImported > 0) { broadcastSSE('amazon-poll-complete', { ordersFound, ordersImported }); } } catch (err) { const msg = err instanceof Error ? err.message : String(err); pollError = msg; console.error('[amazon-poll] Poll failed:', msg); } finally { try { const db = getDb(); await db.insert(amazonPollLog).values({ startedAt, finishedAt: new Date(), ordersFound, ordersImported, error: pollError }); } catch (logErr) { console.error('[amazon-poll] Failed to write poll log:', logErr); } pollRunning = false; } }; // Statut du polling Amazon app.get('/admin/amazon/poll-status', async (_req, res) => { try { const db = getDb(); const [lastLog] = await db .select() .from(amazonPollLog) .orderBy(desc(amazonPollLog.startedAt)) .limit(1); return res.json({ enabled: true, intervalMs: POLL_INTERVAL_MS, pollRunning, lastPollAt, lastLog: lastLog ?? null }); } catch (err) { console.error('GET /admin/amazon/poll-status failed', err); return res.status(500).json({ message: 'internal_error' }); } }); // Forcer un poll immédiat app.post('/admin/amazon/poll-now', async (_req, res) => { if (pollRunning) { return res.status(409).json({ message: 'poll_already_running' }); } void pollAmazonOrders(); return res.json({ message: 'poll_triggered' }); }); // --- Start --- const port = Number(process.env.PORT ?? 4000); const server = app.listen(port, () => { console.log(`API listening on port ${port}`); // Démarrer le polling Amazon après 30s setTimeout(() => { console.log(`[amazon-poll] Starting polling, interval: ${POLL_INTERVAL_MS}ms`); void pollAmazonOrders(); setInterval(() => void pollAmazonOrders(), POLL_INTERVAL_MS); }, 30_000); }); // Graceful shutdown const shutdown = async (signal) => { console.log(`\n[shutdown] ${signal} received, closing...`); server.close(); const pool = getPool(); if (pool) { await pool.end(); } process.exit(0); }; process.on('SIGTERM', () => void shutdown('SIGTERM')); process.on('SIGINT', () => void shutdown('SIGINT'));