2377 lines
89 KiB
JavaScript
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'));
|