From 3e2501721abbf43485437b6907cf0d201ed749bf Mon Sep 17 00:00:00 2001 From: oimwiodev Date: Sun, 31 May 2026 21:35:50 +0100 Subject: [PATCH] Add Hermes API and MCP access --- mini-services/hermes-mcp/README.md | 50 +++++ mini-services/hermes-mcp/server.mjs | 242 +++++++++++++++++++++++ package.json | 3 +- src/app/api/hermes/clients/route.ts | 76 +++++++ src/app/api/hermes/products/route.ts | 48 +++++ src/app/api/hermes/sales/repair/route.ts | 125 ++++++++++++ src/app/api/hermes/status/route.ts | 16 ++ src/app/api/hermes/summary/route.ts | 66 +++++++ src/lib/hermes-auth.ts | 27 +++ src/proxy.ts | 1 + 10 files changed, 653 insertions(+), 1 deletion(-) create mode 100644 mini-services/hermes-mcp/README.md create mode 100644 mini-services/hermes-mcp/server.mjs create mode 100644 src/app/api/hermes/clients/route.ts create mode 100644 src/app/api/hermes/products/route.ts create mode 100644 src/app/api/hermes/sales/repair/route.ts create mode 100644 src/app/api/hermes/status/route.ts create mode 100644 src/app/api/hermes/summary/route.ts create mode 100644 src/lib/hermes-auth.ts diff --git a/mini-services/hermes-mcp/README.md b/mini-services/hermes-mcp/README.md new file mode 100644 index 0000000..08483d3 --- /dev/null +++ b/mini-services/hermes-mcp/README.md @@ -0,0 +1,50 @@ +# OptiqueStock Hermes MCP + +This MCP server gives a Hermes agent safe, token-scoped access to OptiqueStock. + +## Environment + +Set the same key on the Next.js app and the MCP server: + +```bash +HERMES_API_KEY=change-me +OPTICZ_API_BASE=http://192.168.1.30:3000 +``` + +In local development only, the fallback key is: + +```text +hermes-demo-key-change-me +``` + +## Run + +```bash +node mini-services/hermes-mcp/server.mjs +``` + +## MCP Tools + +- `opticz_status` +- `opticz_summary` +- `opticz_search_clients` +- `opticz_search_products` +- `opticz_create_client` +- `opticz_create_repair_sale` + +## Direct REST API + +All endpoints require: + +```http +Authorization: Bearer +``` + +Available endpoints: + +- `GET /api/hermes/status` +- `GET /api/hermes/summary` +- `GET /api/hermes/clients?q=amina` +- `POST /api/hermes/clients` +- `GET /api/hermes/products?q=monture` +- `POST /api/hermes/sales/repair` diff --git a/mini-services/hermes-mcp/server.mjs b/mini-services/hermes-mcp/server.mjs new file mode 100644 index 0000000..58c2027 --- /dev/null +++ b/mini-services/hermes-mcp/server.mjs @@ -0,0 +1,242 @@ +#!/usr/bin/env node + +const apiBase = (process.env.OPTICZ_API_BASE || 'http://127.0.0.1:3000').replace(/\/$/, '') +const apiKey = process.env.HERMES_API_KEY || 'hermes-demo-key-change-me' + +const tools = [ + { + name: 'opticz_status', + description: 'Check OptiqueStock Hermes API health.', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'opticz_summary', + description: 'Get store counts, low stock products, pending workshop count, and recent sales.', + inputSchema: { + type: 'object', + properties: {}, + }, + }, + { + name: 'opticz_search_clients', + description: 'Search OptiqueStock clients by name, phone, or email.', + inputSchema: { + type: 'object', + properties: { + q: { type: 'string', description: 'Search query.' }, + limit: { type: 'number', description: 'Maximum results, default 20, max 50.' }, + }, + }, + }, + { + name: 'opticz_search_products', + description: 'Search active products by reference, designation, brand, or category.', + inputSchema: { + type: 'object', + properties: { + q: { type: 'string', description: 'Search query.' }, + category: { type: 'string', description: 'Optional category filter, e.g. MONTURE or VERRE.' }, + inStockOnly: { type: 'boolean', description: 'Only include products with stock. Defaults true.' }, + limit: { type: 'number', description: 'Maximum results, default 25, max 75.' }, + }, + }, + }, + { + name: 'opticz_create_client', + description: 'Create a client, or return the existing client when the phone number already exists.', + inputSchema: { + type: 'object', + required: ['nom', 'prenom', 'telephone'], + properties: { + nom: { type: 'string' }, + prenom: { type: 'string' }, + telephone: { type: 'string' }, + email: { type: 'string' }, + adresse: { type: 'string' }, + ville: { type: 'string' }, + codePostal: { type: 'string' }, + notes: { type: 'string' }, + }, + }, + }, + { + name: 'opticz_create_repair_sale', + description: 'Create a paid repair sale for an existing client.', + inputSchema: { + type: 'object', + required: ['clientId', 'repairType', 'repairDescription', 'repairPrice'], + properties: { + clientId: { type: 'string' }, + repairType: { type: 'string' }, + repairDescription: { type: 'string' }, + repairPrice: { type: 'number' }, + additionalCharges: { type: 'number' }, + paymentMode: { + type: 'string', + enum: ['ESPECES', 'CARTE', 'CHEQUE', 'VIREMENT', 'BON_CAISSE'], + }, + }, + }, + }, +] + +async function api(path, options = {}) { + const response = await fetch(`${apiBase}${path}`, { + ...options, + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + }) + const text = await response.text() + const data = text ? JSON.parse(text) : null + + if (!response.ok) { + throw new Error(data?.error || `HTTP ${response.status}`) + } + + return data +} + +function query(params) { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(params || {})) { + if (value !== undefined && value !== null && value !== '') { + search.set(key, String(value)) + } + } + const value = search.toString() + return value ? `?${value}` : '' +} + +async function callTool(name, args) { + switch (name) { + case 'opticz_status': + return api('/api/hermes/status') + case 'opticz_summary': + return api('/api/hermes/summary') + case 'opticz_search_clients': + return api(`/api/hermes/clients${query(args)}`) + case 'opticz_search_products': + return api(`/api/hermes/products${query(args)}`) + case 'opticz_create_client': + return api('/api/hermes/clients', { + method: 'POST', + body: JSON.stringify(args || {}), + }) + case 'opticz_create_repair_sale': + return api('/api/hermes/sales/repair', { + method: 'POST', + body: JSON.stringify(args || {}), + }) + default: + throw new Error(`Unknown tool: ${name}`) + } +} + +function send(message) { + process.stdout.write(`${JSON.stringify(message)}\n`) +} + +async function handle(message) { + const { id, method, params } = message + + if (method === 'initialize') { + send({ + jsonrpc: '2.0', + id, + result: { + protocolVersion: params?.protocolVersion || '2024-11-05', + capabilities: { + tools: {}, + }, + serverInfo: { + name: 'opticz-hermes-mcp', + version: '0.1.0', + }, + }, + }) + return + } + + if (method === 'notifications/initialized') return + + if (method === 'tools/list') { + send({ + jsonrpc: '2.0', + id, + result: { tools }, + }) + return + } + + if (method === 'tools/call') { + try { + const result = await callTool(params?.name, params?.arguments || {}) + send({ + jsonrpc: '2.0', + id, + result: { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }, + }) + } catch (error) { + send({ + jsonrpc: '2.0', + id, + result: { + isError: true, + content: [ + { + type: 'text', + text: error instanceof Error ? error.message : 'Unknown error', + }, + ], + }, + }) + } + return + } + + send({ + jsonrpc: '2.0', + id, + error: { + code: -32601, + message: `Unsupported method: ${method}`, + }, + }) +} + +let buffer = '' +process.stdin.setEncoding('utf8') +process.stdin.on('data', (chunk) => { + buffer += chunk + const lines = buffer.split(/\r?\n/) + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) continue + try { + handle(JSON.parse(line)) + } catch (error) { + send({ + jsonrpc: '2.0', + id: null, + error: { + code: -32700, + message: error instanceof Error ? error.message : 'Parse error', + }, + }) + } + } +}) diff --git a/package.json b/package.json index fea8893..ed66dac 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "db:push": "prisma db push", "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", - "db:reset": "prisma migrate reset" + "db:reset": "prisma migrate reset", + "hermes:mcp": "node mini-services/hermes-mcp/server.mjs" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/src/app/api/hermes/clients/route.ts b/src/app/api/hermes/clients/route.ts new file mode 100644 index 0000000..b9f598a --- /dev/null +++ b/src/app/api/hermes/clients/route.ts @@ -0,0 +1,76 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { requireHermesAccess } from '@/lib/hermes-auth' + +export async function GET(request: NextRequest) { + const authError = requireHermesAccess(request) + if (authError) return authError + + const searchParams = request.nextUrl.searchParams + const q = (searchParams.get('q') || '').trim() + const limit = Math.min(Number(searchParams.get('limit') || 20), 50) + + const clients = await db.client.findMany({ + where: q + ? { + OR: [ + { nom: { contains: q } }, + { prenom: { contains: q } }, + { telephone: { contains: q } }, + { email: { contains: q } }, + ], + } + : undefined, + orderBy: [{ nom: 'asc' }, { prenom: 'asc' }], + take: limit, + select: { + id: true, + nom: true, + prenom: true, + email: true, + telephone: true, + ville: true, + codePostal: true, + createdAt: true, + }, + }) + + return NextResponse.json({ clients }) +} + +export async function POST(request: NextRequest) { + const authError = requireHermesAccess(request) + if (authError) return authError + + const body = await request.json() + const nom = String(body.nom || '').trim() + const prenom = String(body.prenom || '').trim() + const telephone = String(body.telephone || '').trim() + + if (!nom || !prenom || !telephone) { + return NextResponse.json( + { error: 'nom, prenom and telephone are required' }, + { status: 400 } + ) + } + + const existingClient = await db.client.findUnique({ where: { telephone } }) + if (existingClient) { + return NextResponse.json({ client: existingClient, existed: true }) + } + + const client = await db.client.create({ + data: { + nom, + prenom, + telephone, + email: body.email || null, + adresse: body.adresse || null, + ville: body.ville || null, + codePostal: body.codePostal || null, + notes: body.notes || null, + }, + }) + + return NextResponse.json({ client, existed: false }, { status: 201 }) +} diff --git a/src/app/api/hermes/products/route.ts b/src/app/api/hermes/products/route.ts new file mode 100644 index 0000000..c77dad8 --- /dev/null +++ b/src/app/api/hermes/products/route.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { requireHermesAccess } from '@/lib/hermes-auth' + +export async function GET(request: NextRequest) { + const authError = requireHermesAccess(request) + if (authError) return authError + + const searchParams = request.nextUrl.searchParams + const q = (searchParams.get('q') || '').trim() + const category = (searchParams.get('category') || '').trim() + const inStockOnly = searchParams.get('inStockOnly') !== 'false' + const limit = Math.min(Number(searchParams.get('limit') || 25), 75) + + const products = await db.produit.findMany({ + where: { + actif: true, + ...(inStockOnly ? { stock: { gt: 0 } } : {}), + ...(category ? { categorie: category } : {}), + ...(q + ? { + OR: [ + { reference: { contains: q } }, + { designation: { contains: q } }, + { marque: { contains: q } }, + { categorie: { contains: q } }, + ], + } + : {}), + }, + orderBy: [{ categorie: 'asc' }, { designation: 'asc' }], + take: limit, + select: { + id: true, + reference: true, + designation: true, + categorie: true, + marque: true, + prixVenteTTC: true, + tva: true, + stock: true, + stockMin: true, + emplacement: true, + }, + }) + + return NextResponse.json({ products }) +} diff --git a/src/app/api/hermes/sales/repair/route.ts b/src/app/api/hermes/sales/repair/route.ts new file mode 100644 index 0000000..90d3602 --- /dev/null +++ b/src/app/api/hermes/sales/repair/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from 'next/server' +import { ModePaiement } from '@prisma/client' +import { db } from '@/lib/db' +import { requireHermesAccess } from '@/lib/hermes-auth' + +const SERVICE_TVA = 20 + +async function generateSaleNumber(): Promise { + const today = new Date() + const year = today.getFullYear() + const month = String(today.getMonth() + 1).padStart(2, '0') + const salesThisMonth = await db.vente.count({ + where: { + date: { + gte: new Date(year, today.getMonth(), 1), + lt: new Date(year, today.getMonth() + 1, 1), + }, + }, + }) + + return `H${year}${month}${String(salesThisMonth + 1).padStart(4, '0')}` +} + +function toHt(ttc: number) { + return ttc / (1 + SERVICE_TVA / 100) +} + +async function getRepairProduct() { + return db.produit.upsert({ + where: { reference: 'HERMES_SERVICE_REPAIR' }, + update: { + designation: 'Hermes service reparation', + actif: true, + stock: { increment: 1 }, + }, + create: { + reference: 'HERMES_SERVICE_REPAIR', + designation: 'Hermes service reparation', + categorie: 'SERVICE', + prixAchatHT: 0, + prixVenteTTC: 0, + tva: SERVICE_TVA, + stock: 999999, + stockMin: 0, + actif: true, + }, + }) +} + +export async function POST(request: NextRequest) { + const authError = requireHermesAccess(request) + if (authError) return authError + + const body = await request.json() + const clientId = String(body.clientId || '').trim() + const repairType = String(body.repairType || '').trim() + const repairDescription = String(body.repairDescription || '').trim() + const repairPrice = Math.max(0, Number(body.repairPrice || 0)) + const additionalCharges = Math.max(0, Number(body.additionalCharges || 0)) + const paymentMode = (body.paymentMode || 'ESPECES') as ModePaiement + + if (!clientId || !repairType || !repairDescription) { + return NextResponse.json( + { error: 'clientId, repairType and repairDescription are required' }, + { status: 400 } + ) + } + + const client = await db.client.findUnique({ where: { id: clientId } }) + if (!client) { + return NextResponse.json({ error: 'Client not found' }, { status: 404 }) + } + + const repairProduct = await getRepairProduct() + const totalTTC = repairPrice + additionalCharges + const totalHT = toHt(totalTTC) + const numero = await generateSaleNumber() + + const sale = await db.vente.create({ + data: { + numero, + clientId, + statut: 'PAYEE', + montantHT: totalHT, + montantTVA: totalTTC - totalHT, + montantTTC: totalTTC, + notes: [ + `Hermes repair: ${repairType}`, + `Description: ${repairDescription}`, + additionalCharges > 0 ? `Additional charges: ${additionalCharges.toFixed(2)}` : '', + ] + .filter(Boolean) + .join('\n'), + lignes: { + create: { + produitId: repairProduct.id, + quantite: 1, + prixUnitaireHT: totalHT, + prixUnitaireTTC: totalTTC, + remise: 0, + montantHT: totalHT, + montantTTC: totalTTC, + }, + }, + paiements: { + create: { + mode: paymentMode, + montant: totalTTC, + reference: 'HERMES', + }, + }, + }, + include: { + client: true, + lignes: { + include: { + produit: true, + }, + }, + paiements: true, + }, + }) + + return NextResponse.json({ sale }, { status: 201 }) +} diff --git a/src/app/api/hermes/status/route.ts b/src/app/api/hermes/status/route.ts new file mode 100644 index 0000000..4096734 --- /dev/null +++ b/src/app/api/hermes/status/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { requireHermesAccess } from '@/lib/hermes-auth' + +export async function GET(request: NextRequest) { + const authError = requireHermesAccess(request) + if (authError) return authError + + await db.$queryRaw`SELECT 1` + + return NextResponse.json({ + ok: true, + service: 'OptiqueStock Hermes API', + time: new Date().toISOString(), + }) +} diff --git a/src/app/api/hermes/summary/route.ts b/src/app/api/hermes/summary/route.ts new file mode 100644 index 0000000..5ea64c4 --- /dev/null +++ b/src/app/api/hermes/summary/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { requireHermesAccess } from '@/lib/hermes-auth' + +export async function GET(request: NextRequest) { + const authError = requireHermesAccess(request) + if (authError) return authError + + const [clients, activeProducts, lowStock, pendingWorkshop, sales] = await Promise.all([ + db.client.count(), + db.produit.count({ where: { actif: true } }), + db.produit.findMany({ + where: { actif: true }, + orderBy: { stock: 'asc' }, + take: 10, + select: { + id: true, + reference: true, + designation: true, + categorie: true, + stock: true, + stockMin: true, + prixVenteTTC: true, + }, + }), + db.vente.count({ + where: { + statutAtelier: { + in: ['EN_ATTENTE', 'EN_COURS', 'TERMINE', 'PRET'], + }, + }, + }), + db.vente.findMany({ + orderBy: { date: 'desc' }, + take: 8, + include: { + client: { + select: { + id: true, + nom: true, + prenom: true, + telephone: true, + }, + }, + }, + }), + ]) + + return NextResponse.json({ + counts: { + clients, + activeProducts, + pendingWorkshop, + }, + lowStock: lowStock.filter((item) => item.stock <= item.stockMin), + recentSales: sales.map((sale) => ({ + id: sale.id, + numero: sale.numero, + date: sale.date, + statut: sale.statut, + statutAtelier: sale.statutAtelier, + montantTTC: sale.montantTTC, + client: sale.client, + })), + }) +} diff --git a/src/lib/hermes-auth.ts b/src/lib/hermes-auth.ts new file mode 100644 index 0000000..d646638 --- /dev/null +++ b/src/lib/hermes-auth.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from 'next/server' + +const DEV_HERMES_KEY = 'hermes-demo-key-change-me' + +export function getHermesApiKey() { + return process.env.HERMES_API_KEY || (process.env.NODE_ENV !== 'production' ? DEV_HERMES_KEY : '') +} + +export function requireHermesAccess(request: NextRequest) { + const expectedKey = getHermesApiKey() + const authHeader = request.headers.get('authorization') || '' + const bearerKey = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '' + const headerKey = request.headers.get('x-hermes-key') || '' + const providedKey = bearerKey || headerKey + + if (!expectedKey || providedKey !== expectedKey) { + return NextResponse.json( + { + error: 'Hermes access denied', + hint: 'Send Authorization: Bearer or x-hermes-key.', + }, + { status: 401 } + ) + } + + return null +} diff --git a/src/proxy.ts b/src/proxy.ts index bddf5c9..b4495be 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -7,6 +7,7 @@ export async function proxy(request: NextRequest) { if ( pathname.startsWith('/login') || pathname.startsWith('/api/auth') || + pathname.startsWith('/api/hermes') || pathname.startsWith('/_next/static') || pathname.startsWith('/_next/image') || pathname === '/favicon.ico'