stock.geolock.fr/apps/api/dist/index.js
2026-02-24 16:10:30 +00:00

2377 lines
89 KiB
JavaScript

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'));